diff --git a/README.md b/README.md index 50550342..15fca3fe 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ Specific people whose work directly shaped parts of projectMM. We study their th - **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. - **[FastLED](https://github.com/FastLED/FastLED)** — the canonical LED-effects library whose conventions the LED-effect world shares. projectMM links no part of FastLED, but it carries forward FastLED's recognisable *names and models* for the colour/animation primitives — `scale8`, `sin8`, the gradient-palette model (`CRGBPalette16` / `colorFromPalette`), the `beatsin8` / `inoise8` / `qadd8` family — so a contributor recognises them on sight. The implementations are projectMM's own, integer-only and hot-path-tuned for our render loop; FastLED is the prior art behind the convention, credited here and in each primitive's notes. +- **wladi ([myhome-control](https://shop.myhome-control.de))** — designer of the [MHC-WLED ESP32-P4 shield](https://shop.myhome-control.de/en/ABC-WLED-ESP32-P4-shield/HW10027), and the source of the hardware and the pinout details that got its **line-in audio** working in [AudioModule](docs/moonmodules/core/AudioModule.md): the onboard PCM1808 I2S ADC (WS 26 / SD 33 / SCK 32 / MCLK 36), the PCM1808's stereo wiring, and its `FMT` format-select jumper (open = I2S/Philips, our default; tie to 3V3 for left-justified) — which is what confirmed the standard-I2S path the ADC needs. ## Contributing diff --git a/docs/assets/boards/abc-wled-esp32-p4-shield.jpg b/docs/assets/boards/abc-wled-esp32-p4-shield.jpg deleted file mode 100644 index fffe9637..00000000 Binary files a/docs/assets/boards/abc-wled-esp32-p4-shield.jpg and /dev/null differ diff --git a/docs/assets/boards/mhc-wled-esp32-p4-shield.jpg b/docs/assets/boards/mhc-wled-esp32-p4-shield.jpg new file mode 100644 index 00000000..e45618ce Binary files /dev/null and b/docs/assets/boards/mhc-wled-esp32-p4-shield.jpg differ diff --git a/docs/backlog/moonlight_images/moonlight/drivers/IRDriver.jpeg b/docs/assets/core/IrModule.jpeg similarity index 100% rename from docs/backlog/moonlight_images/moonlight/drivers/IRDriver.jpeg rename to docs/assets/core/IrModule.jpeg diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 87f96757..b8b5d696 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -150,6 +150,10 @@ No FreeRTOS tasks are pinned today. At 16K LEDs the render task takes ~52 ms/tic ## Architecture +### Consolidate the two module-by-name tree-walkers (backlog) + +`HttpServerModule::findModuleByName` (`findInTree` recursion) and `Scheduler::firstByName` (`firstInTree`) are two implementations of the same "find the first module in tree-walk order with this name" operation. The duplication predates the `setControl`-to-Scheduler extraction, but that extraction made `Scheduler::firstByName` public *and* added `Scheduler::instance()`, so HttpServer no longer needs its own copy: its ~9 remaining call sites (identify, addModule, removeModule, clearChildren, the WLED/system shims) can call `scheduler_->firstByName()` and the private `findModuleByName`/`findInTree` pair deletes. Purely a *No duplication* cleanup — no behaviour change — worth doing so the walk order/semantics live in exactly one place. (Reviewer note, IrModule/setControl branch.) + ### Disabling a module should release its resources, not just stop its loop (backlog) Today `setEnabled(false)` only makes the Scheduler skip the module's `loop`/`loop1s`/`loop20ms` callbacks (gated via `respectsEnabled()`/`enabled()` in `MoonModule`/`Scheduler`). The module still **holds whatever it acquired**: AudioModule keeps its I2S channel open, an LED driver keeps its RMT/LCD/Parlio peripheral + DMA buffers, NetworkSendDriver keeps its socket. So a disabled module stops *acting* but doesn't *free* — which is fine for a quick mute (a non-ticking module can't pollute a perf measurement, the use case that surfaced this), but wrong if "disabled" should mean "give the pins/peripheral/memory back so another module can use them, or so a mic-less reconfig works." The mechanism for this already exists — `MoonModule::onEnabledChanged()` (a no-op hook today) is exactly where a module should deinit/reinit its resource on the flip. Work: audit every resource-holding module (AudioModule, the LED drivers, NetworkSend/Receive, anything with a socket/peripheral/large buffer) and implement `onEnabledChanged()` to release on disable + re-acquire on enable, mirroring what `setup()`/`teardown()` do. Decide the contract: does disable free the buffer (cheaper RAM, slower re-enable) or keep it (instant re-enable, holds RAM)? Probably per-module. Pin controls becoming the standard `Pin` type (just landed) is a related enabler — a disabled driver releasing its pins lets the same GPIO be reassigned live. diff --git a/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png b/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png deleted file mode 100644 index bbca0d99..00000000 Binary files a/docs/backlog/moonlight_images/moonlight/drivers/irdrivercontrols.png and /dev/null differ diff --git a/docs/history/decisions.md b/docs/history/decisions.md index ea56101a..3a46b6e7 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -815,3 +815,25 @@ Bringing up the S31's on-chip 1 Gb RGMII Ethernet took four fixes, and none of t - **A shared clock is a contended resource — the RGMII 125 MHz Tx clock can't come from a PLL that's already spoken for.** The default (AUTO) sourced the Tx clock from the MPLL, but PSRAM already ran the MPLL at 400 MHz (no integer path to 125), and CPLL couldn't synthesise 125 MHz on the 40 MHz XTAL grid either. Only the *fractional* APLL (built for exact frequencies) works. The lesson beyond Ethernet: on an SoC where one PLL feeds multiple peripherals, "pick a clock source" is a *conflict-resolution* decision, not a default — check what already owns each PLL before claiming one. - **Changing a Kconfig *choice* in a defaults fragment needs a clean build.** Editing `CONFIG_ETH_EMAC_RGMII_TX_CLK_SRC_*` in the fragment and doing an incremental build silently kept the *old* choice (the build dir's `sdkconfig` already had a value; defaults don't override an existing one). Two flash cycles were spent "testing APLL" that were actually still CPLL. When a sdkconfig *choice* (not a plain `=y`) changes, `rm -rf` the build dir or the test is a lie. + +## W5500 SPI Ethernet (LightCrafter / SE16): interrupt service, IDF-v6 config, and a per-board reset pin + +Bringing up the two Limpkin ESP32-S3 boards' external W5500 (SPI) Ethernet surfaced three traps between "the driver installs" and "the link is up" — each far from its cause, each a general rule. + +- **Interrupt mode needs `gpio_install_isr_service()` — miss it and the IRQ silently degrades to slow polling, not an error.** The device model gives the W5500 a real INT pin (SE16 GPIO18, LightCrafter GPIO45, per the board schematics). But the IDF W5500 driver registers its handler with `gpio_isr_handler_add()`, which requires the per-pin ISR service already installed — and nothing in the platform layer installed it. The `add` failed with a one-line `E gpio: GPIO isr service is not installed`, the interrupt never fired, and RX was serviced only on the driver's slow fallback → a ~1 s *sawtooth* ping latency (packets serviced in descending batches, the poll-cycle signature). The symptom read like a *network* problem; the cause was a missing one-liner. Rule: a driver that takes a GPIO interrupt needs the ISR service installed first — do it once before the driver init (`ESP_ERR_INVALID_STATE` = already installed = fine). The `int_gpio=-1` polling fallback is a *workaround* that hides this, not the fix. + +- **IDF v6's W5500 driver rejects `int_gpio_num < 0` unless a poll period is also set.** The "no interrupt" fallback isn't just `int_gpio_num = -1` — that alone returns `esp_eth_mac_new_w5500(): invalid configuration argument combination` and the whole eth init fails, cascading the board to WiFi. You must *also* set `w5500_config.poll_period_ms` (e.g. 10). The pair is the API contract; half of it is a config error. + +- **A failed Ethernet init that cascades to WiFi *masks* the eth bug — "why are we on WiFi?" is the tell.** Both bugs above manifested as the board quietly running on WiFi with a healthy HTTP server and no eth log at the default verbosity. The robustness cascade (eth fails → WiFi) is correct behaviour, but it turns a loud init failure into a silent capability loss. When a board that *should* be on Ethernet is on WiFi, that mismatch is the signal to read the eth init log, not a benign state. + +- **A `0x00` "version mismatched" read from an SPI PHY means the chip is held in reset — check for a board reset-release pin.** The LightCrafter's WIZ850IO module holds its own `nRST` until GPIO3 is driven HIGH (it also gates RS485_DE / VBUS_DET on that board). Without releasing it, the ESP32 read `0x00` from the W5500's version register (`expected 0x04, got 0x00`) → "verify chip ID failed". The SE16's W5500 self-resets, so it needed no such pin — which is exactly why this was board-specific and easy to miss. Setting `ethRstGpio` (→ the IDF PHY driver's `reset_gpio_num`, which runs the assert-low/release-high sequence) fixed it. Rule: an all-zeros register read from an SPI peripheral is "the chip isn't talking", and the first suspect after wiring is a reset/enable line the board expects the host to drive. + +## Extract the generic "set a control" primitive to the Scheduler, not per-input seams + +Adding IR remote control raised "how does one module drive another module's control?" The wrong answer is a bespoke seam per target (`Drivers::adjustBrightness`, then `Drivers::setPalette`, … — N one-offs, the "N disparate inputs" the LightsControl backlog warns against). The right answer already existed, half-hidden: the WLED-app bridge sets brightness via `applySetControl(module, control, value)` — the generic find-module → validate → apply → onUpdate → persist → conditional-buildState path. It lived *private on HttpServerModule*. + +The move: **lift `setControl` onto the `Scheduler`** (which owns the module tree + the persistence hook), and make HttpServer's `applySetControl` a thin result→HTTP-status mapper. Now IR, HTTP, Improv, and the WLED bridge all compose against one control-agnostic primitive — brightness, palette, or any future control is the same call with different arguments; adding an input never adds plumbing. HttpServer shrank ~54 lines (dedup, not addition). The reach mechanism is the `FilesystemModule::instance_` singleton pattern (`Scheduler::instance()`), so a factory-created module (IrModule) gets it without a per-module inject. General rule: when a second caller wants a capability that a transport layer implemented privately, the capability belongs in core (the tree owner), and the transport keeps only its status-mapping — the same shape as the DevicesModule/AudioModule "core owns the hard part, the module leans on it" split. + +## An RMT/IR done-callback runs in ISR context: signal only, decode on the task + +The first NEC-decoder version crash-looped the board (USB dropped, port re-enumerated) because the RMT `on_recv_done` callback both **decoded** the frame (calling non-`IRAM_ATTR` helpers) and **re-armed** `rmt_receive()` — from interrupt context. On ESP32, an ISR that jumps to flash-resident code while the cache is disabled, or re-enters a driver call, faults. The existing working RX capture (`rmtWs2812RxCapture`) already showed the safe shape and I didn't follow it at first: **the ISR only records the symbol count and signals a queue; the decode + re-arm happen on the task** (in `irRead`, called from the render loop). Rule: an RMT/GPIO done-callback is ISR context — it may touch only its stack, the passed event data, and an ISR-safe FreeRTOS primitive (`xQueueSendFromISR`); everything else (parsing, logging, re-arming the peripheral) moves to the waking task. When in doubt, copy the codebase's already-proven callback, don't re-derive it. diff --git a/docs/history/plans/Plan-20260703 - WLED audio sync.md b/docs/history/plans/Plan-20260703 - WLED audio sync.md new file mode 100644 index 00000000..c5559999 --- /dev/null +++ b/docs/history/plans/Plan-20260703 - WLED audio sync.md @@ -0,0 +1,129 @@ +# Plan — WLED-compatible audio sync (send + receive) in AudioModule + +## Context + +projectMM's AudioModule analyses local audio (line-in / mic) into an `AudioFrame` (16 GEQ +bands + level + peak). The product owner wants **WLED audio-sync over UDP** so projectMM can +interoperate with the WLED ecosystem: a projectMM device can **broadcast** its analysed audio +for WLED/MoonLight receivers, and can **receive** a peer's audio to drive its own effects when +it has no local source. MoonLight already *receives* this (`D_WLEDAudio.h`) but nothing in the +family *sends* it — this closes that loop. The wire format is a fixed compatibility contract +(netmindz/WLED-sync), so the packet must be byte-exact. + +The design (product-owner-confirmed): a single **`sync` control: Off / Send / Receive**. +- **Off** — local audio only (today's behaviour). No socket bound → zero overhead. +- **Send** — broadcast the local `AudioFrame` as a WLED v2 packet on UDP 11988. +- **Receive** — bind 11988, and when packets arrive, write the peer's audio *into* `frame_` + (so effects react to it transparently); **auto-blend**: fall back to the local mic/simulate + when no packet for ~1 s. Socket bound only in Receive mode. + +## The wire format (authoritative — netmindz/WLED-sync, header "00002") + +`__attribute__((packed))`, **44 bytes**, UDP port **11988**, broadcast: + +| offset | field | type | projectMM source (AudioFrame) | +|---|---|---|---| +| 0 | `header[6]` | char | `"00002"` (+ NUL) | +| 6 | `gap1[2]` | u8×2 | zero (part of the wire layout) | +| 8 | `sampleRaw` | f32 | `level` | +| 12 | `sampleSmth` | f32 | `levelSmoothed` | +| 16 | `samplePeak` | u8 | derived: 1 when a beat/peak this frame, else 0 | +| 17 | `frameCounter` | u8 | incrementing send counter | +| 18 | `fftResult[16]` | u8×16 | `bands[16]` — direct 1:1 | +| 34 | `gap2[2]` | u8×2 | zero | +| 36 | `FFT_Magnitude` | f32 | `peakMag` | +| 40 | `FFT_MajorPeak` | f32 | `peakHz` | + +The mapping is near-1:1 — `AudioFrame`'s comments already cite the WLED field names +(`volumeRaw`/`volumeSmth`), the struct was built with this in mind. (There's also an 83-byte +v1 packet; we **send v2 only**, and **parse v2 only** — v1 is legacy, out of scope unless a +received v1 shows up, in which case ignore it, don't crash.) + +## Files + +1. **New: `src/light/WLEDAudioSyncPacket.h`** — the format in one place (the `ArtNetPacket.h` / + `DdpPacket.h` convention: constants + inline `build` + inline `parse`, round-trip unit-tested). + - `constexpr uint16_t WLED_SYNC_PORT = 11988;` + - `constexpr char WLED_SYNC_HEADER[6] = "00002";` + - The 44-byte `#pragma pack`ed struct (or a hand-serialised builder writing exact offsets — + hand-serialise is safer than relying on struct packing across compilers, matching how + ArtNet/DDP builders write bytes explicitly; **decide at impl time**, but the *test* pins 44 + bytes + the offsets regardless). + - `size_t buildWledAudioSync(uint8_t out[44], const AudioFrame&, uint8_t frameCounter, bool peak)` + - `bool parseWledAudioSync(const uint8_t* buf, size_t len, AudioFrame& out)` — validates length + (44) + header ("00002"); fills an AudioFrame from the packet (inverse of build); returns false + on a v1/short/foreign packet so the caller ignores it. + +2. **`src/core/AudioModule.h`** — the send/receive plumbing (all guarded `if constexpr (platform::hasWiFi)` + so a `MM_NO_WIFI` build compiles the paths out): + - **Members:** `uint8_t sync = 0;` (0=Off/1=Send/2=Receive), `platform::UdpSocket syncSock_;` + `uint32_t lastSyncSend_ = 0;`, `uint32_t lastSyncRecv_ = 0;` (millis of last received packet, + for the auto-blend fallback), `uint8_t syncFrameCounter_ = 0;`, a `uint8_t syncPkt_[64]` scratch. + - **Control:** in `onBuildControls()`, `controls_.addSelect("sync", sync, {"Off","Send","Receive"}, 3)` + — placed after `simulate`. A read-only `"sync status"` line (e.g. "receiving from 1.2.3.4" / + "sending 33/s" / off) via the existing `addReadOnly` + loop1s idiom. + - **Mode transitions:** `sync` changes must re-bind/unbind the socket, so add it to + `controlChangeTriggersBuildState()` → `onBuildState()` → a small `syncReinit()`: close the + socket; if Send → `open()` + `connect("255.255.255.255", 11988)`; if Receive → `open()` + + `bind(11988)`; if Off → leave closed. (Mirrors NetworkSendDriver's `connectIfDestChanged` + + NetworkReceiveEffect's bind.) + - **Send** (in `loop()`, after `frame_` is refreshed): throttle by an interval (WLED sends + ~real-time; cap ~30–40/s to match, a `syncSendIntervalMs` const — reuse the + `now - lastSyncSend_ < interval` pattern from NetworkSendDriver:106-114). Build the packet + from `frame_` + `syncFrameCounter_++`, `syncSock_.sendTo(...)`. + - **Receive** (in `loop()`): bounded non-blocking drain (mirror NetworkReceiveEffect:103-143, + e.g. ≤8 packets/tick — sync is low-rate). For each, `parseWledAudioSync` into a temp frame; + on success, copy it into `frame_` and stamp `lastSyncRecv_ = millis()`. **Auto-blend:** the + existing local-analysis block in `loop()` should only overwrite `frame_` when NOT in Receive + mode, OR when in Receive mode but `millis() - lastSyncRecv_ > kSyncFallbackMs` (~1000 ms) — + i.e. received audio wins while fresh, local mic resumes when the peer goes quiet. + - **`samplePeak` derivation** for the send: a simple beat flag — set when `level` exceeds a + short-running average by a margin (or reuse whatever peak signal the FFT block already has at + AudioModule.h:264-266). Keep it cheap; it's a hint field. + +3. **New: `test/*` round-trip test** — `test/` C++ unit (ctest) for `WLEDAudioSyncPacket.h`: + build→parse round-trips an AudioFrame; pins the **44-byte size**, the **"00002" header**, the + **exact field offsets** (a golden byte vector — the compatibility contract, same rigor as the + Improv frame golden vector), and that `parse` rejects a wrong-length / wrong-header / v1 packet. + (Follows the ArtNet/DDP packet-test precedent + the Improv golden-vector precedent.) + +4. **`docs/moonmodules/core/AudioModule.md`** (+ the `///` header docs check_specs validates) — + document the `sync` control (Off/Send/Receive, the auto-blend behaviour, port 11988, WLED v2 + compatibility). Keep control-name ↔ doc in sync (check_specs gate). + +## Not doing (scope guards) + +- **No platform change** — `UdpSocket` already has open/connect/sendTo/bind/recvFrom + SO_BROADCAST. +- **No v1 (83-byte) send or parse** — v2 only; a received v1/foreign packet is ignored, not crashed. +- **No new module** — this lives in AudioModule (it owns the AudioFrame, both ends need it). +- **No separate send+receive toggles** — one tri-state `sync` (PO-confirmed), simplest coherent UX. +- **Not a `src/light/` driver/effect** — audio sync is an AudioModule capability, not a light node; + the packet header goes in `src/light/` only because that's the established home for wire formats. + +## Verification + +- **Build:** desktop (`cmake --build build`, -Werror) + `esp32p4-eth` (the MHC shield) + a mic-less + variant (e.g. `esp32-eth`) all clean — the `hasWiFi` guard keeps it compiling everywhere. +- **Unit:** `ctest` — the new round-trip/golden-vector test passes; existing tests unaffected. + `check_specs` green (control ↔ doc). +- **Bench — SEND (the headline interop):** on the MHC-WLED P4 shield (line-in working, at + `192.168.1.139`), set `sync=Send`. Capture the UDP on the Mac (`nc -ul 11988` / a tiny Python + `recvfrom` on 11988) and assert: 44-byte packets, header "00002", `fftResult` matches the live + bands, arriving continuously. **Cross-check with MoonLight** if a MoonLight receiver is available: + its `D_WLEDAudio` should light up from the projectMM broadcast — the real compatibility proof. +- **Bench — RECEIVE + auto-blend:** point a second device (or a Python sender emitting the golden + v2 packet) at the shield with `sync=Receive`; confirm the shield's effects react to the injected + audio (its `level`/`peakHz` readouts track the sent values), then **stop** the sender and confirm + it falls back to the local line-in within ~1 s. +- Save the approved plan to `docs/history/plans/Plan-20260703 - WLED audio sync.md`. + +## Open items (settle during impl, not blockers) + +- **Send rate** — WLED transmits ~per-frame; pick a cap (30–40/s) that's WLED-friendly without + flooding. A `sync fps` control could be added but Off/Send/Receive + a sensible fixed rate is + leaner; add the control only if the PO wants tunability. +- **`samplePeak`** — exact beat-flag source (reuse the FFT peak block vs a small level-vs-average + check). It's a hint field; a simple, cheap derivation is fine. +- **Struct packing vs hand-serialise** for the 44-byte layout — hand-serialised byte writes are + the safer, portable choice (no cross-compiler packing surprises); the golden-vector test pins it + either way. diff --git a/docs/history/plans/Plan-20260704 - IrModule brightness.md b/docs/history/plans/Plan-20260704 - IrModule brightness.md new file mode 100644 index 00000000..a011ef28 --- /dev/null +++ b/docs/history/plans/Plan-20260704 - IrModule brightness.md @@ -0,0 +1,125 @@ +# Plan — IrModule: minimal IR receiver peripheral that adjusts brightness + palette + +## Context + +The SE16 / LightCrafter boards carry an IR receiver (SE16: GPIO 5, shared with Ethernet MISO +via the board switch; LightCrafter: GPIO 4). "IR" is currently a `planned` capability. The +product owner wants a **minimal IR service** that actually *does* something — adjust global +**brightness** — rather than only exposing a raw code. The full remote-code → action mapping is +a later step; for now the action plumbing is proven with **`brightness up` / `brightness down` +buttons** in the UI, wired to the same brightness-adjust path a decoded IR code will call later. + +Confirmed with the product owner: +- **Receive only** (no TX). +- **Peripheral, catalog-wired** — factory-registered like AudioModule / I2cScanModule, added + per board via `deviceModels.json`; NOT a hardcoded child of System. +- **Start minimal, grow later** — brightness up/down now; richer remote mapping is a follow-up. + +Backlog alignment ([backlog-mixed.md](../../backlog/backlog-mixed.md)): IR is named as an input +for the eventual **LightsControl** hub. This module is the thin IR *input* peripheral; when +LightsControl is built it consumes IR via the same static seam (the `AudioModule::latestFrame()` +pattern). This does not build LightsControl — it builds the IR input and one concrete action. + +## Files + +1. **New `src/core/IrModule.h`** — shaped on `I2cScanModule` (the minimal peripheral template): + - `class IrModule : public MoonModule`; `role() → Peripheral`; `respectsEnabled()` default + (true — IR is a real feature, not a diagnostic). + - Controls: `addPin("pin", pin_)` (IR receiver GPIO), `addButton("brightness up")`, + `addButton("brightness down")`, `addReadOnly("last code", codeStr_)` (shows the last + decoded code once the RMT decode lands; blank while the seam is a stub). + - `onUpdate(name)`: `"brightness up"` → `Drivers::adjustBrightness(+kStep)`; `"brightness + down"` → `Drivers::adjustBrightness(-kStep)`. `kStep = 16` (a perceptible notch; 16 steps + across the 0–255 range). + - `loop()`: poll `platform::irRead(pin_, code)`; on a fresh code, store it for the readout + (and later: map to an action). Stub returns false today, so loop is a cheap no-op. + - Static `latestCode()` seam for a future LightsControl consumer (mirrors + `AudioModule::latestFrame()`), returning the last decoded code (0 = none yet). + +2. **`src/light/drivers/Drivers.h`** — add a static brightness-adjust seam: + - `static void adjustBrightness(int delta);` — clamps `brightness` to [0,255], rebuilds the + correction LUT, and notifies driver children via `onCorrectionChanged()` — the SAME path + `onUpdate("brightness")` takes, so an IR-driven change behaves exactly like the UI slider. + - Needs a static instance pointer (`static Drivers* active_;` set in `setup()`, cleared in + `teardown()`) — the established single-owner seam pattern. No-op if no Drivers is live. + +3. **`src/platform/platform.h`** — new seam near `i2cScan`: + - `bool irRead(uint16_t pin, uint32_t& codeOut);` — true when a fresh IR frame decodes on + `pin` (self-contained: opens/owns its RMT-RX channel, like `i2cScan` opens its own bus). + Present-tense doc: today ESP32 + desktop both stub to `false`; the RMT-NEC decode is a + focused follow-up (avoids rushing RMT-vs-RmtLedDriver channel contention into this cut). + +4. **`src/platform/esp32/platform_esp32*.cpp`** + **`platform_desktop.cpp`** — `irRead` stubs + returning `false`. (ESP32's real RMT-NEC decode is the follow-up; the RMT-RX machinery + already exists in `platform_esp32_rmt.cpp`.) + +5. **`web-installer/deviceModels.json`** — add an `IrModule` child to SE16 (`pin: 5`) and + LightCrafter (`pin: 4`). Keep "IR" in `planned` (NOT `supported`) until the ESP32 decode + lands — per the vocabulary rule, `supported` requires a working backing capability, and the + *decode* isn't working yet even though the module + brightness action are. The brightness + buttons work now; IR reception is the planned part. + +6. **`src/main.cpp`** — `registerType("IrModule", "core/IrModule.md")`. + +7. **Docs** — the per-module technical page is **moxygen-generated** from `IrModule.h`'s `///` + comments (gitignored, not hand-written); the hand-authored piece is a summary entry in + `docs/moonmodules/core/ui/ui.md` linking to `../moxygen/IrModule.md`, plus embedding the + remote photo effects.md-style (``). *(Superseded the original line below, + which planned a hand-written `IrModule.md` — corrected to the moxygen/ui.md convention.)* The + old line: `docs/moonmodules/core/IrModule.md` — cross-file wiring + the brightness seam + the + "Prior art" note (NEC IR protocol, ESP-IDF RMT RX example). + +8. **Test** `test/unit/core/unit_IrModule.cpp` — the brightness buttons adjust `Drivers` + brightness (up clamps at 255, down clamps at 0, step size); a stub `irRead` yields no code. + Driven through the public control path like `unit_AudioModule_sync`. + +## What actually shipped (grew past the original "brightness buttons" scope) + +The cut ended up delivering the full receive + learn feature, live-proven on the SE16 and +LightCrafter: + +- **Real RMT NEC decode** (`platform_esp32_ir.cpp`): a persistent RX channel on `pin`, an + ISR-minimal done-callback (signals a queue only — no decode/re-arm in interrupt context, the + same discipline as rmtWs2812RxCapture), decode + re-arm on the render task in `irRead`. + Live-proven: 4 distinct remote buttons → 4 stable 32-bit codes on both boards. +- **Learned code → action mapping.** A `learn` select arms an action; the next received code + binds to it (stored per-action in a persistent `code …` Text control, rebuilt into a fast + `learnedCode_` lookup on load). A received code runs its bound action. PO decision + (2026-07-04): learning (any remote, live) over MoonLight's fixed per-remote presets. +- **No UI action buttons** (PO decision 2026-07-04): the remote is the interface once learned, so + brightness/palette up-down buttons would duplicate it — removed. The `learn` select + per-action + `code …` read-outs are the whole UI. +- **No `last code` control** (PO decision 2026-07-04): the received code shows in the status line + ("received 0x…" / "learned … = 0x…"), so a separate read-out would duplicate it. +- **Status feedback** via base `MoonModule::setStatus` (+ a per-module `statusBuf_` for dynamic + text, the I2cScan/Devices pattern): "set pin to receive" / "ready" (setup), "learning: press a + remote button" (armed), "learned = 0x…" (bound), "Drivers.brightness → N" (fired), + "received 0x… (unassigned)" (unbound code). + +## Scope guards (held) + +- **No IR transmit.** +- **No LightsControl hub** — this is the IR input peripheral only; `latestCode()` is the seam it + will consume. + +## Follow-up (next session) + +- **`effect next` / `effect prev`** — change the effect in layer[0] slot[0]. PO asked for it + (2026-07-04); deferred because it is a module *replace* (swap the effect type), not a + `setControl` nudge. It needs: (a) a **replace-by-type primitive extracted to Scheduler** (the + same move as the setControl extraction — currently `applyReplace` is HttpServer-private), (b) + ordered **effect-type enumeration by role** from ModuleFactory, (c) tree navigation to the + layer[0] slot. Then it's two more `kActions`-style rows that call the replace primitive instead + of setControl. Design the primitive first. +- **Move "IR" `planned` → `supported`** in the catalog now that decode works on hardware (a + `supported` capability must have a working backing module — it does now). + +## Verification (done) + +- Desktop + all ESP32 variants build clean (`-Werror`); `ctest` (9 IrModule cases: learn/bind, + fire, clamp, independent bindings, unassigned, robustness); scenarios green; `check_specs` / + `check_devices` valid. +- **Live on SE16 (GPIO 5, IR/Eth switch) and LightCrafter (GPIO 4, IR+Eth simultaneous)**: remote + press decodes to a code, learn binds it, a bound code drives brightness/palette. Boards boot + clean with the decoder (the first ISR-unsafe version crash-looped; the task-side rewrite is + stable). diff --git a/docs/moonmodules/core/ui/ui.md b/docs/moonmodules/core/ui/ui.md index 1d73c02e..27468333 100644 --- a/docs/moonmodules/core/ui/ui.md +++ b/docs/moonmodules/core/ui/ui.md @@ -70,12 +70,15 @@ Detail: [technical](../moxygen/FilesystemModule.md) ### Audio -A System peripheral (added by the user, not auto-wired): an I²S microphone feeding the FFT that audio-reactive effects consume via `AudioModule::latestFrame()`. Idle until real GPIOs are entered. +A System peripheral (added by the user, not auto-wired): an I²S microphone (or line-in ADC) feeding the FFT that audio-reactive effects consume via `AudioModule::latestFrame()`. It also syncs audio over UDP, WLED-compatible: broadcast the local analysis for the WLED ecosystem, or receive a peer's audio to drive effects with no local mic. Idle until real GPIOs are entered. -- `wsPin` / `sdPin` / `sckPin` — the I²S microphone GPIOs (unset until entered). -- `sampleRate` — mic sample rate. +- `wsPin` / `sdPin` / `sckPin` — the I²S GPIOs (unset until entered). +- `mclkPin` — master-clock GPIO for a line-in ADC that needs one (e.g. the PCM1808); leave unset for a plain mic. +- `sampleRate` — mic/ADC sample rate. +- `floor` / `gain` — noise floor and input gain for the analysis. - `simulate` — feed a synthetic signal instead of the mic (for testing without hardware). -- read-only — `level` (RMS), `peakHz`. +- `sync` — Off / Send / Receive: broadcast or receive WLED audio-sync packets. `syncPort` sets the UDP port (default 11988, the WLED standard); Receive auto-blends back to the local mic ~1 s after a peer goes quiet. +- read-only — `level` (RMS), `peakHz`, `sync status`. Detail: [technical](../moxygen/AudioModule.md) @@ -90,3 +93,17 @@ A System peripheral that probes the I²C bus (default GPIO21/22) on a button pre - read-only — `result` (addresses found). Detail: [technical](../moxygen/I2cScanModule.md) + +### IR + +A System peripheral (added per board, not auto-wired): an IR remote receiver that drives other modules' controls through the shared `Scheduler::setControl` primitive — the device's IR input, the counterpart of the WLED-app bridge. It **learns** any remote: pick an action in `learn`, press a remote button to bind its code, and thereafter that button drives the action. Decoding is real NEC-over-RMT; a received code (bound or not) shows in the status line. + +An IR receiver and the common 21-key RGB remote it decodes + +- `pin` — the IR receiver GPIO (unset until entered; on the SE16 it shares GPIO 5 with the Ethernet MISO via the board switch, on the LightCrafter it is its own GPIO 4 alongside Ethernet). +- `learn` — pick an action (off / brightness up / brightness down / palette next / palette prev); the next received code binds to it, then learning disarms. +- `code brightness up` / `code brightness down` / `code palette next` / `code palette prev` — read-only, the learned code for each action (persisted). + +The actions nudge `Drivers.brightness` (±16, clamped 0–255) and step `Drivers.palette` (next/previous). The status line reports setup state ("set pin to receive" / "ready"), the learn prompt, a binding ("learned … = 0x…"), a fired action ("Drivers.brightness → N"), and an unbound code ("received 0x… (unassigned)"). + +Detail: [technical](../moxygen/IrModule.md) diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 89bd16f7..53d8cea1 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -22,6 +22,7 @@ idf_component_register( "../../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_ir.cpp" "../../src/platform/esp32/platform_esp32_es8311.cpp" INCLUDE_DIRS "../../src" diff --git a/scripts/check/check_devices.py b/scripts/check/check_devices.py index 5961ce0f..bcf398bf 100644 --- a/scripts/check/check_devices.py +++ b/scripts/check/check_devices.py @@ -35,7 +35,7 @@ # in lockstep with the modules that actually exist. planned = peripherals with no # module yet (the backlog seed) — open-ended by design, so it is NOT whitelisted, # only type-checked. Adding a new supported capability means a module backs it. -SUPPORTED_VOCAB = {"LEDs", "WiFi", "Ethernet", "Audio"} +SUPPORTED_VOCAB = {"LEDs", "WiFi", "Ethernet", "Audio", "IR"} # Boot-wired singletons: present on every device, added by code, so the catalog # references them by id without the factory creating them. Their catalog `type` diff --git a/src/core/AudioModule.h b/src/core/AudioModule.h index 54d1eb8b..387453ca 100644 --- a/src/core/AudioModule.h +++ b/src/core/AudioModule.h @@ -71,6 +71,7 @@ #include "core/AudioLevel.h" #include "core/AudioBands.h" #include "core/math8.h" // beatsin8 / sin8 — the simulated-audio oscillators +#include "light/WLEDAudioSyncPacket.h" // WLED audio-sync wire format (send/receive) #include "platform/platform.h" #include @@ -106,6 +107,11 @@ class AudioModule : public MoonModule { int8_t wsPin = -1; ///< word-select / LRCLK (-1 = unset). Changing it re-creates the I2S channel live (no reboot). int8_t sdPin = -1; ///< serial data in (-1 = unset). Changing it re-creates the I2S channel live. int8_t sckPin = -1; ///< bit clock (-1 = unset). Changing it re-creates the I2S channel live. + int8_t mclkPin = -1; ///< master clock out (-1 = none). Self-clocked MEMS mics (INMP441) leave this + ///< -1; an ADC/codec that needs a master clock (e.g. the MHC-WLED P4 shield's + ///< line-in ADC on GPIO3) sets it so the I2S peripheral drives MCLK. On a codec + ///< board (CodecType != None) the codec's own mclk is used instead (see reinit()). + ///< Changing it re-creates the I2S channel live. /// Sample rate is a discrete choice (the standard audio rates), so it's a /// dropdown over a fixed set, not a free number. sampleRateSel indexes /// kSampleRates; sampleRate() resolves it to Hz. Default index 2 = 22050 @@ -128,6 +134,17 @@ class AudioModule : public MoonModule { /// A real mic always wins: when `mode` is a fill-in ("silence") mode it only runs while there's no real signal. uint8_t simulate = 0; ///< 0 = off, 1 = music (fill silence), 2 = sweep (fill silence), ///< 3 = music (always), 4 = sweep (always) + /// WLED audio-sync over UDP (port 11988, broadcast, WLED v2 wire format — + /// light/WLEDAudioSyncPacket.h). 0 = Off (local audio only, no socket bound); + /// 1 = Send (broadcast this device's AudioFrame for WLED/MoonLight receivers); + /// 2 = Receive (bind 11988; a peer's audio overwrites frame_ so effects react to + /// it, auto-falling-back to the local mic when packets stop for ~1 s). The socket + /// is bound only in Send/Receive — Off costs nothing. Changing it re-binds live. + uint8_t sync = 0; ///< 0 = off, 1 = send, 2 = receive + /// The sync UDP port — the Send destination and the Receive listen port. Defaults + /// to WLED's 11988 (interop with WLED/MoonLight); set it the same on both ends to + /// run a private projectMM-only sync group on a non-WLED port. + uint16_t syncPort = WLED_SYNC_PORT; static constexpr uint16_t kSampleRates[] = {8000, 16000, 22050, 44100}; static constexpr uint8_t kSampleRateCount = 4; @@ -138,13 +155,34 @@ class AudioModule : public MoonModule { controls_.addPin("wsPin", wsPin); controls_.addPin("sdPin", sdPin); controls_.addPin("sckPin", sckPin); + controls_.addPin("mclkPin", mclkPin); static constexpr const char* kRateOptions[] = {"8000", "16000", "22050", "44100"}; controls_.addSelect("sampleRate", sampleRateSel, kRateOptions, kSampleRateCount); + // floor/gain condition the LOCAL FFT/level mapping. They stay visible in every sync + // mode — including Receive, because auto-blend falls back to the local mic when the + // peer goes quiet, and floor/gain govern that fallback. (They're only transiently + // dead while a peer's audio is actively driving the frame — not permanently, so + // hiding them by the sync setting would wrongly hide a control the fallback needs.) controls_.addUint8("floor", floor, 0, 255); controls_.addUint8("gain", gain, 1, 255); static constexpr const char* kSimulateOptions[] = { "off", "music (silence)", "sweep (silence)", "music (always)", "sweep (always)"}; controls_.addSelect("simulate", simulate, kSimulateOptions, 5); + // WLED audio sync — only on builds with an IP stack (WiFi OR Ethernet: the UDP + // send/receive works over either, so an Ethernet-only board like the MHC-WLED + // shield still gets it). A no-network build hides the controls entirely. + if constexpr (platform::hasNetwork) { + static constexpr const char* kSyncOptions[] = {"off", "send", "receive"}; + controls_.addSelect("sync", sync, kSyncOptions, 3); + // The UDP port — the Send destination and the Receive listen port. Defaults to + // WLED's 11988 (interop with WLED/MoonLight); change it on BOTH ends to run a + // private projectMM-only sync group on a non-WLED port. Shown whenever sync is on + // (Send or Receive), hidden in Off. A `sync` change re-runs this method (it's in + // controlChangeTriggersBuildState) so the row toggles live. + controls_.addUint16("syncPort", syncPort, 1, 65535); + controls_.setHidden(controls_.count() - 1, sync == 0); + controls_.addReadOnly("sync status", syncStr_, sizeof(syncStr_)); + } // Read-only live read-outs (formatted in loop1s). Derived every second, // nothing to persist, so ReadOnly (the display-only type) not a flipped // Text — same idiom as SystemModule's uptime/fps. @@ -158,19 +196,25 @@ class AudioModule : public MoonModule { MoonModule::onBuildControls(); } - /// A pin or rate change rebuilds the I2S channel (live, no reboot). + /// A pin or rate change rebuilds the I2S channel (live, no reboot); a `sync` / + /// `syncPort` change re-binds/unbinds the UDP socket AND re-toggles the port row's + /// visibility (all flow through onBuildState → rebuildControls). bool controlChangeTriggersBuildState(const char* name) const override { return std::strcmp(name, "wsPin") == 0 || std::strcmp(name, "sdPin") == 0 - || std::strcmp(name, "sckPin") == 0 || std::strcmp(name, "sampleRate") == 0; + || std::strcmp(name, "sckPin") == 0 || std::strcmp(name, "mclkPin") == 0 + || std::strcmp(name, "sampleRate") == 0 || std::strcmp(name, "sync") == 0 + || std::strcmp(name, "syncPort") == 0; } - void onBuildState() override { reinit(); MoonModule::onBuildState(); } + void onBuildState() override { reinit(); syncReinit(); MoonModule::onBuildState(); } void setup() override { if (active_ == nullptr) active_ = this; // first live mic wins; a 2nd mic is captured but not read reinit(); + syncReinit(); } void teardown() override { deinit(); + if constexpr (platform::hasNetwork) { syncSock_.close(); syncOpen_ = false; } if (active_ == this) active_ = nullptr; // vacate; a surviving module re-elects itself in loop() } @@ -179,6 +223,16 @@ class AudioModule : public MoonModule { /// mic-less build just sees silence. const AudioFrame* audioFrame() const { return &frame_; } + // --- Test seams (host unit tests only; mirror DevicesModule::injectPacketForTest) --- + // Read-only views of the sync socket lifecycle so unit_AudioModule_sync can assert it + // through the public loop() without befriending the class or exposing internals broadly. + bool syncOpenForTest() const { return syncOpen_; } + uint8_t syncFrameCounterForTest() const { return syncFrameCounter_; } + const char* syncStatusForTest() const { return syncStr_; } + static constexpr uint32_t syncSendIntervalMsForTest() { return kSyncSendIntervalMs; } + static constexpr uint32_t syncFallbackMsForTest() { return kSyncFallbackMs; } + static constexpr uint32_t syncOpenRetryMsForTest() { return kSyncOpenRetryMs; } + /// Process-wide accessor for the consumers (audio effects). There is one mic, /// and an effect can be added/removed via the UI at any time, so it can't rely /// on a boot-time setter — it asks here @@ -204,6 +258,17 @@ class AudioModule : public MoonModule { // if (active_ == nullptr) active_ = this; + // WLED audio sync. Send broadcasts the current frame_ (throttled). Receive drains + // the socket into frame_ and, while a peer's audio is fresh, RETURNS so the local + // mic analysis below is skipped (the received frame drives the effects); once the + // peer goes quiet (~1 s) it falls through and the local mic resumes (auto-blend). + if constexpr (platform::hasNetwork) { + if (sync != 0 && syncEnsureSocket()) { // lazy-open once the network is up + if (sync == 1) syncSend(); + else if (sync == 2 && syncReceive()) return; + } + } + // Simulated audio (see the `simulate` control): 0=off, 1=music-on-silence, 2=sweep-on-silence, // 3=music-always, 4=sweep-always. Real mic input always wins in the "on-silence" modes — the // mic path below resets realQuietMs_ whenever a block carries signal, and synthesizeFrame() @@ -323,6 +388,20 @@ class AudioModule : public MoonModule { std::snprintf(levelStr_, sizeof(levelStr_), "%u", static_cast(levelPeak_)); std::snprintf(peakStr_, sizeof(peakStr_), "%u Hz", static_cast(frame_.peakHz)); levelPeak_ = 0; // reset for the next window + // Live sync status: "sending" / "receiving" (peer audio fresh) / "listening" + // (bound, no peer) / "off". While the socket isn't open yet, leave the baseline + // syncReinit/syncEnsureSocket set ("waiting for network" / "…failed"); only once + // open do we report the moment-to-moment send/receive state. + if constexpr (platform::hasNetwork) { + if (sync == 0) std::snprintf(syncStr_, sizeof(syncStr_), "off"); + else if (syncOpen_) { + if (sync == 1) std::snprintf(syncStr_, sizeof(syncStr_), "sending"); + else std::snprintf(syncStr_, sizeof(syncStr_), + (lastSyncRecv_ != 0 + && platform::millis() - lastSyncRecv_ < kSyncFallbackMs) + ? "receiving" : "listening"); + } + } MoonModule::loop1s(); } @@ -354,6 +433,19 @@ class AudioModule : public MoonModule { static constexpr uint16_t kSimRealGraceBlocks = 86; // ~2 s at ~23 ms/block before the sim takes over uint16_t realBlocks_ = 0; // grace countdown: >0 = mic was recently live, hold off the sim + // WLED audio sync (light/WLEDAudioSyncPacket.h). One socket, bound only in Send/Receive. + platform::UdpSocket syncSock_; + uint32_t lastSyncSend_ = 0; // millis of the last broadcast (send throttle) + uint32_t lastSyncRecv_ = 0; // millis of the last received packet (receive auto-blend) + uint8_t syncFrameCounter_ = 0; // increments per send (the packet's dup/reorder field) + bool syncOpen_ = false; // socket opened for the current mode (lazy-open latch) + uint32_t lastSyncOpenFailMs_ = 0; // millis of the last failed open (0 = none); bring-up backoff + char syncStr_[32] = {}; // "sync status" read-out + static constexpr uint32_t kSyncSendIntervalMs = 25; // ~40/s — WLED-friendly, well under a flood + static constexpr uint32_t kSyncFallbackMs = 1000; // no packet this long → resume local mic + static constexpr uint32_t kSyncOpenRetryMs = 1000; // pause between socket bring-up retries after a failure + static constexpr int kSyncMaxRecvPerTick = 8; // bounded non-blocking drain (sync is low-rate) + static constexpr const char* kInitFailMsg = "mic init failed — check pins / rate"; /// (Re)create the I2S channel for the current pins + rate. On a codec board the @@ -374,16 +466,19 @@ 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); + // Bring up the I2S channel FIRST. Where MCLK comes from depends on the board: + // - Codec board (an analog mic behind an I2S codec, e.g. the S31's ES8311): the + // codec's MCLK pin from the per-target config (platform::audioCodecPins.mclk). + // The codec won't even answer I2C until that clock runs, so I2S precedes the + // codec config below. + // - No codec: the runtime `mclkPin` control — −1 for a self-clocked MEMS mic + // (INMP441), or a real pin for an I2S ADC that needs a master clock (the + // MHC-WLED P4 shield's line-in ADC on GPIO3, WLED's SR_DMTYPE=4). + const int16_t mclk = platform::audioCodecType == platform::CodecType::None + ? mclkPin : static_cast(platform::audioCodecPins.mclk); inited_ = platform::audioMicInit(mic_, static_cast(wsPin), static_cast(sdPin), - static_cast(sckPin), mclkPin, sampleRate()); + static_cast(sckPin), mclk, sampleRate()); if (!inited_) { setStatus(kInitFailMsg, Severity::Error); return; @@ -422,6 +517,112 @@ class AudioModule : public MoonModule { // would leave the last real frame frozen on the LEDs instead of going dark. frame_ = AudioFrame{}; } + + // --- WLED audio sync (guarded: only compiled where platform::hasNetwork) --- + + /// Reset the sync socket to the current mode. Called from setup()/onBuildState() + /// so a `sync` control change applies live (no reboot). This only CLOSES the socket + /// and records the mode — it never opens one, because setup() runs at boot before + /// NetworkModule brings an interface up, and any lwip socket call before then asserts + /// (the core mutex is still null). The actual open() is deferred to syncEnsureSocket(), + /// which runs from the tick path once platform::networkReady() is true. + void syncReinit() { + if constexpr (!platform::hasNetwork) return; + syncSock_.close(); // syncEnsureSocket() re-opens per mode when the net is up + syncOpen_ = false; + lastSyncOpenFailMs_ = 0; // a mode change retries bring-up immediately (no stale backoff) + lastSyncRecv_ = 0; + std::snprintf(syncStr_, sizeof(syncStr_), + sync == 1 ? "send: waiting for network" + : sync == 2 ? "receive: waiting for network" + : "off"); + } + + /// Lazily open the sync socket for the current mode, once the network stack is up. + /// Idempotent: opens exactly once per mode (syncOpen_ latch), re-armed by syncReinit() + /// on a mode change. Returns true when the socket is ready to use this tick. Off is a + /// no-op (socket stays closed, zero overhead). Send connects to the LAN broadcast; + /// Receive binds the port. Mirrors NetworkSendDriver/NetworkReceiveEffect, but deferred + /// past boot so a boot-present AudioModule can't touch lwip before it exists. + bool syncEnsureSocket() { + if constexpr (!platform::hasNetwork) return false; + if (sync == 0) return false; + if (syncOpen_) return true; + if (!platform::networkReady()) return false; // interface not up yet — try again next tick + // Back off between failed bring-ups: loop() runs every tick, so without this a + // persistent open/bind failure (e.g. the port is busy) would retry — one socket() + // syscall per tick — dozens of times a second. lastSyncOpenFailMs_ stamps the last + // failure; hold off until kSyncOpenRetryMs has passed (same throttle form as syncSend). + const uint32_t now = platform::millis(); + if (lastSyncOpenFailMs_ != 0 && now - lastSyncOpenFailMs_ < kSyncOpenRetryMs) return false; + if (sync == 1) { // send → broadcast destination (configurable port) + char bcast[16]; formatDottedQuad(bcast, kBroadcast_); + if (syncSock_.open() && syncSock_.connect(bcast, syncPort)) { + syncOpen_ = true; + std::snprintf(syncStr_, sizeof(syncStr_), "sending"); + } else { + syncSock_.close(); + std::snprintf(syncStr_, sizeof(syncStr_), "send: socket failed"); + } + } else { // receive → bind the port (WLED 11988, or a custom group) + if (syncSock_.open() && syncSock_.bind(syncPort)) { + syncOpen_ = true; + std::snprintf(syncStr_, sizeof(syncStr_), "listening"); + } else { + syncSock_.close(); + std::snprintf(syncStr_, sizeof(syncStr_), "receive: bind failed"); + } + } + // Stamp a failure (or clear the timer on success). now==0 is nudged to 1 so the + // "!=0 means a failure is pending" sentinel holds even at millis()==0. + lastSyncOpenFailMs_ = syncOpen_ ? 0 : (now == 0 ? 1 : now); + return syncOpen_; + } + + /// Broadcast the current frame_ as a WLED v2 packet, throttled to ~40/s. Called + /// from loop() in Send mode. Cheap: builds a 44-byte packet, one non-blocking sendTo. + void syncSend() { + if constexpr (!platform::hasNetwork) return; + const uint32_t now = platform::millis(); + if (now - lastSyncSend_ < kSyncSendIntervalMs) return; + lastSyncSend_ = now; + // samplePeak hint: a beat is a raw level notably above the smoothed average. + const bool peak = frame_.level > frame_.levelSmoothed + kSyncPeakMargin; + uint8_t pkt[WLED_SYNC_PACKET_SIZE]; + buildWledAudioSync(pkt, frame_, syncFrameCounter_++, peak); + syncSock_.sendTo(pkt, WLED_SYNC_PACKET_SIZE); + } + + /// Drain the sync socket (bounded, non-blocking) in Receive mode. A valid v2 packet + /// overwrites frame_ and stamps lastSyncRecv_. Returns true while a peer's audio is + /// FRESH (within kSyncFallbackMs) so loop() skips the local mic analysis — false once + /// the peer goes quiet, letting the local mic resume (auto-blend). + bool syncReceive() { + if constexpr (!platform::hasNetwork) return false; + uint8_t pkt[WLED_SYNC_PACKET_SIZE + 8]; // a little slack over the 44-byte v2 + uint8_t srcIp[4] = {}; + for (int i = 0; i < kSyncMaxRecvPerTick; i++) { + const int n = syncSock_.recvFrom(pkt, sizeof(pkt), srcIp); + if (n <= 0) break; // -1 = nothing pending + AudioFrame rf; + if (parseWledAudioSync(pkt, static_cast(n), rf)) { + frame_ = rf; // received audio drives the effects + lastSyncRecv_ = platform::millis(); + // Feed the peer level into the same 1 s peak window the local mic uses, so the + // "level RMS" read-out (loop1s → levelStr_) reflects received audio too — otherwise + // it freezes at the last local value while a peer is driving the effects. + if (frame_.level > levelPeak_) + levelPeak_ = static_cast(frame_.level > 255 ? 255 : frame_.level); + } + // else: a v1 / foreign packet — ignore, keep draining. + } + // Fresh received audio → skip local mic. Stale (peer quiet) → fall through. + return lastSyncRecv_ != 0 + && (platform::millis() - lastSyncRecv_) < kSyncFallbackMs; + } + + static constexpr uint16_t kSyncPeakMargin = 8; // level over smoothed = a beat (samplePeak hint) + static constexpr uint8_t kBroadcast_[4] = {255, 255, 255, 255}; // LAN broadcast for send }; } // namespace mm diff --git a/src/core/FilesystemModule.cpp b/src/core/FilesystemModule.cpp index 05fb68b0..20624a3e 100644 --- a/src/core/FilesystemModule.cpp +++ b/src/core/FilesystemModule.cpp @@ -20,7 +20,13 @@ FilesystemModule::~FilesystemModule() { void FilesystemModule::setScheduler(Scheduler* s) { scheduler_ = s; instance_ = this; - if (s) s->setLoadAllHook(&loadAllHookTrampoline_); + if (s) { + s->setLoadAllHook(&loadAllHookTrampoline_); + // Scheduler::setControl calls this after a mutation so a control set from anywhere + // (IR, WLED bridge, /api/control) schedules the same debounced save. noteDirty is a + // static, so a plain function pointer suffices — no trampoline needed. + s->setNoteDirtyHook(&FilesystemModule::noteDirty); + } } void FilesystemModule::setup() { diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 00a4f335..8ff15d7a 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -496,47 +496,19 @@ void HttpServerModule::writeControls(JsonSink& sink, MoonModule* mod) { // free: no TcpConnection, returns an OpResult the caller maps to its own reporting. HttpServerModule::OpResult HttpServerModule::applySetControl( const char* moduleName, const char* controlName, const char* valueJson) { - MoonModule* target = findModuleByName(moduleName); - if (!target) return OpResult::ModuleNotFound; - - // Module-level "enabled" pseudo-control. - if (std::strcmp(controlName, "enabled") == 0) { - target->setEnabled(mm::json::parseBool(valueJson, "value")); - target->markDirty(); - FilesystemModule::noteDirty(); - if (scheduler_) scheduler_->buildState(); - return OpResult::Ok; - } - - auto& ctrls = target->controls(); - for (uint8_t i = 0; i < ctrls.count(); i++) { - auto& c = ctrls[i]; - if (std::strcmp(c.name, controlName) != 0) continue; - - // Per-type parse + validate + apply lives in Control.cpp. Non-Ok leaves the - // storage untouched, so no rollback needed. - ApplyResult r = applyControlValue(c, valueJson, "value"); - switch (r) { - case ApplyResult::Ok: break; - case ApplyResult::OutOfRange: return OpResult::OutOfRange; - case ApplyResult::Malformed: return OpResult::Malformed; - case ApplyResult::ReadOnly: return OpResult::ReadOnly; - } - // Rebuild the control list after every change so onBuildControls() can - // re-evaluate which controls are visible for the new value (a Select - // revealing fields, etc.). clear()+onBuildControls(), cheap + idempotent. - target->rebuildControls(); - // Three-tier control-change reaction (see MoonModule::onUpdate): onUpdate - // always; a tree-wide buildState only when the control reshapes dims/mapping. - target->onUpdate(controlName); - target->markDirty(); - FilesystemModule::noteDirty(); - if (target->controlChangeTriggersBuildState(controlName) && scheduler_) { - scheduler_->buildState(); - } - return OpResult::Ok; - } - return OpResult::ControlNotFound; // control name not on this module + // The generic control-set is a Scheduler primitive (it owns the tree + persistence hook), + // shared with every other control writer — Improv, the WLED bridge, IrModule. This wrapper + // only maps its result onto the HTTP OpResult so the response carries the right status code. + if (!scheduler_) return OpResult::ModuleNotFound; + switch (scheduler_->setControl(moduleName, controlName, valueJson)) { + case Scheduler::SetControlResult::Ok: return OpResult::Ok; + case Scheduler::SetControlResult::ModuleNotFound: return OpResult::ModuleNotFound; + case Scheduler::SetControlResult::ControlNotFound: return OpResult::ControlNotFound; + case Scheduler::SetControlResult::OutOfRange: return OpResult::OutOfRange; + case Scheduler::SetControlResult::Malformed: return OpResult::Malformed; + case Scheduler::SetControlResult::ReadOnly: return OpResult::ReadOnly; + } + return OpResult::ModuleNotFound; // unreachable; keeps -Wreturn-type happy } void HttpServerModule::handleSetControl(platform::TcpConnection& conn, const char* body) { diff --git a/src/core/IrModule.h b/src/core/IrModule.h new file mode 100644 index 00000000..f3183b5f --- /dev/null +++ b/src/core/IrModule.h @@ -0,0 +1,207 @@ +#pragma once + +#include "core/MoonModule.h" +#include "core/Scheduler.h" // setControl — the generic control-set primitive +#include "core/FilesystemModule.h" // noteDirty — schedule the debounced save on a learned bind +#include "platform/platform.h" // irRead + +#include +#include +#include // strtoul — rebuild learnedCode_ from persisted hex strings +#include + +namespace mm { + +/// A core, domain-neutral IR-receiver peripheral: it decodes an IR remote on `pin` and drives +/// other modules' controls. It is the device's IR *input* — the role the WLED-app bridge plays +/// for the phone app, expressed for a physical remote. +/// +/// **How it acts.** Every action routes through `Scheduler::setControl(module, control, value)`, +/// the one generic control-set primitive (also used by `/api/control`, Improv, and the WLED +/// bridge). IR never reaches into another module's internals; it composes against that primitive, +/// so adding a new action is one row in `kActions` — not new plumbing. The actions are +/// **brightness up/down** and **palette prev/next**, each a relative nudge of a target control. +/// +/// **Buttons vs the remote.** Each action is a UI button (press it → the nudge happens now) AND a +/// learnable remote binding. The two share one path: a button press and a matching remote code +/// both call `runAction`. +/// +/// **Learning (any remote, no firmware table).** Pick an action in the `learn` select; the next +/// decoded IR code binds to it (stored in that action's `code …` control, which persists like any +/// control). Press that remote button afterwards and its code is looked up → the action runs. This +/// is more flexible than a fixed per-remote preset table (MoonLight's model): it works with any +/// remote, the user teaches it live. A code bound to nothing is shown in `last code` and ignored. +/// +/// **Relative adjust.** An action nudges a target control by a signed delta, clamped to the +/// control's own `[min, max]` — read generically from the target's `ControlDescriptor`, so the +/// same code adjusts a 0–255 brightness slider and an N-option palette Select without knowing +/// either's domain. A future LightsControl hub (docs/backlog) absorbs this as the normalised +/// interface effects read; `latestCode()` is the seam it consumes (the `AudioModule::latestFrame` +/// pattern). +/// +/// **Not auto-wired.** Factory-registered like AudioModule / I2cScanModule, so a board with an IR +/// receiver adds it via the installer device catalog (its `pin` carrying that board's IR GPIO) or +/// the user adds it from the UI. On the SE16 the IR line shares GPIO 5 with the Ethernet MISO +/// through the board's hardware switch; the pin is the receiver input. +/// +/// **Prior art:** consumer IR remotes use the NEC protocol (a 32-bit address+command frame, +/// LSB-first, ~9 ms lead burst); the ESP-IDF RMT peripheral decodes it (the espressif +/// `ir_nec_transceiver` example). The decode itself lives behind `platform::irRead`. +class IrModule : public MoonModule { +public: + ModuleRole role() const override { return ModuleRole::Peripheral; } + + void onBuildControls() override { + controls_.addPin("pin", pin_); + controls_.addSelect("learn", learn_, kLearnOptions, kActionCount + 1); + for (uint8_t i = 0; i < kActionCount; i++) { + // The learned code for this action, shown next to it. A Text control (persistable), + // rendered read-only: the learn flow + persistence write it, the user doesn't type it — + // the same "persist but tooling-set" pattern as SystemModule.deviceModel. (ReadOnly is + // display-only and NOT persisted, so it would lose the binding on reboot; Text persists.) + // No UI button per action: the remote drives the action once learned, so a button would + // duplicate that — the `learn` select + these read-outs are the whole interface. + controls_.addText(kActions[i].codeCtrl, codeStr_[i], sizeof(codeStr_[i])); + controls_.setReadOnly(controls_.count() - 1, true); + } + // No "last code" control — a received code shows in the status line ("received 0x…" / + // "learned … = 0x…"), so a separate read-out would duplicate it. + MoonModule::onBuildControls(); + } + + // Setup state so the module says whether it can receive (a pin is required). Re-run on any + // rebuild so setting the pin updates the status live. Also rebuild the fast uint32 lookup from + // the persisted hex strings — persistence restores the codeStr_ buffers (Text controls), so + // parse each back into learnedCode_ here (post-load) so a learned binding survives a reboot. + void onBuildState() override { + for (uint8_t i = 0; i < kActionCount; i++) + learnedCode_[i] = codeStr_[i][0] ? std::strtoul(codeStr_[i], nullptr, 0) : 0; + reportReady(); + MoonModule::onBuildState(); + } + + void onUpdate(const char* controlName) override { + if (std::strcmp(controlName, "pin") == 0) reportReady(); + else if (std::strcmp(controlName, "learn") == 0) { + if (learn_ != 0) setStatus("learning: press a remote button", Severity::Status); + else reportReady(); + } + } + + void loop() override { + if (pin_ < 0) return; + uint32_t code = 0; + if (platform::irRead(static_cast(pin_), code)) processCode(code); + } + + /// The last decoded IR code (0 = none yet). The seam a future LightsControl consumes. + uint32_t latestCode() const { return lastCode_; } + + /// Feed a decoded code as if it arrived from the receiver — the entry the host unit tests + /// drive (platform::irRead is a stub on desktop). Mirrors DevicesModule::injectPacketForTest. + void injectCodeForTest(uint32_t code) { processCode(code); } + +private: + // One action: a UI button + a learnable remote code, nudging (module, control) by delta, + // clamped to the control's own bounds. Adding an action is one row here. + struct Action { + const char* button; // the UI button name + const char* codeCtrl; // the readonly control showing this action's learned code + const char* module; + const char* control; + int delta; + }; + static constexpr Action kActions[] = { + {"brightness up", "code brightness up", "Drivers", "brightness", +16}, + {"brightness down", "code brightness down", "Drivers", "brightness", -16}, + {"palette next", "code palette next", "Drivers", "palette", +1}, + {"palette prev", "code palette prev", "Drivers", "palette", -1}, + }; + static constexpr uint8_t kActionCount = sizeof(kActions) / sizeof(kActions[0]); + // learn select: index 0 = off, 1..N = arm learning for kActions[index-1]. + static constexpr const char* kLearnOptions[] = { + "off", "brightness up", "brightness down", "palette next", "palette prev", + }; + + // Handle a decoded code: in learn mode bind it to the armed action (and persist); otherwise + // look it up and run the bound action, or report it as unassigned. The shared entry for a real + // receive (loop) and a test injection. + void processCode(uint32_t code) { + lastCode_ = code; // the raw frame; surfaced via latestCode() + the status line below + + if (learn_ != 0) { + const uint8_t idx = learn_ - 1; // learn select is 1-based (0 = off) + learnedCode_[idx] = code; + std::snprintf(codeStr_[idx], sizeof(codeStr_[idx]), "0x%08lX", + static_cast(code)); + std::snprintf(statusBuf_, sizeof(statusBuf_), "learned %s = 0x%08lX", + kActions[idx].button, static_cast(code)); + setStatus(statusBuf_); + learn_ = 0; + // Persist the new binding: markDirty flags the subtree, noteDirty stamps the debounce + // timer loop1s watches. A bound code is written straight to codeStr_ here (not via + // setControl), so it must schedule the save itself — else the binding could be lost + // before an unrelated save happens to run. + markDirty(); + FilesystemModule::noteDirty(); + return; + } + for (uint8_t i = 0; i < kActionCount; i++) { + if (learnedCode_[i] != 0 && learnedCode_[i] == code) { runAction(kActions[i]); return; } + } + std::snprintf(statusBuf_, sizeof(statusBuf_), "received 0x%08lX (unassigned)", + static_cast(code)); + setStatus(statusBuf_); // status only — nothing persistent changed, so no dirty mark + } + + // Read the target control's current value + bounds, apply the clamped delta, set it through the + // shared primitive — the same path the UI slider takes, so the change rebuilds the correction / + // active palette and persists identically. Reports what it changed. + void runAction(const Action& a) { + Scheduler* sched = Scheduler::instance(); + if (!sched) return; + MoonModule* target = sched->firstByName(a.module); + if (!target) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "%s: no %s module", a.button, a.module); + setStatus(statusBuf_, Severity::Warning); + markDirty(); + return; + } + auto& ctrls = target->controls(); + for (uint8_t i = 0; i < ctrls.count(); i++) { + auto& c = ctrls[i]; + if (std::strcmp(c.name, a.control) != 0) continue; + int next = controlIntValue(c) + a.delta; + if (next < c.min) next = c.min; + if (next > c.max) next = c.max; + char valueJson[32]; + std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%d}", next); + sched->setControl(a.module, a.control, valueJson); + std::snprintf(statusBuf_, sizeof(statusBuf_), "%s.%s → %d", a.module, a.control, next); + setStatus(statusBuf_); + markDirty(); + return; + } + } + + // Report setup readiness (static messages → no format buffer needed). + void reportReady() { + if (pin_ < 0) setStatus("set pin to receive", Severity::Warning); + else setStatus("ready"); + } + + // A 1-byte numeric control (Uint8 / Select) read as int via its descriptor pointer — covers + // every kActions target (brightness Uint8, palette Select both store a uint8_t). + static int controlIntValue(const ControlDescriptor& c) { + return c.ptr ? *static_cast(c.ptr) : 0; + } + + int8_t pin_ = -1; // IR receiver GPIO; -1 until a board/user sets it + uint8_t learn_ = 0; // learn select: 0=off, 1..N=arm kActions[idx-1] + uint32_t learnedCode_[kActionCount] = {}; // bound code per action (0 = unbound) + char codeStr_[kActionCount][12] = {}; // hex read-out per learned code (persisted control) + uint32_t lastCode_ = 0; // last decoded frame (0 = none) + char statusBuf_[48] = ""; // storage for dynamic setStatus() text +}; + +} // namespace mm diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h index 1af4372d..c63d9022 100644 --- a/src/core/NetworkModule.h +++ b/src/core/NetworkModule.h @@ -263,7 +263,10 @@ class NetworkModule : public MoonModule { // (ESP-IDF's minimum) — write 2 or higher for predictable // behavior. Always bound on radio-capable builds; the // deviceModels.json catalog injects 8 dBm for brown-out-prone boards. + // Hidden with the same radioOn gate as the txPower readout above — a WiFi + // TX-power cap is meaningless on Ethernet / Idle where the radio is off. controls_.addInt16("txPowerSetting", txPowerSetting_, 0, 21); + controls_.setHidden(controls_.count() - 1, !radioOn); } controls_.addBool("mDNS", mdnsEnabled_); diff --git a/src/core/Scheduler.cpp b/src/core/Scheduler.cpp index d914e47b..74b4ef1a 100644 --- a/src/core/Scheduler.cpp +++ b/src/core/Scheduler.cpp @@ -1,5 +1,7 @@ #include "core/Scheduler.h" +#include "core/Control.h" // applyControlValue + ApplyResult in setControl +#include "core/JsonUtil.h" // mm::json::parseBool for the "enabled" pseudo-control #include "platform/platform.h" #include // std::snprintf in ensureUniqueName @@ -13,6 +15,7 @@ void Scheduler::addModule(MoonModule* mod) { } void Scheduler::setup() { + instance_ = this; // the one live Scheduler, reachable via Scheduler::instance() startTime_ = platform::millis(); // Phase 1: bind each module's controls. After this, ControlList descriptors hold @@ -127,6 +130,7 @@ void Scheduler::teardown() { deleteTree(modules_[i - 1]); } moduleCount_ = 0; + instance_ = nullptr; } uint32_t Scheduler::elapsed() const { @@ -202,6 +206,48 @@ MoonModule* Scheduler::firstByName(const char* name) { return nullptr; } +Scheduler::SetControlResult Scheduler::setControl(const char* moduleName, + const char* controlName, + const char* valueJson) { + MoonModule* target = firstByName(moduleName); + if (!target) return SetControlResult::ModuleNotFound; + + // Module-level "enabled" pseudo-control — toggles the flag, then a full rebuild so the + // disabled subtree stops/starts ticking. + if (std::strcmp(controlName, "enabled") == 0) { + target->setEnabled(mm::json::parseBool(valueJson, "value")); + target->markDirty(); + if (noteDirtyHook_) noteDirtyHook_(); + buildState(); + return SetControlResult::Ok; + } + + auto& ctrls = target->controls(); + for (uint8_t i = 0; i < ctrls.count(); i++) { + auto& c = ctrls[i]; + if (std::strcmp(c.name, controlName) != 0) continue; + + // Per-type parse + validate + apply lives in Control.cpp. A non-Ok result leaves + // the storage untouched, so there is no rollback to do. + switch (applyControlValue(c, valueJson, "value")) { + case ApplyResult::Ok: break; + case ApplyResult::OutOfRange: return SetControlResult::OutOfRange; + case ApplyResult::Malformed: return SetControlResult::Malformed; + case ApplyResult::ReadOnly: return SetControlResult::ReadOnly; + } + // Rebuild the control list so onBuildControls() re-evaluates conditional visibility + // for the new value; fire the three-tier change reaction (onUpdate always, a + // tree-wide buildState only when the control reshapes dims/mapping); persist. + target->rebuildControls(); + target->onUpdate(controlName); + target->markDirty(); + if (noteDirtyHook_) noteDirtyHook_(); + if (target->controlChangeTriggersBuildState(controlName)) buildState(); + return SetControlResult::Ok; + } + return SetControlResult::ControlNotFound; +} + void Scheduler::walkAndEnsureUnique(MoonModule* mod) { if (!mod) return; ensureUniqueName(mod); diff --git a/src/core/Scheduler.h b/src/core/Scheduler.h index f23d0e74..c89c7480 100644 --- a/src/core/Scheduler.h +++ b/src/core/Scheduler.h @@ -54,6 +54,12 @@ class Scheduler { using LoadAllFn = void(*)(Scheduler*); void setLoadAllHook(LoadAllFn fn) { loadAllHook_ = fn; } + /// Hook invoked after a control mutation so the persistence layer can schedule a + /// debounced save (FilesystemModule::noteDirty). Same decoupling as setLoadAllHook — + /// Scheduler stays independent of FilesystemModule's type. No-op if unset. + using NoteDirtyFn = void(*)(); + void setNoteDirtyHook(NoteDirtyFn fn) { noteDirtyHook_ = fn; } + void addModule(MoonModule* mod); void setup(); void tick(); @@ -83,13 +89,43 @@ class Scheduler { /// First module in tree-walk order with this name, or nullptr if none. MoonModule* firstByName(const char* name); + /// The single live Scheduler, or nullptr before setup() / after teardown(). Mirrors + /// FilesystemModule::instance_ — the one Scheduler is statically reachable so a module + /// created by the factory (IrModule) can call setControl() without a per-module injection. + static Scheduler* instance() { return instance_; } + + /// Outcome of setControl — the generic control-set primitive's result. Transport + /// layers map these to their own status (HTTP → 404 / 400 / 409, …). + enum class SetControlResult : uint8_t { + Ok, + ModuleNotFound, ///< no module with that name in the tree + ControlNotFound, ///< module exists but has no such control + OutOfRange, ///< numeric value outside the control's bounds + Malformed, ///< value didn't parse + ReadOnly, ///< tried to write a display-only control + }; + + /// Set one control by (module name, control name) to a value, applying the full + /// control-change reaction: parse+validate, rebuild the module's control list, fire + /// onUpdate, mark dirty for persistence, and buildState() when the control reshapes + /// dims/mapping. `valueJson` is a small JSON object read for its "value" key + /// (`{"value":128}`) — the same shape /api/control, Improv, and the WLED bridge send. + /// This is THE domain-neutral way for any module (IR, buttons, network bridges) to + /// drive another module's control: they compose against this one primitive instead of + /// reaching into a target's internals. The special control name "enabled" toggles the + /// module's enabled flag. Returns the outcome; a transport maps it to its status codes. + SetControlResult setControl(const char* moduleName, const char* controlName, + const char* valueJson); + private: void walkAndEnsureUnique(MoonModule* mod); static MoonModule* firstInTree(MoonModule* mod, const char* name); + static inline Scheduler* instance_ = nullptr; std::array modules_{}; uint8_t moduleCount_ = 0; LoadAllFn loadAllHook_ = nullptr; + NoteDirtyFn noteDirtyHook_ = nullptr; uint32_t startTime_ = 0; uint32_t lastLoop20ms_ = 0; uint32_t lastLoop1s_ = 0; diff --git a/src/light/WLEDAudioSyncPacket.h b/src/light/WLEDAudioSyncPacket.h new file mode 100644 index 00000000..48b98850 --- /dev/null +++ b/src/light/WLEDAudioSyncPacket.h @@ -0,0 +1,96 @@ +#pragma once + +#include "core/AudioFrame.h" + +#include +#include + +namespace mm { + +// WLED audio-sync wire format — the one place the packet layout lives (the +// ArtNetPacket.h / DdpPacket.h convention). AudioModule builds it to broadcast +// its analysed audio, and parses it to drive effects from a peer's audio; a unit +// test round-trips build↔parse against a golden byte vector so we can never drift +// from WLED. The contract is fixed by netmindz/WLED-sync (the header MoonModules' +// MoonLight receives with, D_WLEDAudio.h), so the bytes must be exact. +// +// v2 packet — 44 bytes, UDP port 11988, broadcast. Matches the "packed" struct in +// WLED-sync.h; we hand-serialise the exact offsets (like ArtNet/DDP) rather than +// rely on cross-compiler struct packing. The two "gap" runs are real wire bytes +// (WLED's struct has explicit gap1/gap2 padding), so they're part of the 44 and +// sent as zero. Floats are little-endian IEEE-754 (WLED memcpy's the struct on a +// little-endian MCU; every projectMM target is little-endian too, so a raw memcpy +// reproduces the exact bytes). +// +// 0-5 header "00002" (+ NUL) +// 6-7 gap1 (zero) +// 8-11 sampleRaw float — AudioFrame.level (WLED volumeRaw) +// 12-15 sampleSmth float — AudioFrame.levelSmoothed (WLED volume/volumeSmth) +// 16 samplePeak u8 — 1 = beat/peak this frame, else 0 +// 17 frameCounter u8 — increments per send, for dup/reorder detection +// 18-33 fftResult[16] u8×16 — AudioFrame.bands[16] (the 16 GEQ channels) +// 34-35 gap2 (zero) +// 36-39 FFT_Magnitude float — AudioFrame.peakMag +// 40-43 FFT_MajorPeak float — AudioFrame.peakHz + +constexpr uint16_t WLED_SYNC_PORT = 11988; +constexpr size_t WLED_SYNC_PACKET_SIZE = 44; +// 6 chars incl NUL: "00002". v1 packets use "00001" (83 bytes) — legacy, ignored. +constexpr char WLED_SYNC_HEADER[6] = "00002"; +constexpr size_t WLED_SYNC_NUM_BANDS = 16; // == AudioFrame bands + WLED NUM_GEQ_CHANNELS + +// Little-endian IEEE-754 store/load. Every projectMM target (Xtensa/RISC-V ESP32, +// desktop x86/arm64) is little-endian, and so is WLED's source MCU — so the raw +// float bytes are the wire bytes. Kept as a helper so the intent is explicit and +// the round-trip test pins it. +inline void wledPutFloatLE(uint8_t* p, float v) { std::memcpy(p, &v, 4); } +inline float wledGetFloatLE(const uint8_t* p) { float v; std::memcpy(&v, p, 4); return v; } + +// Truncate a wire float into an AudioFrame uint16 field, bounded to [0, 65535]. +// A foreign/garbage packet that passed the header check can carry NaN or an +// out-of-range magnitude; casting such a float straight to uint16_t is undefined, +// so clamp first. NaN fails both comparisons and falls through to 0. +inline uint16_t wledFloatToU16(float v) { + if (!(v > 0.0f)) return 0; // <= 0 or NaN + if (v > 65535.0f) return 65535; + return static_cast(v); +} + +// Build a v2 audio-sync packet from an AudioFrame into out (>= 44 bytes). +// `frameCounter` and `peak` are the two fields not carried by AudioFrame (the +// caller owns the send counter and the beat flag). Returns the packet size (44). +inline size_t buildWledAudioSync(uint8_t out[WLED_SYNC_PACKET_SIZE], const AudioFrame& f, + uint8_t frameCounter, bool peak) { + std::memset(out, 0, WLED_SYNC_PACKET_SIZE); // zeroes header pad + both gaps + std::memcpy(out, WLED_SYNC_HEADER, 6); // "00002\0" + wledPutFloatLE(out + 8, static_cast(f.level)); // sampleRaw + wledPutFloatLE(out + 12, static_cast(f.levelSmoothed)); // sampleSmth + out[16] = peak ? 1 : 0; // samplePeak + out[17] = frameCounter; // frameCounter + std::memcpy(out + 18, f.bands, WLED_SYNC_NUM_BANDS); // fftResult[16] + wledPutFloatLE(out + 36, static_cast(f.peakMag)); // FFT_Magnitude + wledPutFloatLE(out + 40, static_cast(f.peakHz)); // FFT_MajorPeak + return WLED_SYNC_PACKET_SIZE; +} + +// Parse + validate a v2 audio-sync packet into an AudioFrame (the inverse of +// build). Returns true only for a well-formed v2 datagram: exactly 44 bytes and +// the "00002" header. A v1 (83-byte "00001") packet, a short/foreign datagram, or +// a null buffer returns false so the caller ignores it (be strict on our own +// format, drop everything else — never crash). The float level/peak fields are +// truncated into the AudioFrame's small-integer fields, bounded to [0, 65535] +// (wledFloatToU16) so a foreign packet carrying NaN or an out-of-range value +// can't produce an undefined conversion. +inline bool parseWledAudioSync(const uint8_t* pkt, size_t len, AudioFrame& out) { + if (!pkt || len != WLED_SYNC_PACKET_SIZE) return false; + if (std::memcmp(pkt, WLED_SYNC_HEADER, 6) != 0) return false; + out.level = wledFloatToU16(wledGetFloatLE(pkt + 8)); + out.levelSmoothed = wledFloatToU16(wledGetFloatLE(pkt + 12)); + // pkt[16] samplePeak and pkt[17] frameCounter are hints the AudioFrame doesn't carry. + std::memcpy(out.bands, pkt + 18, WLED_SYNC_NUM_BANDS); + out.peakMag = wledFloatToU16(wledGetFloatLE(pkt + 36)); + out.peakHz = wledFloatToU16(wledGetFloatLE(pkt + 40)); + return true; +} + +} // namespace mm diff --git a/src/main.cpp b/src/main.cpp index 33389fc2..5788118e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -97,6 +97,7 @@ #include "core/SystemModule.h" #include "core/AudioModule.h" #include "core/I2cScanModule.h" +#include "core/IrModule.h" #include "core/FirmwareUpdateModule.h" #include "core/ImprovProvisioningModule.h" #include "core/DevicesModule.h" @@ -210,6 +211,7 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("SystemModule", "core/SystemModule.md"); mm::ModuleFactory::registerType("AudioModule", "core/AudioModule.md"); mm::ModuleFactory::registerType("I2cScanModule", "core/I2cScanModule.md"); + mm::ModuleFactory::registerType("IrModule", "core/IrModule.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 f1327927..9484937a 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -53,6 +53,10 @@ struct EthPinConfig { // (shared code) `if constexpr (hasEthernet)`s the eth controls off, and seeds its // members from this default; both must exist here for that shared code to compile. constexpr bool hasEthernet = false; +// Some-IP-stack flag (WiFi OR Ethernet) — mirrors the esp32 config so shared code +// (WLED audio sync, UDP interop) gates on "has network" uniformly. True on desktop +// via the WiFi stubs (UdpSocket has a desktop implementation). +constexpr bool hasNetwork = hasWiFi || hasEthernet; // No SPI-Ethernet (W5500) driver on desktop either — NetworkModule's live-reconfigure // path gates on this, so it must exist on every platform (mirrors the esp32 flag). constexpr bool hasEthW5500 = false; diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index f3e096af..1a5dc347 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -448,6 +448,10 @@ int wifiStaRssi() { return 0; } bool wifiApInit(const char* /*apName*/, const char* /*ip*/) { return false; } bool wifiApConnected() { return false; } void wifiApStop() {} + +// Host sockets work regardless of the (stubbed) link predicates above, and there is +// no lwip-style init race — always socket-safe. +bool networkReady() { return true; } int wifiTxPower() { return 0; } // Match the API contract: 0 is a successful no-op (matches ESP-IDF // MM_NO_WIFI stub semantics). Any non-zero value returns false since @@ -727,6 +731,8 @@ int TcpConnection::read(uint8_t* buf, size_t maxLen) { bool TcpConnection::write(const uint8_t* data, size_t len) { if (fd_ < 0) return false; + // Send ALL bytes (blocking retry on a full buffer) — an HTTP response must arrive complete. + // See the ESP32 impl: a healthy interface drains in microseconds so the retry rarely spins. size_t sent = 0; while (sent < len) { auto n = ::send(sock(fd_), reinterpret_cast(data + sent), @@ -951,4 +957,8 @@ size_t i2cScan(uint16_t /*sda*/, uint16_t /*scl*/, uint8_t* /*out*/, size_t /*ma return kI2cBusUnavailable; } +// No IR receiver on the host — the seam is a no-op so IrModule runs (its buttons still work +// through Scheduler::setControl); reception is ESP32-only. +bool irRead(uint16_t /*pin*/, uint32_t& /*codeOut*/) { return false; } + } // namespace mm::platform diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index 7bff2eb9..7405df5a 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -164,6 +164,12 @@ constexpr bool hasEthernet = false; constexpr bool hasEthernet = true; #endif +// True when the firmware carries an IP stack at all — WiFi OR Ethernet. UdpSocket +// (lwIP BSD sockets) is present whenever either is, so features that only need +// "some network" (WLED audio sync, any UDP interop) gate on this rather than +// hasWiFi — an Ethernet-only board (the MHC-WLED P4 shield) still has UDP. +constexpr bool hasNetwork = hasWiFi || hasEthernet; + // Which Ethernet PHY *drivers* this firmware actually carries. The W5500 SPI // driver is compiled in only on chips with no internal EMAC and the SPI-eth // fragment (the S3 — CONFIG_ETH_USE_SPI_ETHERNET set, CONFIG_ETH_USE_ESP32_EMAC diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 807a7786..55eaee14 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -612,7 +612,21 @@ static bool ethInitSpi() { devcfg.queue_size = 20; eth_w5500_config_t w5500_config = ETH_W5500_DEFAULT_CONFIG(kSpiHost, &devcfg); - w5500_config.int_gpio_num = ethConfig_.spiIrq; // -1 → W5500 driver falls back to polling + w5500_config.int_gpio_num = ethConfig_.spiIrq; // wired INT pin (interrupt), or -1 for polling + if (ethConfig_.spiIrq >= 0) { + // Interrupt-driven RX: the W5500 driver registers its handler with gpio_isr_handler_add(), + // which requires the per-pin ISR service to be installed first. Install it once here; + // ESP_ERR_INVALID_STATE means another driver already installed it, which is fine. + esp_err_t isr = gpio_install_isr_service(0); + if (isr != ESP_OK && isr != ESP_ERR_INVALID_STATE) { + ESP_LOGW(NET_TAG, "gpio_install_isr_service failed (%s) — W5500 INT may not fire", + esp_err_to_name(isr)); + } + } else { + // No INT pin: IDF v6's W5500 driver requires a poll period when int_gpio_num < 0, so drive + // the MAC by polling — 10 ms services RX promptly without an interrupt. + w5500_config.poll_period_ms = 10; + } eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG(); eth_phy_config_t phy_config = ETH_PHY_DEFAULT_CONFIG(); @@ -1020,6 +1034,14 @@ bool wifiSetTxPower(int8_t quarterDbm) { return quarterDbm == 0; } #endif // MM_NO_WIFI +// Socket-safe once any interface has an IP: at that point esp_netif_init() has run +// and the lwip core mutex exists, so opening a socket won't assert. Each predicate +// is stubbed to false in the build that lacks its interface, so this OR compiles and +// answers correctly on every firmware. +bool networkReady() { + return ethConnected() || wifiStaConnected() || wifiApConnected(); +} + // Bring the mDNS stack up (idempotent) and ADVERTISE this device as .local. // 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 @@ -1331,6 +1353,9 @@ int TcpConnection::read(uint8_t* buf, size_t maxLen) { bool TcpConnection::write(const uint8_t* data, size_t len) { if (fd_ < 0) return false; + // Send every byte, retrying on a full send buffer — an HTTP response must arrive complete. + // Blocks the caller until the peer drains, which suits a one-shot response on a per-request + // connection; a healthy interface drains in microseconds, so the retry rarely spins. size_t sent = 0; while (sent < len) { auto n = lwip_write(fd_, data + sent, len - sent); @@ -1419,4 +1444,6 @@ void TcpServer::close() { } } +// irRead (IR receive) lives in platform_esp32_ir.cpp — an RMT NEC decoder. + } // namespace mm::platform diff --git a/src/platform/esp32/platform_esp32_ir.cpp b/src/platform/esp32/platform_esp32_ir.cpp new file mode 100644 index 00000000..00a46dbf --- /dev/null +++ b/src/platform/esp32/platform_esp32_ir.cpp @@ -0,0 +1,137 @@ +// IR receive — the platform::irRead seam (declared in platform.h), decoding the NEC remote +// protocol off the RMT RX peripheral. A persistent RX channel runs on the IR pin; its done ISR +// only records how many symbols arrived and signals a queue (ISR-minimal: no decode, no driver +// call in interrupt context — the same discipline as rmtWs2812RxCapture). irRead(), on the render +// task, drains that signal non-blocking, decodes the captured symbols, and re-arms the channel. +// IrModule is the sole caller. +// +// NEC protocol: a 9 ms lead mark + 4.5 ms space, then 32 bits LSB-first (address, ~address, +// command, ~command), each a 560 µs mark followed by a 560 µs space (0) or a 1690 µs space (1), +// then a final 560 µs stop mark. A repeat frame (9 ms mark + 2.25 ms space) is ignored — we +// surface distinct presses, not auto-repeat. Prior art: the ESP-IDF ir_nec_transceiver example; +// the timing is the published NEC standard. + +#include "platform/platform.h" + +#include "driver/rmt_rx.h" +#include "freertos/FreeRTOS.h" +#include "freertos/queue.h" +#include "soc/soc_caps.h" // SOC_RMT_MEM_WORDS_PER_CHANNEL + +#include + +namespace mm::platform { + +namespace { + +// NEC timings in µs (RX resolution is 1 µs — see kResolutionHz). ±30 % windows absorb drift. +constexpr uint32_t kLeadMark = 9000; +constexpr uint32_t kLeadSpace = 4500; +constexpr uint32_t kBitMark = 560; +constexpr uint32_t kZeroSpace = 560; +constexpr uint32_t kOneSpace = 1690; +constexpr uint32_t kResolutionHz = 1000000; // 1 tick = 1 µs + +// A symbol duration falls within ±30 % of `target`. Task-context only (called from the decode in +// irRead, never the ISR), so no IRAM requirement. +inline bool nearUs(uint32_t d, uint32_t target) { + const uint32_t tol = target * 3 / 10; + return d + tol >= target && d <= target + tol; +} + +// Decode NEC symbols → 32-bit code. True + code on a well-formed data frame; false on a repeat +// frame or malformed input. Runs on the render task (from irRead), not the ISR. +bool decodeNec(const rmt_symbol_word_t* sym, size_t n, uint32_t& out) { + if (n < 34) return false; // repeat (2 symbols) or truncated + if (!nearUs(sym[0].duration0, kLeadMark)) return false; + if (!nearUs(sym[0].duration1, kLeadSpace)) return false; // 4.5 ms space = data (2.25 = repeat) + uint32_t code = 0; + for (int i = 0; i < 32; i++) { + const rmt_symbol_word_t& s = sym[i + 1]; + if (!nearUs(s.duration0, kBitMark)) return false; + if (nearUs(s.duration1, kOneSpace)) code |= (1u << i); // LSB-first + else if (!nearUs(s.duration1, kZeroSpace)) return false; // neither 0 nor 1 → malformed + } + out = code; + return true; +} + +// Persistent channel + state. Opened lazily on the first irRead for a pin; reopened if the pin +// changes. One IR receiver per device, so a single static channel suffices. +rmt_channel_handle_t rxChan_ = nullptr; +QueueHandle_t doneQueue_ = nullptr; // ISR → task: how many symbols the last frame captured +int currentPin_ = -1; +rmt_symbol_word_t rxBuf_[68]; // NEC = 34 symbols (lead + 32 bits + stop); slack for noise +rmt_receive_config_t rxCfg_ = {}; + +// ISR: record the symbol count and wake the task. No decode, no rmt_* re-arm here — the task +// re-arms after it has copied the buffer, so the DMA target is never overwritten mid-decode. +bool IRAM_ATTR rxDoneCb(rmt_channel_handle_t, const rmt_rx_done_event_data_t* edata, void*) { + size_t n = edata->num_symbols; + BaseType_t high = pdFALSE; + xQueueSendFromISR(doneQueue_, &n, &high); + return high == pdTRUE; +} + +void closeChannel() { + if (rxChan_) { rmt_disable(rxChan_); rmt_del_channel(rxChan_); rxChan_ = nullptr; } + if (doneQueue_) { vQueueDelete(doneQueue_); doneQueue_ = nullptr; } + currentPin_ = -1; +} + +// Arm one receive into rxBuf_. Called from the task (initial arm + re-arm after each frame). +bool arm() { return rmt_receive(rxChan_, rxBuf_, sizeof(rxBuf_), &rxCfg_) == ESP_OK; } + +// (Re)open the RX channel on `pin`. Idempotent for an unchanged pin. Returns true if live after. +bool ensureChannel(int pin) { + if (rxChan_ && currentPin_ == pin) return true; + closeChannel(); + if (pin < 0) return false; + + rmt_rx_channel_config_t cfg = {}; + cfg.gpio_num = static_cast(pin); + cfg.clk_src = RMT_CLK_SRC_DEFAULT; + cfg.resolution_hz = kResolutionHz; + cfg.mem_block_symbols = SOC_RMT_MEM_WORDS_PER_CHANNEL; // one hardware block is ample for NEC + if (rmt_new_rx_channel(&cfg, &rxChan_) != ESP_OK) { rxChan_ = nullptr; return false; } + + doneQueue_ = xQueueCreate(1, sizeof(size_t)); + if (!doneQueue_) { rmt_del_channel(rxChan_); rxChan_ = nullptr; return false; } + + rmt_rx_event_callbacks_t cbs = {}; + cbs.on_recv_done = rxDoneCb; + rmt_rx_register_event_callbacks(rxChan_, &cbs, nullptr); + + rxCfg_.signal_range_min_ns = 200; // shorter than a 560 µs mark — filters glitches + rxCfg_.signal_range_max_ns = 12000000; // longer than the 9 ms lead — ends the frame + if (rmt_enable(rxChan_) != ESP_OK || !arm()) { + closeChannel(); + return false; + } + currentPin_ = pin; + return true; +} + +} // namespace + +bool irRead(uint16_t pin, uint32_t& codeOut) { + if (!ensureChannel(static_cast(pin))) return false; + + // Non-blocking: did the ISR signal a completed frame since the last call? + size_t n = 0; + if (xQueueReceive(doneQueue_, &n, 0) != pdTRUE) return false; + + // Decode the captured buffer (task context), then re-arm for the next frame. Re-arming here — + // not in the ISR — guarantees the decode reads a stable buffer the next capture can't clobber. + const bool ok = decodeNec(rxBuf_, n, codeOut); + if (!arm()) { + // Re-arm failed → the channel is enabled but not receiving, and ensureChannel() would + // treat it as still-open (pin unchanged) and never recover it. Tear it down so the next + // irRead reopens a fresh channel on this pin. + closeChannel(); + return false; + } + return ok; +} + +} // namespace mm::platform diff --git a/src/platform/platform.h b/src/platform/platform.h index 9b3dc795..8f2ba45a 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -145,6 +145,14 @@ bool wifiApInit(const char* apName, const char* ip); bool wifiApConnected(); void wifiApStop(); +// True when it is safe to open/use a socket: the TCP/IP stack is initialised and +// an interface has an IP. On ESP32 that means Ethernet or WiFi (STA/AP) is up — +// calling any lwip socket API before then asserts (the core mutex is still null). +// Desktop: always true (host sockets work regardless of link state). Callers that +// open sockets at boot (before NetworkModule brings an interface up) must gate on +// this and open lazily from the tick path once it turns true. +bool networkReady(); + // Current WiFi transmit power, in dBm (ESP-IDF reports quarter-dBm internally // and we round to whole). Returns 0 when WiFi isn't initialised or the call // fails. Same value for STA and AP — WiFi has one radio at one TX power. @@ -311,7 +319,7 @@ class TcpConnection { bool valid() const { return fd_ >= 0; } int read(uint8_t* buf, size_t maxLen); // non-blocking: >0 data, 0 closed, -1 nothing - bool write(const uint8_t* data, size_t len); // blocking — retries until all sent + bool write(const uint8_t* data, size_t len); // blocking — sends all bytes (HTTP responses must complete) // Non-blocking partial write: send as many of `len` bytes as the socket accepts right // now, return the count actually written (0..len). -1 = socket error (caller closes); // 0 = WouldBlock (buffer full, try later) or len==0. The caller advances its own offset @@ -591,4 +599,12 @@ inline constexpr size_t kI2cBusUnavailable = static_cast(-1); // 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); +// Poll the IR receiver on `pin` for a decoded remote frame. Returns true and writes the +// frame into `codeOut` when a fresh code is available since the last call, false otherwise +// (nothing received, or IR decode unavailable on this target). Self-contained like i2cScan — +// it owns whatever peripheral it needs (an RMT RX channel on ESP32). Non-blocking: safe to +// call every tick. IrModule is the sole caller. ESP32 decodes NEC over RMT; desktop has no IR +// hardware and always returns false. +bool irRead(uint16_t pin, uint32_t& codeOut); + } // namespace mm::platform diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index e5431805..f74bd43a 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -8,6 +8,8 @@ add_executable(mm_tests unit/core/unit_math8.cpp unit/core/unit_noise.cpp unit/core/unit_crc.cpp + unit/core/unit_AudioModule_sync.cpp + unit/core/unit_IrModule.cpp unit/core/unit_Control_apply_absent_key.cpp unit/core/unit_Control_list.cpp unit/core/unit_DeviceIdentify.cpp @@ -45,6 +47,7 @@ add_executable(mm_tests unit/light/unit_AudioBands.cpp unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp unit/light/unit_NetworkSendDriver_packet.cpp + unit/light/unit_WledAudioSyncPacket.cpp unit/light/unit_BlendMap.cpp unit/light/unit_draw.cpp unit/light/unit_GameOfLifeEffect.cpp diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json index 1c434170..6b81aa2f 100644 --- a/test/scenarios/light/scenario_perf_full.json +++ b/test/scenarios/light/scenario_perf_full.json @@ -87,7 +87,7 @@ "pc-macos": { "tick_us": [ 0, - 12 + 25 ], "free_heap": [ 0, @@ -99,7 +99,7 @@ ], "at": [ "2026-06-17", - "2026-07-01" + "2026-07-04" ] }, "esp32s3-n16r8": { @@ -176,7 +176,7 @@ "pc-macos": { "tick_us": [ 0, - 8 + 17 ], "free_heap": [ 0, @@ -188,7 +188,7 @@ ], "at": [ "2026-06-17", - "2026-07-01" + "2026-07-04" ] }, "esp32s3-n16r8": { @@ -265,7 +265,7 @@ "pc-macos": { "tick_us": [ 0, - 7 + 20 ], "free_heap": [ 0, @@ -277,7 +277,7 @@ ], "at": [ "2026-06-17", - "2026-07-01" + "2026-07-04" ] }, "esp32s3-n16r8": { @@ -352,7 +352,7 @@ "pc-macos": { "tick_us": [ 0, - 3 + 10 ], "free_heap": [ 0, @@ -364,7 +364,7 @@ ], "at": [ "2026-06-17", - "2026-07-01" + "2026-07-04" ] }, "esp32s3-n16r8": { @@ -1217,7 +1217,7 @@ "pc-macos": { "tick_us": [ 16, - 462 + 490 ], "free_heap": [ 0, @@ -1229,7 +1229,7 @@ ], "at": [ "2026-06-17", - "2026-07-01" + "2026-07-04" ] }, "esp32s3-n16r8": { @@ -1318,7 +1318,7 @@ "pc-macos": { "tick_us": [ 3, - 21 + 28 ], "free_heap": [ 0, @@ -1330,7 +1330,7 @@ ], "at": [ "2026-06-17", - "2026-06-27" + "2026-07-04" ] }, "esp32s3-n16r8": { @@ -1411,7 +1411,7 @@ "pc-macos": { "tick_us": [ 14, - 85 + 92 ], "free_heap": [ 0, @@ -1423,7 +1423,7 @@ ], "at": [ "2026-06-17", - "2026-06-27" + "2026-07-04" ] }, "esp32s3-n16r8": { @@ -1596,8 +1596,8 @@ "observed": { "pc-macos": { "tick_us": [ - 272, - 1712 + 271, + 2452 ], "free_heap": [ 0, @@ -1609,7 +1609,7 @@ ], "at": [ "2026-06-17", - "2026-07-01" + "2026-07-04" ] }, "esp32s3-n16r8": { @@ -1902,7 +1902,7 @@ "pc-macos": { "tick_us": [ 14, - 106 + 239 ], "free_heap": [ 0, @@ -1914,7 +1914,7 @@ ], "at": [ "2026-06-17", - "2026-07-01" + "2026-07-04" ] }, "esp32": { @@ -1995,7 +1995,7 @@ "pc-macos": { "tick_us": [ 61, - 296 + 769 ], "free_heap": [ 0, @@ -2007,7 +2007,7 @@ ], "at": [ "2026-06-17", - "2026-07-01" + "2026-07-04" ] }, "esp32": { diff --git a/test/unit/core/unit_AudioModule_sync.cpp b/test/unit/core/unit_AudioModule_sync.cpp new file mode 100644 index 00000000..a1683f05 --- /dev/null +++ b/test/unit/core/unit_AudioModule_sync.cpp @@ -0,0 +1,176 @@ +// @module AudioModule +// @also WledAudioSyncPacket + +// Drives AudioModule's WLED audio-sync socket lifecycle on the host through the public +// loop() — the same entry the scheduler calls on-device. Covers: lazy open once per mode +// (syncEnsureSocket latches), the send path reaching "sending", send throttling, and the +// receive path over a real localhost UDP round-trip (frame replacement + the fresh→stale +// auto-blend fallback). platform::networkReady() is true on desktop, so the lazy open fires +// on the first tick — mirroring a device once its interface is up. +// +// Time is driven deterministically with platform::setTestNowMs() (the animation-test idiom) +// so the throttle/fallback windows are exact and the suite never sleeps a real second; only +// the actual UDP delivery is real, polled with a bounded retry (the NetworkReceiveEffect +// localhost-round-trip pattern). A test port (not 11988) avoids colliding with a running +// projectMM desktop app that would hold the real sync port. + +#include "doctest.h" +#include "core/AudioModule.h" +#include "light/WLEDAudioSyncPacket.h" +#include "platform/platform.h" + +#include +#include + +using namespace mm; + +namespace { +constexpr uint16_t kTestSyncPort = 21988; // a free high port, not the real 11988 + +// The status read-out is published by loop1s(); call it after a loop() so the assertions +// below see the current state string rather than the setup() baseline. +const char* status(AudioModule& a) { a.loop1s(); return a.syncStatusForTest(); } + +// A guard that freezes virtual time for a case and restores the real clock on scope exit, +// so a thrown assertion can't leak frozen time into the next case. +struct FrozenClock { + FrozenClock(uint32_t ms) { platform::setTestNowMs(ms); } + ~FrozenClock() { platform::setTestNowMs(0); } + void advance(uint32_t ms) { now_ += ms; platform::setTestNowMs(now_); } + uint32_t now_ = 1; +}; +} // namespace + +TEST_CASE("AudioModule sync=Send: lazy-opens once and reports sending") { + FrozenClock clk(1); + AudioModule a; + a.sync = 1; + a.syncPort = kTestSyncPort; + a.setup(); // syncReinit() only — socket NOT opened here (boot-safe) + CHECK(std::strstr(status(a), "waiting") != nullptr); // no loop() yet → still waiting + + a.loop(); // networkReady() true on desktop → opens now + CHECK(std::strcmp(status(a), "sending") == 0); + CHECK(a.syncOpenForTest()); + + // Idempotent: a second tick doesn't re-open (the latch holds). + a.loop(); + CHECK(a.syncOpenForTest()); + CHECK(std::strcmp(status(a), "sending") == 0); + + a.teardown(); + CHECK_FALSE(a.syncOpenForTest()); +} + +TEST_CASE("AudioModule sync=Send: broadcasts are throttled to ~kSyncSendIntervalMs") { + FrozenClock clk(1); + AudioModule a; + a.sync = 1; + a.syncPort = kTestSyncPort; + a.setup(); + a.loop(); // opens + first send (frameCounter bumps once) + REQUIRE(a.syncOpenForTest()); + + // More ticks within the same interval must not each emit — the frame counter (bumped + // only on an actual send) does not advance while the throttle window is open. + const uint8_t c0 = a.syncFrameCounterForTest(); + a.loop(); + a.loop(); + CHECK(a.syncFrameCounterForTest() == c0); // throttled: no send per tick + + // After the interval elapses, exactly one more send is allowed. + clk.advance(AudioModule::syncSendIntervalMsForTest() + 5); + a.loop(); + CHECK((uint8_t)(a.syncFrameCounterForTest() - c0) == 1); + + a.teardown(); +} + +TEST_CASE("AudioModule sync=Receive: a localhost WLED packet drives frame_, then auto-blends back") { + FrozenClock clk(1); + AudioModule a; + a.sync = 2; + a.syncPort = kTestSyncPort; + a.setup(); + a.loop(); // binds kTestSyncPort + REQUIRE(a.syncOpenForTest()); + CHECK(std::strcmp(status(a), "listening") == 0); + + // Send a real WLED v2 packet to the bound port over loopback. + AudioFrame peer; + peer.level = 222; peer.levelSmoothed = 111; peer.peakHz = 660; peer.peakMag = 55; + for (int i = 0; i < 16; i++) peer.bands[i] = static_cast(i * 8); + uint8_t pkt[WLED_SYNC_PACKET_SIZE]; + buildWledAudioSync(pkt, peer, /*frameCounter=*/1, /*peak=*/false); + + platform::UdpSocket tx; + REQUIRE(tx.open()); + REQUIRE(tx.connect("127.0.0.1", kTestSyncPort)); + REQUIRE(tx.sendTo(pkt, WLED_SYNC_PACKET_SIZE)); + + // Loopback delivery is async in real time — poll loop() until the peer frame lands + // (bounded, ≤100 iterations). Virtual time stays frozen, so the frame counts as fresh. + bool landed = false; + for (int i = 0; i < 100 && !landed; i++) { + a.loop(); + landed = a.audioFrame()->level == 222 && a.audioFrame()->peakHz == 660; + if (!landed) platform::delayMs(1); // real wait for the datagram, not virtual time + } + CHECK(landed); + CHECK(a.audioFrame()->levelSmoothed == 111); + CHECK(std::strcmp(status(a), "receiving") == 0); // fresh peer audio + + // Auto-blend: advance virtual time past the fallback window with no new packet — the + // peer goes stale and the status falls back to "listening" (the local mic resumes + // on-device). Deterministic: no real sleep. + clk.advance(AudioModule::syncFallbackMsForTest() + 20); + a.loop(); + CHECK(std::strcmp(status(a), "listening") == 0); + + tx.close(); + a.teardown(); +} + +TEST_CASE("AudioModule sync=Receive: a failed bind backs off instead of retrying every tick") { + FrozenClock clk(1); + // Hold the port with an external socket so the module's bind() fails. + platform::UdpSocket hog; + REQUIRE(hog.open()); + REQUIRE(hog.bind(kTestSyncPort)); + + AudioModule a; + a.sync = 2; + a.syncPort = kTestSyncPort; + a.setup(); + a.loop(); // first bring-up attempt → bind fails + CHECK_FALSE(a.syncOpenForTest()); + CHECK(std::strcmp(status(a), "receive: bind failed") == 0); + + // Within the backoff window, further ticks must NOT retry — the socket stays closed and + // the status is unchanged (no per-tick socket() churn). loop1s() only reasserts the + // baseline while syncOpen_ is false, so the string staying put is the observable proof. + a.loop(); + a.loop(); + CHECK_FALSE(a.syncOpenForTest()); + CHECK(std::strcmp(a.syncStatusForTest(), "receive: bind failed") == 0); + + // Release the port and advance past the backoff — the next tick retries and succeeds. + hog.close(); + clk.advance(AudioModule::syncOpenRetryMsForTest() + 5); + a.loop(); + CHECK(a.syncOpenForTest()); + CHECK(std::strcmp(status(a), "listening") == 0); + + a.teardown(); +} + +TEST_CASE("AudioModule sync=Off: no socket, reports off") { + FrozenClock clk(1); + AudioModule a; + a.sync = 0; + a.setup(); + a.loop(); + CHECK_FALSE(a.syncOpenForTest()); + CHECK(std::strcmp(status(a), "off") == 0); + a.teardown(); +} diff --git a/test/unit/core/unit_IrModule.cpp b/test/unit/core/unit_IrModule.cpp new file mode 100644 index 00000000..be49aaec --- /dev/null +++ b/test/unit/core/unit_IrModule.cpp @@ -0,0 +1,183 @@ +// @module IrModule +// @also Scheduler + +// Pins IrModule's action path: its buttons adjust another module's control through the shared +// Scheduler::setControl primitive, clamped to the control's own bounds. A fake "Drivers" module +// (a Knob with brightness + palette controls) stands in for the real one, so the test needs no +// light-domain modules — it proves the generic relative-adjust behaviour in isolation. The IR +// *reception* side is a platform stub (irRead returns false), so this focuses on the action +// path, which is the working, testable part of this cut. + +#include "doctest.h" +#include "core/IrModule.h" +#include "core/Scheduler.h" +#include "core/MoonModule.h" + +#include + +using namespace mm; + +namespace { + +// Stands in for Drivers: a brightness Uint8 (0–255) and a palette Select (0–3). Named "Drivers" +// so IrModule's kActions ("Drivers"/"brightness", "Drivers"/"palette") resolve to it. +struct FakeDrivers : public MoonModule { + uint8_t brightness = 100; + uint8_t palette = 1; + void onBuildControls() override { + controls_.addUint8("brightness", brightness, 0, 255); + // A Select's max is (optionCount - 1); addSelect binds min 0 / max count-1. + static const char* kPalettes[] = {"A", "B", "C", "D"}; + controls_.addSelect("palette", palette, kPalettes, 4); + } +}; + +// Build Scheduler + FakeDrivers + IrModule, run setup so Scheduler::instance() is live and +// controls are bound. Caller owns teardown via the scheduler (modules are heap-allocated). +struct Rig { + Scheduler scheduler; + FakeDrivers* drivers = new FakeDrivers(); + IrModule* ir = new IrModule(); + Rig() { + drivers->setName("Drivers"); + ir->setName("Ir"); + scheduler.addModule(drivers); + scheduler.addModule(ir); + scheduler.setup(); // binds controls + sets Scheduler::instance() + } + ~Rig() { scheduler.teardown(); } + // Learn `code` to the action at `learnIndex` (1-based: 1=brightness up, 2=down, 3=palette + // next, 4=prev), then that code drives the action on later inject. Mirrors the on-device flow: + // arm the learn select → the next received code binds → subsequent codes fire the action. + void learn(int learnIndex, uint32_t code) { + char v[24]; std::snprintf(v, sizeof(v), "{\"value\":%d}", learnIndex); + Scheduler::instance()->setControl("Ir", "learn", v); + ir->injectCodeForTest(code); // binds + disarms + } + void fire(uint32_t code) { ir->injectCodeForTest(code); } // a received code drives its action +}; + +} // namespace + +TEST_CASE("IrModule: a learned code adjusts Drivers brightness by the step") { + Rig r; + r.drivers->brightness = 100; + r.learn(1, 0xB1); // bind code 0xB1 → brightness up + r.learn(2, 0xB2); // bind code 0xB2 → brightness down + r.fire(0xB1); + CHECK(r.drivers->brightness == 116); // +16 + r.fire(0xB2); + r.fire(0xB2); + CHECK(r.drivers->brightness == 84); // 116 - 32 +} + +TEST_CASE("IrModule: firing a learned code reports what it changed via status") { + Rig r; + r.drivers->brightness = 100; + // Before setup drives anything, the pin is unset in the rig → readiness warns to set it. + CHECK(std::strstr(r.ir->status(), "set pin") != nullptr); + r.learn(1, 0xB1); + r.fire(0xB1); + // The action acknowledges the change it made, naming the target control + new value. + CHECK(std::strcmp(r.ir->status(), "Drivers.brightness → 116") == 0); +} + +TEST_CASE("IrModule: pin state drives readiness status") { + Rig r; + CHECK(std::strstr(r.ir->status(), "set pin") != nullptr); // unset pin → warns to set it + // Set a valid pin through the real path; onBuildState() re-reports readiness. + Scheduler::instance()->setControl("Ir", "pin", "{\"value\":5}"); + CHECK(std::strcmp(r.ir->status(), "ready") == 0); + // Back to unset → warns again. + Scheduler::instance()->setControl("Ir", "pin", "{\"value\":-1}"); + CHECK(std::strstr(r.ir->status(), "set pin") != nullptr); +} + +TEST_CASE("IrModule: a learned brightness code clamps at 0 and 255") { + Rig r; + r.learn(1, 0xB1); // brightness up + r.learn(2, 0xB2); // brightness down + r.drivers->brightness = 250; + r.fire(0xB1); + CHECK(r.drivers->brightness == 255); // 250+16 clamps to 255, not wrap + r.drivers->brightness = 8; + r.fire(0xB2); + CHECK(r.drivers->brightness == 0); // 8-16 clamps to 0, not underflow +} + +TEST_CASE("IrModule: learned palette codes step the Select and clamp at the ends") { + Rig r; + r.learn(3, 0xB3); // palette next + r.learn(4, 0xB4); // palette prev + r.drivers->palette = 1; + r.fire(0xB3); + CHECK(r.drivers->palette == 2); + r.fire(0xB4); + r.fire(0xB4); + CHECK(r.drivers->palette == 0); // 2 → 1 → 0 + r.fire(0xB4); + CHECK(r.drivers->palette == 0); // clamps at the low end (options 0..3) + r.drivers->palette = 3; + r.fire(0xB3); + CHECK(r.drivers->palette == 3); // clamps at the high end +} + +TEST_CASE("IrModule: learn binds a code to an action, then that code drives it") { + Rig r; + r.drivers->brightness = 100; + // Arm learning for "brightness up" (learn select index 1 = first action). + Scheduler::instance()->setControl("Ir", "learn", "{\"value\":1}"); + // A code arrives → it binds to "brightness up" and learning disarms; brightness unchanged yet. + r.ir->injectCodeForTest(0xFA057F80); + CHECK(r.drivers->brightness == 100); + CHECK(std::strstr(r.ir->status(), "learned brightness up") != nullptr); + // The SAME code now drives the bound action. + r.ir->injectCodeForTest(0xFA057F80); + CHECK(r.drivers->brightness == 116); + r.ir->injectCodeForTest(0xFA057F80); + CHECK(r.drivers->brightness == 132); +} + +TEST_CASE("IrModule: an unlearned code is reported as unassigned, drives nothing") { + Rig r; + r.drivers->brightness = 100; + r.ir->injectCodeForTest(0xDEADBEEF); // never learned + CHECK(r.drivers->brightness == 100); + CHECK(std::strstr(r.ir->status(), "unassigned") != nullptr); + CHECK(r.ir->latestCode() == 0xDEADBEEF); +} + +TEST_CASE("IrModule: two codes bind to two actions independently") { + Rig r; + r.drivers->brightness = 100; + r.drivers->palette = 1; + r.learn(1, 0x11111111); // brightness up + r.learn(3, 0x22222222); // palette next + // Each code drives only its own action. + r.fire(0x11111111); + CHECK(r.drivers->brightness == 116); + CHECK(r.drivers->palette == 1); // unchanged + r.fire(0x22222222); + CHECK(r.drivers->palette == 2); + CHECK(r.drivers->brightness == 116); // unchanged +} + +TEST_CASE("IrModule: an unassigned code is a no-op, not a crash") { + Rig r; + const uint8_t before = r.drivers->brightness; + r.fire(0xABCDEF01); // no learned binding → nothing happens + CHECK(r.drivers->brightness == before); + // The code is still recorded (for the read-out) even though it drove no action. + CHECK(r.ir->latestCode() == 0xABCDEF01); +} + +TEST_CASE("IrModule: a learned code whose target module is gone is a no-op, reported") { + Rig r; + const uint8_t before = r.drivers->brightness; + r.learn(1, 0xC0DE); // bind 0xC0DE → brightness up (targets "Drivers") + // Take the target out of reach: rename it so firstByName("Drivers") returns null. + r.drivers->setName("NotDrivers"); + r.fire(0xC0DE); // the action fires but its module is missing + CHECK(r.drivers->brightness == before); // nothing changed + CHECK(std::strstr(r.ir->status(), "no Drivers module") != nullptr); // reported, not crashed +} diff --git a/test/unit/light/unit_WledAudioSyncPacket.cpp b/test/unit/light/unit_WledAudioSyncPacket.cpp new file mode 100644 index 00000000..d1b08b04 --- /dev/null +++ b/test/unit/light/unit_WledAudioSyncPacket.cpp @@ -0,0 +1,146 @@ +// @module WledAudioSyncPacket + +// Pins the WLED audio-sync wire format — the 44-byte v2 packet projectMM broadcasts on +// UDP 11988 and that WLED / MoonLight (D_WLEDAudio.h) receive. A wire format breaks +// silently, so build → parse is round-tripped AND a golden byte vector fixes the exact +// offsets: the packet is a fixed compatibility contract (netmindz/WLED-sync), not ours to +// drift. (Same rigor as the Improv frame golden vector.) + +#include "doctest.h" +#include "light/WLEDAudioSyncPacket.h" + +#include +#include +#include + +using namespace mm; + +// Build a frame with distinct, checkable values in every field. +static AudioFrame sampleFrame() { + AudioFrame f; + f.level = 200; + f.levelSmoothed = 150; + f.peakHz = 440; + f.peakMag = 77; + for (int i = 0; i < 16; i++) f.bands[i] = static_cast(i * 16); // 0,16,...,240 + return f; +} + +TEST_CASE("build produces a 44-byte v2 packet with the exact WLED layout") { + AudioFrame f = sampleFrame(); + uint8_t pkt[WLED_SYNC_PACKET_SIZE]; + size_t n = buildWledAudioSync(pkt, f, /*frameCounter=*/7, /*peak=*/true); + + CHECK(n == 44); + CHECK(WLED_SYNC_PACKET_SIZE == 44); + CHECK(WLED_SYNC_PORT == 11988); + + // header "00002" (+ NUL) at offset 0 + CHECK(std::memcmp(pkt, "00002", 6) == 0); + // gap1 (offset 6-7) is zero + CHECK(pkt[6] == 0); CHECK(pkt[7] == 0); + // sampleRaw = level (float LE) at offset 8 + CHECK(wledGetFloatLE(pkt + 8) == doctest::Approx(200.0f)); + // sampleSmth = levelSmoothed at offset 12 + CHECK(wledGetFloatLE(pkt + 12) == doctest::Approx(150.0f)); + // samplePeak / frameCounter at 16 / 17 + CHECK(pkt[16] == 1); + CHECK(pkt[17] == 7); + // fftResult[16] = bands at offset 18 + for (int i = 0; i < 16; i++) CHECK(pkt[18 + i] == static_cast(i * 16)); + // gap2 (offset 34-35) is zero + CHECK(pkt[34] == 0); CHECK(pkt[35] == 0); + // FFT_Magnitude = peakMag at 36, FFT_MajorPeak = peakHz at 40 + CHECK(wledGetFloatLE(pkt + 36) == doctest::Approx(77.0f)); + CHECK(wledGetFloatLE(pkt + 40) == doctest::Approx(440.0f)); +} + +TEST_CASE("build -> parse round-trips every AudioFrame field") { + AudioFrame f = sampleFrame(); + uint8_t pkt[WLED_SYNC_PACKET_SIZE]; + buildWledAudioSync(pkt, f, 3, false); + + AudioFrame out; + REQUIRE(parseWledAudioSync(pkt, WLED_SYNC_PACKET_SIZE, out)); + CHECK(out.level == f.level); + CHECK(out.levelSmoothed == f.levelSmoothed); + CHECK(out.peakHz == f.peakHz); + CHECK(out.peakMag == f.peakMag); + for (int i = 0; i < 16; i++) CHECK(out.bands[i] == f.bands[i]); +} + +TEST_CASE("parse rejects wrong length, wrong header, v1, and null") { + AudioFrame f = sampleFrame(); + uint8_t pkt[WLED_SYNC_PACKET_SIZE]; + buildWledAudioSync(pkt, f, 0, false); + AudioFrame out; + + // exact-length valid packet parses + CHECK(parseWledAudioSync(pkt, 44, out)); + // one byte short / one byte long — rejected (WLED sends exactly 44) + CHECK_FALSE(parseWledAudioSync(pkt, 43, out)); + CHECK_FALSE(parseWledAudioSync(pkt, 45, out)); + // wrong header ("00001" = the legacy v1 packet) — rejected, not crashed + uint8_t v1[44]; std::memcpy(v1, pkt, 44); std::memcpy(v1, "00001", 6); + CHECK_FALSE(parseWledAudioSync(v1, 44, out)); + // an 83-byte v1-sized buffer — rejected on length + uint8_t big[83] = {}; std::memcpy(big, "00001", 6); + CHECK_FALSE(parseWledAudioSync(big, sizeof(big), out)); + // null + CHECK_FALSE(parseWledAudioSync(nullptr, 44, out)); +} + +TEST_CASE("parse clamps NaN / out-of-range floats instead of undefined casts") { + // A foreign packet passes the header check but carries hostile float payloads. + // wledFloatToU16 must bound them to [0, 65535] rather than let static_cast + // of a NaN / huge / negative float produce an undefined result. + AudioFrame f = sampleFrame(); + uint8_t pkt[WLED_SYNC_PACKET_SIZE]; + buildWledAudioSync(pkt, f, 0, false); + + const float nan = std::numeric_limits::quiet_NaN(); + wledPutFloatLE(pkt + 8, nan); // level → NaN + wledPutFloatLE(pkt + 12, -5.0f); // levelSmoothed → negative + wledPutFloatLE(pkt + 36, 1e9f); // peakMag → far over 65535 + wledPutFloatLE(pkt + 40, 440.6f); // peakHz → normal, truncates to 440 + + AudioFrame out; + REQUIRE(parseWledAudioSync(pkt, WLED_SYNC_PACKET_SIZE, out)); + CHECK(out.level == 0); // NaN → 0 + CHECK(out.levelSmoothed == 0); // negative → 0 + CHECK(out.peakMag == 65535); // clamped to the u16 ceiling + CHECK(out.peakHz == 440); // in range → truncated + + // The helper directly, at the boundaries. + CHECK(wledFloatToU16(nan) == 0); + CHECK(wledFloatToU16(-1.0f) == 0); + CHECK(wledFloatToU16(0.0f) == 0); + CHECK(wledFloatToU16(65535.0f) == 65535); + CHECK(wledFloatToU16(70000.0f) == 65535); + CHECK(wledFloatToU16(123.9f) == 123); +} + +TEST_CASE("golden vector — the exact bytes on the wire (the compatibility contract)") { + // A fixed frame → the exact 44 bytes any WLED receiver must accept. If this changes, + // interop with WLED/MoonLight breaks — the test is the alarm. + AudioFrame f; + f.level = 100; f.levelSmoothed = 50; f.peakHz = 1000; f.peakMag = 25; + for (int i = 0; i < 16; i++) f.bands[i] = static_cast(i); // 0..15 + uint8_t pkt[WLED_SYNC_PACKET_SIZE]; + buildWledAudioSync(pkt, f, /*frameCounter=*/1, /*peak=*/false); + + // Little-endian IEEE-754 for 100.0, 50.0, 25.0, 1000.0 + const uint8_t golden[44] = { + '0','0','0','0','2','\0', // 0-5 header + 0, 0, // 6-7 gap1 + 0x00,0x00,0xC8,0x42, // 8-11 sampleRaw = 100.0f + 0x00,0x00,0x48,0x42, // 12-15 sampleSmth = 50.0f + 0, // 16 samplePeak + 1, // 17 frameCounter + 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15, // 18-33 fftResult + 0, 0, // 34-35 gap2 + 0x00,0x00,0xC8,0x41, // 36-39 FFT_Magnitude = 25.0f + 0x00,0x00,0x7A,0x44, // 40-43 FFT_MajorPeak = 1000.0f + }; + CHECK(std::memcmp(pkt, golden, 44) == 0); +} diff --git a/web-installer/deviceModels.json b/web-installer/deviceModels.json index 8119bfb1..93f9807d 100644 --- a/web-installer/deviceModels.json +++ b/web-installer/deviceModels.json @@ -711,13 +711,16 @@ "esp32s3-n8r8" ], "image": "assets/boards/lightcrafter-16.jpg", - "url": "https://lightcrafter.eu/", + "url": "https://www.limpkin.fr/", "supported": [ "LEDs", - "Ethernet" + "WiFi", + "Ethernet", + "IR" ], "planned": [ - "16-ch parallel LEDs (I2S, not yet)" + "16-ch parallel LEDs (I2S, not yet)", + "Power monitoring" ], "modules": [ { @@ -741,7 +744,16 @@ "ethSpiMosi": 11, "ethSpiSck": 12, "ethSpiCs": 10, - "ethSpiIrq": 45 + "ethSpiIrq": 45, + "ethRstGpio": 3 + } + }, + { + "type": "IrModule", + "id": "Ir", + "parent_id": "System", + "controls": { + "pin": 4 } } ] @@ -753,12 +765,16 @@ "esp32s3-n8r8" ], "image": "assets/boards/esp32-s3-stephanelec-16p.jpg", + "url": "https://www.limpkin.fr/", "supported": [ "LEDs", - "Ethernet" + "WiFi", + "Ethernet", + "IR" ], "planned": [ - "16-ch parallel LEDs (I2S, not yet)" + "16-ch parallel LEDs (I2S, not yet)", + "Power monitoring" ], "modules": [ { @@ -784,6 +800,14 @@ "ethSpiCs": 15, "ethSpiIrq": 18 } + }, + { + "type": "IrModule", + "id": "Ir", + "parent_id": "System", + "controls": { + "pin": 5 + } } ] }, @@ -847,12 +871,12 @@ ] }, { - "name": "ABC-WLED ESP32-P4 shield", + "name": "MHC-WLED ESP32-P4 shield", "chip": "ESP32-P4", "firmwares": [ "esp32p4-eth" ], - "image": "assets/boards/abc-wled-esp32-p4-shield.jpg", + "image": "assets/boards/mhc-wled-esp32-p4-shield.jpg", "url": "https://shop.myhome-control.de/en/ABC-WLED-ESP32-P4-shield/HW10027", "supported": [ "LEDs", @@ -861,7 +885,7 @@ ], "planned": [ "WiFi", - "RS-485 / DMX", + "RS-485 / DMX (in/out)", "Inputs", "16-ch parallel LEDs (8+ not yet)" ], @@ -870,7 +894,19 @@ "type": "System", "id": "System", "controls": { - "deviceModel": "ABC-WLED ESP32-P4 shield" + "deviceModel": "MHC-WLED ESP32-P4 shield" + } + }, + { + "type": "AudioModule", + "id": "Audio", + "parent_id": "System", + "controls": { + "wsPin": 26, + "sdPin": 33, + "sckPin": 32, + "mclkPin": 36, + "gain": 100 } }, {