Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Binary file removed docs/assets/boards/abc-wled-esp32-p4-shield.jpg
Binary file not shown.
Binary file added docs/assets/boards/mhc-wled-esp32-p4-shield.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions docs/backlog/backlog-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file not shown.
22 changes: 22 additions & 0 deletions docs/history/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
129 changes: 129 additions & 0 deletions docs/history/plans/Plan-20260703 - WLED audio sync.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading