diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..0c05d2b3 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "command", + "command": "uv run \"$CLAUDE_PROJECT_DIR/moondeck/check/hook_prose.py\"" + } + ] + } + ] + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 46c3e563..08e02be0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,8 @@ New behavior is pinned before it ships: a unit test for module logic, a scenario Docs land with the code, not at merge time: the module's spec and catalog card describe what actually shipped ([coding-standards § Documentation model](docs/coding-standards.md#documentation-model)); a breaking change gets its entry in [docs/MIGRATING.md](docs/MIGRATING.md); a shipped backlog item or spec draft is deleted. The merge gate only verifies this happened. +**How the writing looks: American spelling, no em-dashes.** `color`, `serialize`, `behavior`, `analyze`; a comma, colon or full stop where an em-dash wants to go. In comments, docs, commit messages and chat replies alike. Both rules are enforced mechanically by `check_prose.py` (a write-time hook, and again at the commit gate), because they are exactly the kind of habit an author does not notice in their own prose. Full rationale: [coding-standards § Writing](docs/coding-standards.md). + ### Commit Git only with the PO in the loop: staging, committing, and pushing happen only when the PO explicitly triggers them. **The PO verifies EVERY changed file before it is committed.** That is the rule the others serve: nothing reaches history unseen. Two things follow, and both have been broken. **The trigger is the words "commit now", never a task instruction** — "fix it", "do step 4", "the build is broken", even "hotfix it on main" say what to change and nothing about recording it; finishing the work is not a prompt to commit it. And **a "commit now" covers only the files the PO has actually looked at** — touch one more, anything at all, and the tree again holds something unverified, so the go-ahead is void until they see it. Stop at a clean tree, say exactly which files changed, and wait. On main exactly as on a branch; a one-line fix exactly as a feature. What and when to commit or merge is 100% the product owner's call — never ask or propose commit timing. One combined commit per cycle (no partial commits; hygiene changes fold into the next one). Branches and commits may bundle multiple topics: not every small change gets its own commit — the pre-commit and pre-merge checks would be too much overhead. @@ -107,7 +109,9 @@ Agents never commit. **Delegate the mechanical roles**: parallelizable or substa **Sanity-check every request.** Hold it against README, this file, and architecture.md. If it conflicts, push back briefly with the specific reference; the product owner can still overrule. -**Anti-stalling.** If a build error or test failure survives 2 fix attempts: STOP. Ask, or roll back and re-approach. +**Never revert without asking.** Undoing work already done is the product owner's call, whatever prompted it: a doc that seems to contradict it, a reviewer finding, a failing check, or the agent's own second thoughts. Deleting a file, dropping a config, or backing out a change costs the thinking that went into it and may reverse a decision the PO made deliberately. State the case and wait; a written statement is a status, not a law, and only the PO knows which. + +**Anti-stalling.** If a build error or test failure survives 2 fix attempts: STOP. Ask, or roll back and re-approach (rolling back is itself a revert: ask). **Bench boards are free test rigs.** Build and flash freely to verify work; re-probe ports first. A *rigorous* change (anything that could brick, boot-loop, or wipe a board: flash erases, boot/partition/build-config changes, a first flash of an untested board) gets a one-sentence heads-up and a go-ahead first — the test is reversibility. diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index ff7e7832..3ea9fd2d 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -24,13 +24,29 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul ## Unreleased (`next-iteration`) +### esp32-16mb moves to the MoonBase partition table (2026-08-28) + +**Action: erase flash** (USB re-flash). Back up first (File Manager, or the installer's +bookmarklet on older firmware); restore after the install brings WiFi, config and scripts back. + +`esp32-16mb` replaces its dual-OTA layout with +[MoonBase](architecture.md#moonbase-the-second-boot-image), the same trade the 4 MB variants +made in the entry below, taken here by choice rather than necessity: the second app slot was +idle except during an update, so the filesystem grows 7168 to 11264 KB and the device gains +MoonBase's stronger recovery story (a power cut mid-install boots MoonBase and the user retries +over the network). One app slot remains, at its full 4096 KB. + +Every partition moves, so the existing filesystem volume is not where the new table looks: +without a backup, WiFi credentials, module config and scripts all re-enter through provisioning. +A partition table only changes over USB, so an OTA update leaves a device on the old layout. + ### 4 MB boards move to the MoonBase partition table (2026-08-26) **Action: erase flash** (USB re-flash). Back up first (File Manager ⤓, or the installer's bookmarklet on older firmware); restore after the install brings WiFi, config and scripts back. The 4 MB variants (`esp32`, `esp32-wrover`, `esp32-eth`) replace the dual-OTA layout with -[MoonBase](architecture.md#moonbase-the-second-boot-image-4-mb-boards): the app slot grows +[MoonBase](architecture.md#moonbase-the-second-boot-image): the app slot grows 1856 → 2496 KB and the filesystem 256 → 548 KB, but the filesystem moves (0x3B0000 → 0x360000), so the existing volume is not where the new table looks; without a backup, WiFi credentials, module config and scripts all re-enter through provisioning. A partition table only changes over USB: a device diff --git a/docs/architecture.md b/docs/architecture.md index df05272f..b6240ea5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -263,14 +263,20 @@ Three distinct things, kept distinct in the vocabulary: A deviceModel can run multiple firmwares (the Olimex Gateway runs both `esp32-eth` and the default `esp32`); a firmware can run on multiple deviceModels (`esp32` runs on any classic ESP32 dev kit). The `esp32s3-n16r8` firmware is S3-only and does not run on the Olimex Gateway or other classic-ESP32 hardware. The codebase reserves "deviceModel" exclusively for the physical product and "firmware" exclusively for the compiled binary. -### MoonBase: the second boot image (4 MB boards) +### MoonBase: the second boot image -A 4 MB board has room for one application, not two, so the dual-OTA layout (half the chip spent -on a second copy of the firmware) is replaced on those boards by **MoonBase**: a small, -rarely-changing image in the partition table's `factory` slot that owns the device while the -application is being replaced, a board cannot rewrite the partition it is executing from. The -app slot grows by a third in exchange. 8/16 MB boards keep dual-OTA and are untouched by any of -this. +Dual-OTA spends half the app area on a second copy of the firmware that is idle except during an +update. **MoonBase** replaces it: a small, rarely-changing image in the partition table's +`factory` slot that owns the device while the application is being replaced, since a board +cannot rewrite the partition it is executing from. One app slot then suffices, and the flash the +second slot held goes elsewhere. + +A 4 MB board has no choice, having room for one application and not two; its app slot grows by a +third in exchange. On a **16 MB** board the choice is deliberate rather than forced, and the +freed 4 MB goes to the filesystem (11 MB rather than 7). Which boards use MoonBase is a +per-variant decision recorded in `moondeck/build/build_esp32.py`, not a property of flash size: +today the 4 MB classic, the S3-Zero and `esp32-16mb` do, and it may become the default +everywhere. The update cycle: the app stages the install URL in NVS (or nothing, for a browser upload), points the bootloader at MoonBase and reboots; MoonBase joins the network with the app's stored @@ -285,7 +291,7 @@ MoonBase, visibly, rather than silently reverting to the old app; the way back i MoonBase is a standalone ESP-IDF project (`moonbase/`, ~750 KB against an 896 KB slot) sharing no sources with the app, the deliberate trade for an image that must stay small and, once -working, hardly change. `moondeck/build/build_esp32.py` builds it alongside the 4 MB variants +working, hardly change. `moondeck/build/build_esp32.py` builds it alongside every variant that opts in and owns the flash-layout helpers every consumer uses (serial flash, mooninstaller manifests, release preview, the QEMU image): IDF's own `flasher_args.json` knows nothing of the two-image scheme and stages the app at the factory offset, so each of those paths applies the same diff --git a/docs/assets/deviceModels/esp32-s3-zero-pinout.png b/docs/assets/deviceModels/esp32-s3-zero-pinout.png new file mode 100644 index 00000000..e5e7b29f Binary files /dev/null and b/docs/assets/deviceModels/esp32-s3-zero-pinout.png differ diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index a947a0ec..63317bf3 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -126,7 +126,7 @@ Full design + the reasoned transport split: [Plan-20260629 — UDP device discov ## MoonBase follow-ups -MoonBase v1 ([architecture.md § MoonBase](../architecture.md#moonbase-the-second-boot-image-4-mb-boards)) +MoonBase v1 ([architecture.md § MoonBase](../architecture.md#moonbase-the-second-boot-image)) ships exactly one action: install firmware (upload + URL). The name is deliberately broader than "recovery", these are the candidate next actions, each solving something only a separate boot image can solve. The budget rule from the partition table applies to all of them: the 896 KB slot @@ -919,6 +919,53 @@ The P4 build runs at **360 MHz** because IDF's `Kconfig.cpu` caps a `SELECTS_REV Neither ships until a rev-3 P4 can prove 400 runs clean — no untested clock config, per the same rule the S31/320 and this P4/400 bootloop both taught. +## ESP32-P4: the esp-dsp assembly FFT faults once a second task runs (workaround shipped) + +**Found:** bench, 2026-08-28, bringing up HLS on the P4. + +**Symptom:** a crash loop the moment the HLS encode task exists alongside the render loop — +`Guru Meditation Error: ... (Illegal instruction)` or `(Load access fault)`, the type varying +between boots, always faulting on the twiddle-table load in `dsps_fft2r_fc32_arp4.S:52` +(`flw fa0, 0(t3)`) reached from `platform::audioFft`. The LED output freezes after a few frames +and the board reboots. Present only with audio analysis running AND a second task; either alone +is stable, which is why it never surfaced before HLS. + +**Cause:** the P4's hardware-loop unit has a documented silicon erratum, declared by Espressif's +own `soc_caps.h`: `SOC_CPU_HAS_HWLOOP_STATE_BUG` — "HWLOOP state doesn't go to DIRTY after +executing the last instruction of a loop". FreeRTOS saves the HWLP registers lazily and keys that +save on the DIRTY flag, so a context switch out of the FFT concludes there is nothing to save and +the loop state is lost; the resumed kernel then reads its table through a stale register. The +esp-dsp `_arp4` kernel uses `esp.lp.setup` (the hardware-loop instruction), which is why the FFT +is where it lands. IDF v6.1-rc1 carries workarounds for this erratum at two sites in +`portasm.S` (lines 217 and 795, gated `ESP32P4_REV_MIN_FULL <= 1`, which our `REV_MIN_FULL=0` +build satisfies) — **both on the RESTORE side**. The **save** site (~line 676) reads +`CSR_HWLP_STATE_REG`, skips saving unless it equals `HWLP_DIRTY_STATE`, and carries no erratum +guard at all. That is precisely what the erratum breaks: a task switched out after a loop's last +instruction reports non-DIRTY, so its hardware-loop registers are never saved, and the resumed +FFT reads its twiddle table through a stale register. Restore is patched; save is not. + +**Workaround shipped:** `CONFIG_DSP_ANSI=y` in `sdkconfig.defaults.esp32p4rev1-eth`, which swaps +esp-dsp's hand-written assembly kernels for portable C. Measured cost on the bench P4: the Audio +module's tick goes from ~615 us to ~658 us, about 40 us (7%) against 3.3 ms of tick headroom. +Stability confirmed over a soak with HLS streaming: zero crashes, zero corrupt packets. + +**Reported upstream:** [esp-idf#19025](https://github.com/espressif/esp-idf/issues/19025) +(2026-08-28), which names the unguarded save path. Espressif already had the symptom on file from +another reporter: [esp-dsp#119](https://github.com/espressif/esp-dsp/issues/119) hits the same +fault at the same instruction on IDF v5.5-beta1 and settles on the same `CONFIG_DSP_ANSI=y` +workaround at the same ~7% cost, and [esp-dsp#102](https://github.com/espressif/esp-dsp/issues/102) +tracks P4 hardware loops in general. So the bug is real, reproducible by others, and spans at +least v5.5-beta1 to v6.1-rc1 - but it is unfixed, and the workaround stays until it is answered. + +**What is NOT proven:** the save-path gap is read from the source and matches every symptom, but +it has not been confirmed by instrumenting the switch itself (logging `CSR_HWLP_STATE_REG` on a +switch out of the FFT), nor reduced to a minimal project. Offered upstream if triage wants it. Ruled out on the bench, so nobody re-treads them: worker stack size (8K -> 16K), +core placement (worker on core 1 vs core 0), heap corruption (comprehensive heap poisoning reports +nothing), an unpinned task migrating with coprocessor state (our main task is pinned to core 0), +an FFT size mismatch (512 <= 1024 <= 4096), and a cross-task race on the single `audioFft` call +site. **To report upstream** (esp-dsp and/or esp-idf) with the repro above; the workaround stands +until it is answered, and reverting it needs a bench soak with audio + HLS together. + ## Flaky unit tests: the AudioService sync suite contends on a fixed UDP port **Found:** 2026-07-27, caught by `premerge.py`; pinned to the exact cases by looping the suite and keeping the failing logs. Fails roughly **1 run in 10**. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 36811021..cc379332 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -136,6 +136,25 @@ encode-worker-stalled latch. A page refresh reportedly did NOT revive it; toggli wake-up re-request, or per-driver lease state that only prepare() resets. Needs a reproduction with the WS uplink logged before it can be fixed. +### HLS upscaling is cache-hostile on large walls (measured, 2026-08-28) + +`HlsDriver`'s `scale` control replicates each light into a scale x scale block. Measured on the +bench P4 at 128x128: **~1 ms per frame at 1:1, ~60 ms at scale 4** (a 512x512 output). The frame +is 16x larger, so ~16 ms would be the honest cost; the extra 4x is the access pattern. The loop +walks LIGHTS and writes each block as `scale` separate short rows scattered across a 786 KB +buffer, so consecutive lights touch distant addresses and every write misses cache, where the +1:1 path writes straight through sequentially. + +**Not urgent, because the default path never hits it:** auto-scale only engages on walls below +the encoder's 80-pixel floor, where the output is small by construction (a 20x10 wall becomes +160x80, 38 KB). The expensive case is a manual scale on an already-large wall, which is also +where upscaling has the least to offer. + +**The fix when it earns its place:** iterate the OUTPUT rows rather than the input lights, so +writes are sequential: for each output row, walk its source row once and emit `scale` copies of +each light's colour, then `memcpy` that finished row to the remaining `scale - 1` rows of the +block. Same output, one pass through the destination in address order. + ### Sprite follow-ups (draw::sprite + FlyingToasters shipped; [spec + plan](../history/plans/Plan-20260827%20-%20Sprites%20and%20flying%20toasters.md)) Deliberately deferred when sprites landed: P4 PPA acceleration behind the same `draw::sprite` @@ -155,6 +174,24 @@ That is the arrangement `platform_desktop.cpp` uses for Npcap today: resolve the Also note projectMM renders into a CPU buffer, so a Spout/Syphon path would upload to the GPU purely to hand off, spending the zero-copy advantage it was chosen for. +### M5Stack Tab5 as a display target — MIPI-DSI, not the H.264 path (open) + +The Tab5 is an ESP32-P4 with a 1280x720 MIPI-DSI panel, and a P4 is already a supported target, so +the question is what its *screen* would show. The P4's H.264 block does not answer it: that encoder +exists to compress an incoming MIPI-CSI camera feed, and the P4 has no hardware H.264 **decoder** +at all (Espressif's own FAQ points at software decode, which will not hold 720p). Driving the panel +is the **MIPI-DSI** peripheral plus the PPA / 2D-DMA blitter, which take raw pixels and never touch +a codec. So the three things a Tab5 could be are separate pieces of work, and only the first is free: + +- **An HLS source**, like any other P4: it encodes its own rendered grid and streams to a TV. Its + panel is incidental, and this needs nothing beyond the P4 HLS work itself. +- **A local wall preview or touch console** — the interesting one, and the real ask: a `platform::` + MIPI-DSI display seam plus a UI on the panel. Related to the PPA acceleration noted under sprite + follow-ups above (same 2D-DMA block), and it is a display *output* seam projectMM does not have + today; the nearest prior art is the WLED-MM-P4 world's LovyanGFX usage, which we would not vendor. +- **An HLS/video player**, showing another device's stream: blocked on the missing hardware decoder, + so not worth planning. + ### Multi-card walls — does a daisy chain work today? (open, ask before building) The ColorLight format has **no card addressing**: the destination MAC is a fixed constant and every card filters on it, so every card on a segment shows the same image. A user with six cards on a switch observed exactly that. diff --git a/docs/backlog/hls-driver-spec.md b/docs/backlog/hls-driver-spec.md deleted file mode 100644 index ac25580b..00000000 --- a/docs/backlog/hls-driver-spec.md +++ /dev/null @@ -1,102 +0,0 @@ -# HlsDriver spec (draft, ships with the implementation) - -Watch a projectMM installation pixel-exact on a TV, a media player, or a browser: the desktop -build streams its rendered output as H.264 over HLS from its own HTTP server. One general -implementation for every host with ffmpeg (macOS, Windows, Linux, Raspberry Pi); ESP32 is out -of scope (no hardware encoder). Complements NdiDriver: NDI is the pro-tools path, HLS is the -consumer-playback path. - -## Decision: pipe to ffmpeg - -The driver spawns `ffmpeg` (found on PATH; a runtime dependency like Npcap and the NDI -runtime, never vendored) and writes raw RGB frames to its stdin; ffmpeg encodes with the -user-picked encoder (an `encoder` Select: `libx264` default, hardware entries like -`h264_videotoolbox` offload it) and writes HLS segments plus the `.m3u8` playlist into -`/.hls/` under the fs mount, which HttpServerModule serves. One code path for -all hosts, no codec in the tree, no license baggage. Rejected alternatives: per-OS encoder -integrations (three implementations, fails the generality bar), MJPEG (no interframe -compression: fails 4K throughput), vendored x264/openh264 (GPL / binary-patent baggage). - -## Pixel-exact contract - -The encoded frame IS the grid: width x height from the layer, no scaling anywhere in the -pipeline. A 512x512 grid arrives at the TV as a 512x512 video the display letterboxes; every -written pixel is one video pixel. Frame pacing follows the render loop capped by `targetFps`; -H.264 carries the actual rate. - -## Deviations settled at implementation - -- **mDNS advert deferred**: desktop has no mDNS implementation at all today and Apple TV does - not browse DNS-SD; the read-only `url` control is the discovery. Its own item if wanted. -- **libx264 first**: at practical grid sizes the software encode is trivial CPU; hardware - encoder selection (VideoToolbox / Media Foundation / VAAPI) becomes worthwhile together with - the render-scaling item. - -## Latency - -Encode adds milliseconds; HLS segmentation and player buffering add the seconds. Tuned for -live (1 s segments, short playlist, no B-frames) the expectation is 2-5 s glass-to-glass; -document it on the card so nobody expects preview-grade feedback. Option, not scope: the same -encode can also serve an MPEG-TS endpoint (~1-2 s in VLC) if testing ever needs it. - -## Module - -Light-domain driver `HlsDriver` under Drivers, desktop builds only (`platform::hasFfmpegPipe` -style gate mirrors NdiDriver's). Controls: - -- `targetFps` (uint8, default 30): encode pacing; frames beyond it are dropped before the pipe. -- `bitrate` (uint16 kbit, default 8000): passed to ffmpeg as `-b:v`. -- `encoder` (Select, default `libx264`): the ffmpeg video encoder; hardware entries offload - the encode. Availability depends on the ffmpeg BUILD (libx264 needs --enable-libx264, which - practically every distribution ships): an encoder this ffmpeg lacks starts and exits - immediately, surfacing through the restart path as `encoder exited - check ffmpeg`, since - encoderStart() can only verify that ffmpeg itself launches. -- read-only `status`: `streaming WxH at F fps` (with a dropped-frame count when any), - `ffmpeg not found - see the docs`, `encoder restarted`, `encoder exited - check ffmpeg`. -- read-only `url`: the playable address (`http://:/hls/stream.m3u8`, the port the - server actually serves), shown so the user can copy it into VLC/TV. - -## Robustness and the hot path - -- The render tick packs the frame (per-pixel correction, O(width x height)) and ENQUEUES it - whole; a dedicated platform writer thread does the blocking pipe writes on every OS, so the - tick never touches the pipe. A queue past 3 frames drops-newest with a visible counter, and - whole-frame handoff makes a torn frame structurally impossible. After a spawn the driver - waits a short warm-up before the first frame (encoder init reads nothing). -- ffmpeg missing: status says so, nothing crashes, the driver idles until re-enabled. -- ffmpeg exits (crash, kill): status shows the exit, restart with backoff; segments dir is - recreated per session and cleaned on release(). -- Live reconfiguration: grid size or fps change tears down and respawns ffmpeg (a new encode - geometry needs a new stream); viewers re-buffer, which is inherent to the format. - -## GridLayout change (rides along, PO-requested) - -`width`/`height` become plain NUMBER inputs (`setNumberField`) with max 3840 x 2160 (4K); -`depth` keeps its slider and 512 bound. `lengthType` (int16_t) holds 3840. The card documents -the framerate expectation honestly: the render loop is single-threaded, so large grids trade -fps (roughly: 512^2 smooth, 1024^2 15-30 fps, 1080p 7-15 fps on a desktop-class host); render -parallelization is a separate backlog item. - -## Files - -- src/light/drivers/HlsDriver.h — the driver (spawn, pipe, pacing, status). -- src/platform/…: process-spawn + non-blocking pipe seam (desktop implementation; ESP32 stubs - compile out), ffmpeg discovery. -- src/core/HttpServerModule.cpp — serve the segments directory (`/hls/…`, no-cache playlist). -- src/light/layouts/GridLayout.h — number fields + 4K bounds. -- docs/moonmodules/light/drivers.md — the HlsDriver card (latency + install-ffmpeg note); - building.md gains the ffmpeg runtime-dependency line. - -## Tests - -- Unit: ffmpeg argument builder (geometry/fps/bitrate); lifecycle with ffmpeg absent (status, - no crash); non-blocking drop counter when the sink stalls; restart-with-backoff. -- Host integration: pipe frames into a fake ffmpeg (a script that consumes stdin and writes a - playlist) and assert the served playlist + segment routes. -- Scenario: driver add/enable/disable/remove live, no reboot. - -## Verification - -Desktop build zero warnings; live: stream a 512x512 grid to VLC and a TV, confirm pixel-exact -(test pattern with single-pixel features), measure glass-to-glass latency, kill ffmpeg -mid-stream and watch the status + recovery. The PO's eyes on the TV are the measurement. diff --git a/docs/building.md b/docs/building.md index d29bc8f2..a7d06142 100644 --- a/docs/building.md +++ b/docs/building.md @@ -136,8 +136,9 @@ uv run moondeck/build/flash_esp32.py --firmware esp32 --port /dev/tty.usbserial- uv run moondeck/run/monitor_esp32.py --port /dev/tty.usbserial-XXXX ``` -On the 4 MB variants (`esp32`, `esp32-wrover`, `esp32-eth`, `qemu`) the build also produces -**MoonBase**, the second boot image ([architecture.md § MoonBase](architecture.md#moonbase-the-second-boot-image-4-mb-boards)), +On the variants that opt into it (`esp32`, `esp32-16mb`, `esp32-wrover`, `esp32-eth`, +`esp32s3-zero`, and `qemu`, which is emulated rather than installable) the build also produces +**MoonBase**, the second boot image ([architecture.md § MoonBase](architecture.md#moonbase-the-second-boot-image)), and `flash_esp32.py` writes the corrected layout in one pass: app in the big `ota_0` slot, MoonBase in `factory`, and an otadata that boots the app directly. A device on the older dual-OTA table adopts this layout only through such a full serial flash, OTA never rewrites diff --git a/docs/history/plans/Plan-20260827 - HLS on ESP32-P4.md b/docs/history/plans/Plan-20260827 - HLS on ESP32-P4.md new file mode 100644 index 00000000..4ac3e388 --- /dev/null +++ b/docs/history/plans/Plan-20260827 - HLS on ESP32-P4.md @@ -0,0 +1,101 @@ +# Plan: HLS on ESP32-P4 — hardware H.264 behind the same HlsDriver + +## Context + +The P4 has a hardware H.264 ENCODER (no decoder, which is irrelevant: HLS only encodes), so a +P4 can stream its wall to a TV with no desktop in the loop. The design goal, confirmed with +the PO: the EXISTING HlsDriver stays the one module; only the platform side gains a P4 +implementation of the encoder seam. Espressif's `esp_h264` managed component drives the +hardware encoder; the HLS packaging (MPEG-TS muxer) and segment store are ours. + +## Two structural changes the exploration proved necessary + +1. **The seam carries ffmpeg CLI strings today** (buildArgs flattens geometry/fps/bitrate into + argv; a P4 impl would have to string-parse `-s`/`-r`/`-b:v`). Refactor to structured + params: `struct EncoderConfig { uint16_t w, h; uint8_t fps; uint16_t bitrateKbit; + const char* encoderName; const char* outDir; }` and + `bool encoderStart(const EncoderConfig&)`. The desktop impl builds its ffmpeg argv FROM + the struct (argv assembly moves from the driver into platform_desktop, where ffmpeg + knowledge belongs anyway); the P4 impl consumes the numbers directly. The argv pin test + moves to the desktop side of the seam (`encoderTestArgs()` unchanged); the driver's + buildArgs dies. The `encoder` Select stays: on P4 the platform ignores the name (one + hardware encoder) and the control hides via a new capability + `platform::hasEncoderChoice` (desktop true, P4 false). +2. **`/hls/` serving is fs-only** (serveHlsFile → streamFsFile → fsSize/fsReadAt). P4 + segments live in PSRAM, not LittleFS (flash wear at one segment/second). Chosen hook: a + RAM branch INSIDE serveHlsFile before the fs fallthrough (the serveFile disk-then-embedded + precedent, HttpServerModule.cpp:873): new seam + `bool hlsSegment(const char* name, const uint8_t** data, size_t* len)` — desktop returns + false (fs path unchanged), P4 serves from the segment ring. Serve with the chunked + error-checked loop (streamFsFile's shape), not serveFile's single write. + +## The P4 platform implementation (new src/platform/esp32/platform_esp32_h264.cpp) + +Added to esp32/main/CMakeLists.txt SRCS (the one-file-per-seam convention). Everything inside +`#if CONFIG_MM_HLS` (a new Kconfig symbol, see gating). + +- **Pipeline**: encoderWrite copies the RGB frame into a PSRAM slot ring (the desktop's + 3-slot reuse-ring shape); an `mmH264` pinned task (spawnPinnedTask, 8 KB, priority 5, + core 1 — the mmEncode/urlOta conventions, WDT-subscribed per platform_esp32_worker.cpp's + contract) converts RGB→YUV420 (CPU; sub-ms at 256²) and feeds `esp_h264` hardware encode. +- **Muxing**: our MPEG-TS muxer (~300-500 lines, its own header `platform_esp32_h264_ts.h` + or folded in): PAT/PMT + H.264-in-PES from the encoder's Annex-B NALs, 188-byte packets, + cut on keyframes (GOP = fps = 1 s segments, the driver's existing contract). +- **Segment ring**: N=8 segments in PSRAM (`platform::alloc`, PSRAM-first; P4-NANO has + 32 MB; ~1 s at 2-4 Mbit ≈ 250-500 KB → ring ≈ 2-4 MB) + a generated m3u8 string, exposed + via the `hlsSegment` seam. `/.hls` on-disk never exists on P4; HlsDriver's + fsMkdir/clearSegments become no-ops behind `platform::fsMkdir` returning true (verify) or + get a `hasFsSegments` guard — pick during implementation, smallest wins. +- **Lifecycle**: encoderStart allocates ring + esp_h264 session; encoderRunning = session + alive; encoderStop joins the task (stopPinnedTask, bounded) and frees; warm-up handled by + the driver as today (harmless). + +## Gating + +- `hasHls` in esp32/platform_config.h becomes SOC-derived per the file's own rule + (`CONFIG_MM_HLS`-mirrored `#define` like the MM_HEAVY_COMPUTE precedent). +- `esp_h264` dependency in esp32/main/idf_component.yml gated + `$CONFIG{MM_HLS} == True` (the documented Kconfig-gate idiom — NOT a bare target gate, + which would land it in all four P4 images; the ip101 comment records that ungated deps + break other chips' solves). `MM_HLS` declared in esp32/main/Kconfig.projbuild, default y + only on P4 targets; rev1 and rev3 both get it (the two-generation duplication rule). +- IDF pin v6.1-rc1: verify esp_h264's compatibility first (step 1 below); if it needs older + IDF, the whole plan gates on that finding. + +## Steps + +1. **Spike (half day, gates everything)**: add esp_h264 to a P4 build, encode ONE synthetic + frame on the bench P4 (.139), verify NALs come out under IDF v6.1-rc1. No driver wiring. +2. Seam refactor to EncoderConfig (desktop argv moves into platform_desktop; driver's + buildArgs deleted; argv test relocated; hasEncoderChoice hides the Select on P4). +3. TS muxer + unit tests (host-buildable pure code: feed canned NALs, assert packet + structure, PAT/PMT, continuity counters — testable on desktop, no P4 needed). +4. P4 pipeline file (ring, task, RGB→YUV, esp_h264 wiring) + hlsSegment seam + the RAM + branch in serveHlsFile. +5. hasHls gating + Kconfig + component manifest (rev1 + rev3 fragments). +6. Docs: drivers.md HLS card gains the P4 paragraph; the spec's ESP32-out-of-scope line + updated; backlog entry closed. + +## Tests + +- Host: TS muxer unit tests (the substance); EncoderConfig seam pin replaces the argv pin + at driver level; existing HlsDriver tests unchanged (Record seam untouched). +- Bench (the real gate): P4 .139 streams to VLC; ffprobe confirms h264 at grid size; + soak + kill/restart; the PO's TV. + +## Verification + +Desktop build + ctest green (seam refactor must not disturb the desktop path: re-verify the +live macOS stream after step 2). P4: esp32p4rev1-eth-wifi on the bench board at .139, url +control shows the P4's address, VLC plays, uptime stable through a 10-minute soak. + +## Risks + +- esp_h264 vs IDF v6.1-rc1 compatibility is unproven: the step-1 spike settles it before + anything else is built. +- P4 WiFi throughput (hosted C6 link) may cap bitrate: Ethernet is the primary path, WiFi + best-effort. +- Encoder memory appetite (esp_h264 internal buffers) on top of the app: measured in the + spike; the ring is PSRAM so main heap stays untouched. +- The rev3 images stay untested hardware-wise (no rev3 board on the bench) — same caveat + they already carry. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 3b29c82a..1c85b294 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,78 +1,79 @@ { - "commit": "fc680586", + "commit": "93055e63", "flash": { - "esp32s3-n16r8": 1851120, - "desktop": 1578968, - "esp32": 1799040, + "esp32s3-n16r8": 1852000, + "desktop": 1579288, + "esp32": 1809456, "esp32p4rev1-eth": 1675216, - "esp32p4rev1-eth-wifi": 1933472, + "esp32p4rev1-eth-wifi": 2019392, "esp32s3-n8r8": 1833248, "esp32s31": 2105072, - "esp32-16mb": 1714608, + "esp32-16mb": 1809472, "esp32-eth": 1397456, "esp32-wrover": 1843760, "qemu": 1383648, - "esp32p4rev3-eth": 1643760 + "esp32p4rev3-eth": 1643760, + "esp32s3-zero": 1788624 }, "perf": { "desktop": { - "tick_us": 142, - "fps": 7042 + "tick_us": 180, + "fps": 5555 }, "esp32": { - "tick_us": 8389, + "tick_us": 8347, "fps": 119 } }, "loc": { - "core": 20712, - "light": 26689, - "platform": 16257, - "ui": 8171, - "test": 48239, - "moondeck": 22130 + "core": 20761, + "light": 26877, + "platform": 17131, + "ui": 8207, + "test": 48912, + "moondeck": 22456 }, "comments": { "core": { - "lines": 8175, + "lines": 8192, "ratio": 0.427 }, "light": { - "lines": 10445, - "ratio": 0.432 + "lines": 10540, + "ratio": 0.433 }, "platform": { - "lines": 5656, - "ratio": 0.382 + "lines": 5899, + "ratio": 0.378 }, "ui": { - "lines": 2163, - "ratio": 0.28 + "lines": 2175, + "ratio": 0.281 }, "test": { - "lines": 8874, + "lines": 8985, "ratio": 0.211 }, "moondeck": { - "lines": 3577, + "lines": 3623, "ratio": 0.185 } }, "tests": { - "cases": 1614, + "cases": 1640, "scenarios": 23 }, "docs": { "md_files": 203, - "md_lines": 30045, - "plans_files": 108, - "backlog_lines": 4672, + "md_lines": 30167, + "plans_files": 109, + "backlog_lines": 4654, "lessons_lines": 622, - "claude_md_lines": 136 + "claude_md_lines": 140 }, "complexity": { - "functions": 2833, - "over_threshold": 180, + "functions": 2868, + "over_threshold": 188, "worst_ccn": 108 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index ad5ab0ee..a823c069 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,67 +1,70 @@ # Repo health -Measured at `fc680586`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `93055e63`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. ## Firmware size -| Target | Flash | -|---|---:| -| desktop | 1,542 KB (+20 KB) ⚠ | -| esp32 | 1,757 KB | -| esp32-16mb | 1,674 KB | -| esp32-eth | 1,365 KB | -| esp32-wrover | 1,801 KB | -| esp32p4rev1-eth | 1,636 KB | -| esp32p4rev1-eth-wifi | 1,888 KB | -| esp32p4rev3-eth | 1,605 KB | -| esp32s3-n16r8 | 1,808 KB (+4 KB) ⚠ | -| esp32s3-n8r8 | 1,790 KB | -| esp32s31 | 2,056 KB | -| qemu | 1,351 KB | +| Target | Flash | Capacity | Used | Built | +|---|---:|---:|---:|:--:| +| desktop | 1,542 KB | - | - | yes | +| esp32 | 1,767 KB (+10 KB) ⚠ | 2,496 KB | 71% | yes | +| esp32-16mb | 1,767 KB (+93 KB) ⚠ | 4,096 KB | 43% | yes | +| esp32-eth | 1,365 KB | 2,496 KB | 55% | carried | +| esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried | +| esp32p4rev1-eth | 1,636 KB | 4,096 KB | 40% | carried | +| esp32p4rev1-eth-wifi | 1,972 KB (+0 KB) ⚠ | 4,096 KB | 48% | yes | +| esp32p4rev3-eth | 1,605 KB | 4,096 KB | 39% | carried | +| esp32s3-n16r8 | 1,809 KB (+0 KB) ⚠ | 4,096 KB | 44% | yes | +| esp32s3-n8r8 | 1,790 KB | 3,072 KB | 58% | carried | +| esp32s3-zero | 1,747 KB | 2,496 KB | 70% | yes | +| esp32s31 | 2,056 KB | 4,096 KB | 50% | carried | +| qemu | 1,351 KB | - | - | carried | + +`Built: carried` means that firmware was NOT rebuilt this run and its number is the previous one, so an absent delta says nothing about the change. `Used` is against the app slot in the firmware's own partition table. ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 142 µs (−121 µs) ✓ | 7,042 (+3,240) ✓ | -| esp32 | 8,389 µs (+17 µs) ⚠ | 119 | +| desktop | 180 µs (+48 µs) ⚠ | 5,555 (−2,020) ⚠ | +| esp32 | 8,347 µs (−42 µs) ✓ | 119 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 20,712 (+20) ⚠ | 8,175 | 42.7 % | -| light | 26,689 (+299) ⚠ | 10,445 | 43.2 % (−0.2 %) ✓ | -| platform | 16,257 (+142) ⚠ | 5,656 | 38.2 % (−0.1 %) ✓ | -| ui | 8,171 (+12) ⚠ | 2,163 | 28.0 % | -| test | 48,239 (+168) ⚠ | 8,874 | 21.1 % | -| moondeck | 22,130 | 3,577 | 18.5 % | +| core | 20,761 (+20) ⚠ | 8,192 | 42.7 % | +| light | 26,877 (+24) ⚠ | 10,540 | 43.3 % (+0.1 %) ⚠ | +| platform | 17,131 (+111) ⚠ | 5,899 | 37.8 % | +| ui | 8,207 | 2,175 | 28.1 % | +| test | 48,912 (+102) ⚠ | 8,985 | 21.1 % | +| moondeck | 22,456 (+177) ⚠ | 3,623 | 18.5 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,614 (+6) ✓ | +| unit cases | 1,640 (+3) ✓ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,833 (+15) ✓ | -| over threshold | 180 (+2) ⚠ | +| functions | 2,868 (+5) ✓ | +| over threshold | 188 (+2) ⚠ | | worst CCN | 108 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 203 (+2) ⚠ | -| markdown lines | 30,045 (+274) ⚠ | -| plan files | 108 (+2) ⚠ | -| backlog lines | 4,672 (+10) ⚠ | +| markdown files | 203 | +| markdown lines | 30,167 (+23) ⚠ | +| plan files | 109 | +| backlog lines | 4,654 | | lessons lines | 622 | -| CLAUDE.md lines | 136 | +| CLAUDE.md lines | 140 (+2) ⚠ | diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md index 6d0fb953..b39fc61e 100644 --- a/docs/moonmodules/core/system.md +++ b/docs/moonmodules/core/system.md @@ -98,7 +98,7 @@ Over-the-air firmware flashing — the one operation that swaps the binary and n devices, where the update overlay carries the progress instead), and on 4 MB boards `moonbase`: the second boot image is present, so installs run through the reboot-into-MoonBase cycle behind one "updating firmware" overlay, and a **MoonBase** button - opens the maintenance image directly ([architecture.md § MoonBase](../../architecture.md#moonbase-the-second-boot-image-4-mb-boards)). + opens the maintenance image directly ([architecture.md § MoonBase](../../architecture.md#moonbase-the-second-boot-image)). Detail: [technical](moxygen/FirmwareUpdateModule.md) diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 7d304e15..2d0c885c 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -175,21 +175,35 @@ Detail: [technical](moxygen/NdiDriver.md) ### HLS 🖥️ · video out -Streams the layer as **H.264 over HLS** from the device's own HTTP server: open the `url` the card shows in VLC, a browser, or hand it to an Apple TV (VLC for tvOS, or open it in Safari and AirPlay the video, the Apple TV then pulls the stream itself). Where NDI feeds production tools, this feeds anything that plays video. +Streams the layer as **H.264 over HLS** from the device's own HTTP server: open the `url` the card shows in VLC, a browser, or hand it to an Apple TV (VLC for tvOS, or open it in Safari and AirPlay the video). Where NDI feeds production tools, this feeds anything that plays video. -**Pixel-exact**: the frame IS the grid (`physicalWidth` x `physicalHeight`, one light per pixel, output correction applied), no scaling anywhere; the display letterboxes. Latency is HLS's own: expect **2-5 seconds** glass-to-glass, so this is for watching, not for live-control feedback. Large grids trade framerate, the render loop is single-threaded: 512x512 streams smoothly, TV-native resolutions do not yet. +The grid becomes the frame, output correction applied, so a viewer sees what the wall sees. Latency is HLS's own: expect **2-5 seconds** glass-to-glass, so this is for watching, not for live-control feedback. -**Desktop only**, and **you install ffmpeg yourself** (any 5.x+, on PATH), projectMM never ships or links an encoder: `brew install ffmpeg` (macOS), `winget install ffmpeg` (Windows), `sudo apt install ffmpeg` (Debian/Ubuntu/Raspberry Pi OS). Without it the driver reports `ffmpeg not found` and nothing else changes. Segments live in the transient `/.hls/` directory, served at `/hls/`, excluded from config backups. +Runs on **desktop** (where **you install ffmpeg yourself**) and on the **ESP32-P4** (which encodes in hardware, no ffmpeg). See [§ HLS, details](#hls-details). -- `targetFps` — encode-rate ceiling (default 30, 1–120); the render loop runs faster and extra frames are not encoded. -- `bitrate` — H.264 target in kbit/s (default 8000). -- `encoder` — the ffmpeg video encoder (Select; default `libx264`, which practically every ffmpeg distribution ships — a build without `--enable-libx264` is the exception). The hardware entries offload the encode entirely and are worth picking on large grids: `h264_videotoolbox` on a Mac (~10% CPU for a 1024x1024 stream on Apple silicon), `h264_v4l2m2m` on a Raspberry Pi, `h264_nvenc` on NVIDIA. An encoder your ffmpeg lacks starts and exits immediately; the status shows `encoder exited - check ffmpeg`. +- `targetFps` — encode-rate ceiling (default 30, 1–120); the render loop runs faster and extra frames are not encoded. This is also the bandwidth knob: the bitrate is derived, not a setting. +- `scale` — video pixels per light (default 0 = auto, which enlarges a small wall just enough to be watchable). Each light becomes a solid square block, never an interpolated blur. +- `encoder` — which ffmpeg encoder to use (desktop only; the P4 has just the one). - read-only — `url` (the playable address), and the status line reports streaming state, dropped frames, or why the encoder stopped. -Origin: projectMM; encoding by the user's ffmpeg (HLS is Apple's RFC 8216) +Origin: projectMM; encoding by the user's ffmpeg on desktop, the P4's H.264 block on device (HLS is Apple's RFC 8216) Detail: [technical](moxygen/HlsDriver.md) + + +## HLS, details + +**On desktop you install ffmpeg yourself** (any 5.x+, on PATH), projectMM never ships or links an encoder: `brew install ffmpeg` (macOS), `winget install ffmpeg` (Windows), `sudo apt install ffmpeg` (Debian/Ubuntu/Raspberry Pi OS). Without it the driver reports `ffmpeg not found` and nothing else changes. The `encoder` control picks which one ffmpeg uses: `libx264` (the default, in practically every build) is software, while `h264_videotoolbox` on a Mac (~10% CPU for a 1024x1024 stream on Apple Silicon), `h264_v4l2m2m` on a Raspberry Pi and `h264_nvenc` on NVIDIA offload it to hardware and are worth picking on large grids. An encoder your ffmpeg lacks starts and exits immediately; the status then reads `encoder exited - check ffmpeg`. + +**On the ESP32-P4** there is no ffmpeg and no filesystem in the path: the chip's own H.264 block encodes, projectMM packages the MPEG-TS itself, and segments are served from a RAM ring rather than written to flash, which at one segment per second would wear it for nothing. The `encoder` control is absent, since the hardware offers only one. + +**Sizing the picture.** The P4's encoder takes only EVEN dimensions between 80x80 and 1920x2032; an odd wall has its scale doubled so both axes come out even, and a wall whose scaled size exceeds the maximum is refused with a status saying so rather than streaming something the hardware cannot encode. Desktop ffmpeg has none of these limits. The floor is what the auto scale exists for: the P4 will not accept a frame smaller than 80x80, and a small wall streamed 1:1 arrives as a postage stamp in the player. `scale` at 0 (the default) therefore picks the smallest whole factor that lifts *both* axes to 80: a 20x10 wall streams as 160x80 rather than being refused, and a wall already past 80 stays 1:1. One factor serves both axes, so the aspect ratio is preserved and each light stays a square block. Raising `scale` by hand on an already-large wall costs real time (a 128x128 wall at scale 4 measures about 60 ms per frame against 1 ms at 1:1) and buys nothing a player's own zoom does not. + +**The bitrate is derived, not a setting.** It follows from the grid size and `targetFps` at about 0.1 bits per pixel per frame, which puts a 512x512 wall at 30 fps near 800 kbit; a 128x128 lands under the 500 kbit floor the derivation clamps to. `targetFps` is the knob for bandwidth, and the better trade for LED content: fewer frames rather than a blockier picture. + +**Where the segments live.** On desktop, the transient `/.hls/` directory, served at `/hls/` and excluded from config backups. Large grids trade framerate, the render loop being single-threaded: 512x512 streams smoothly, TV-native resolutions do not yet. + ## Preview, details diff --git a/docs/moonmodules/light/effects.md b/docs/moonmodules/light/effects.md index dad2018a..d6adcec6 100644 --- a/docs/moonmodules/light/effects.md +++ b/docs/moonmodules/light/effects.md @@ -267,9 +267,26 @@ Physics is driven by elapsed time, not frame count, so the same settings behave Origin: projectMM original, on the WLED Particle System's firework family by Damian Schneider / [@DedeHai](https://github.com/DedeHai) + + +### Fish Tank 📊 · 2D + +An aquarium on a light wall: fish of three shapes swim across a dark tank, each in its own color from the active palette, tails beating. Movement is a particle-pool entry per fish with constant velocity, respawning at the far edge when it swims off; the shape is drawn through the `draw::sprite` power function. Unlike the other sprite effects, the art carries shade ROLES (body, outline, highlight, fin, eye, band) rather than fixed colors, and each fish fills them from its own place on the palette, so one drawing yields as many colorways as there are fish. + +- `fish` — how many broad tropical fish (0-8). +- `slim` — how many slender fish (0-8). +- `school` — how many tiny schooling fish (0-8). +- `speed` — swim rate in body-lengths, so motion reads the same on any grid; each fish varies around it, and the smaller shapes drift slower, which reads as depth. +- `spriteSize` — integer magnification (crisp nearest-neighbor); 0 = auto, scaling with the grid so a fish reads as a fish on a 16x16 matrix and on a 768-wide desktop grid alike. +- `soundReactive` — move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. + +Uses the global palette: every fish takes a body color from it, with its band a paler version of that same color rather than a second pick, which would read as two fish fused together. + +Origin: projectMM original; inspired by the aquarium screensavers of the After Dark era, the pixel art drawn fresh for this effect + -### Flying Toasters 🔬 · 2D +### Flying Toasters 🔬📊 · 2D The classic screensaver on a light wall: chrome toasters with flapping wings and slices of toast drift diagonally across the dark, forever. Each flier is a particle-pool entry with constant velocity (respawning off the upper-right when it leaves the lower-left), rendered through the `draw::sprite` power function; the wing flap runs on a shared BeatPhase with a per-toaster offset so the flock never syncs. @@ -277,11 +294,30 @@ The classic screensaver on a light wall: chrome toasters with flapping wings and - `toast` — how many slices trail along (0–8). - `speed` — drift rate in sprite-widths, so flight reads the same on any grid; each flier varies ±25% around it. - `spriteSize` — integer magnification for toasters AND toast (crisp nearest-neighbor); 0 = auto, scaling with the grid so a toaster reads as a toaster on a big wall. +- `soundReactive` — move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. The sprites carry their own colors (chrome, wing, crust), so the global palette does not apply. Needs a grid at least the toaster's size (12×9). Origin: projectMM original; inspired by After Dark's Flying Toasters (Berkeley Systems, 1989), suggested by Frank ([softhack007](https://github.com/softhack007)) — the pixel art here is drawn fresh for this effect + + +### Pacman 🔬📊 · 2D + +The arcade cast crossing a light wall: Pacman chomps his way along while the four ghosts drift past, each in its own color, wrapping around the edges forever. Movement is a particle-pool entry per character and the shapes go through the `draw::sprite` power function; one ghost drawing serves all four colors because the art carries palette slots rather than fixed colors, and a single drawing serves both travel directions because `draw::sprite` can mirror it. + +In this first iteration the characters travel independently and do not notice each other. The maze, the pellets and the chase are the next step, built on the shapes and the movement grid this one establishes. + +- `pacmen` — how many Pacmen (0-4). +- `ghosts` — how many ghosts (0-8); the arcade cast is four. +- `speed` — travel rate in sprite-widths, so motion reads the same on any grid; Pacman runs slightly ahead of the ghosts, as in the original. +- `spriteSize` — integer magnification (crisp nearest-neighbor); 0 = auto, scaling with the grid so the characters read on a 16x16 matrix and on a 768-wide desktop grid alike. +- `soundReactive` — move to the music: each sprite follows its own frequency band, so the scene breathes rather than surging as one block, and silence stands it still. Without an audio source the sprites keep moving normally. + +Pacman is always his own yellow; the ghosts take their body colors from the active palette, so they stay four distinguishable characters whatever palette is loaded. + +Origin: projectMM original; inspired by Namco's Pac-Man (1980), the pixel art drawn fresh for this effect + ### Ballpit 🔬 · 2D diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 8555900e..7ccc5459 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -19,6 +19,7 @@ idf_component_register( "../../src/platform/esp32/moonlive_asm_xtensa.cpp" "../../src/platform/esp32/moonlive_asm_riscv.cpp" "../../src/platform/esp32/platform_esp32_fs.cpp" + "../../src/platform/esp32/platform_esp32_h264.cpp" "../../src/platform/esp32/platform_esp32_ota.cpp" "../../src/platform/esp32/platform_esp32_improv.cpp" "../../src/platform/esp32/platform_esp32_rmt.cpp" diff --git a/esp32/main/Kconfig.projbuild b/esp32/main/Kconfig.projbuild index f8833387..e4fce0fe 100644 --- a/esp32/main/Kconfig.projbuild +++ b/esp32/main/Kconfig.projbuild @@ -22,4 +22,18 @@ menu "projectMM" path on an eth-only build (link up but no IP). Only the WiFi build (esp32p4-eth-wifi) sets this, via sdkconfig.defaults.esp32p4-eth-wifi. + config MM_HLS + bool "HLS streaming via the hardware H.264 encoder (ESP32-P4 only)" + # The title says P4-only; this makes it so. Without it a fragment or menuconfig could + # set the symbol on another target, which would pull esp_h264 and compile a call to + # esp_h264_enc_hw_new on silicon that has no encoder. + depends on IDF_TARGET_ESP32P4 + default y if IDF_TARGET_ESP32P4 + default n + help + Pulls the esp_h264 managed component and compiles the platform HLS + encoder (hardware H.264 + MPEG-TS muxer + PSRAM segment ring) behind + the same HlsDriver the desktop uses. Only the P4 has the encoder + silicon; other chips keep this off and build nothing. + endmenu diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml index 46fd968d..3256357b 100644 --- a/esp32/main/idf_component.yml +++ b/esp32/main/idf_component.yml @@ -35,6 +35,13 @@ dependencies: # (dsps_fft2r_fc32) for the microphone spectrum. Float (not fixed-point) because # every mic-capable target here has an FPU, where float is faster. Referenced # only from platform_esp32_i2s.cpp's audioFft. + # H.264 hardware encoder wrapper for the P4's encoder silicon (HLS streaming). Gated on + # MM_HLS, not the target: a bare target rule would land it in every P4 image; the Kconfig + # gate keeps it out wherever HLS is off. $CONFIG{NAME} syntax per the esp_wifi_remote note. + espressif/esp_h264: + version: "^1.0.0" + rules: + - if: "$CONFIG{MM_HLS} == True" espressif/esp-dsp: version: "^1.5.0" # esp_codec_dev — Espressif's audio-codec driver library; we use its ES8311 part diff --git a/esp32/partitions/ota_16mb_moonbase.csv b/esp32/partitions/ota_16mb_moonbase.csv new file mode 100644 index 00000000..505faee2 --- /dev/null +++ b/esp32/partitions/ota_16mb_moonbase.csv @@ -0,0 +1,29 @@ +# 16 MB partition table with MoonBase: a factory recovery image, ONE app slot, and the flash +# the second OTA slot used to hold given to the filesystem. +# +# The same trade the 4 MB table makes (esp32dev_moonbase.csv), taken deliberately rather than +# out of necessity. Dual-OTA spends half the app area on a second copy of the firmware that is +# idle except during an update; MoonBase is a small image that owns the device while the app is +# replaced, so one app slot is enough. On 16 MB that returns 4 MB, and it goes to LittleFS: +# 11264 KB instead of 7168 KB, which is where the space is actually useful (MoonLive scripts, +# effect assets, captures). +# +# A separate file rather than an edit to ota_16mb.csv, which the S3-N16R8 and P4 images share: +# changing that in place would repartition those boards too. A device flashed with the old +# table keeps it until a FULL serial flash, since OTA updates the app and never the table. +# +# Layout (16 MB = 0x1000000): +# 0x9000-0xDFFF nvs ( 20 KB) +# 0xE000-0xFFFF otadata ( 8 KB) +# 0x10000-0xEFFFF moonbase ( 896 KB) -> factory, the recovery image (see moonbase/) +# 0xF0000-0x4EFFFF app ( 4096 KB) -> ota_0, the only app slot +# 0x4F0000-0xFEFFFF spiffs (11264 KB) -> LittleFS state +# 0xFF0000-0xFFFFFF coredump ( 64 KB) +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x5000, +otadata, data, ota, 0xe000, 0x2000, +moonbase, app, factory, 0x10000, 0xE0000, +app, app, ota_0, 0xF0000, 0x400000, +spiffs, data, spiffs, 0x4F0000, 0xB00000, +coredump, data, coredump, 0xFF0000, 0x10000, diff --git a/esp32/sdkconfig.defaults.esp32p4rev1-eth b/esp32/sdkconfig.defaults.esp32p4rev1-eth index 5e9bf287..33b843b1 100644 --- a/esp32/sdkconfig.defaults.esp32p4rev1-eth +++ b/esp32/sdkconfig.defaults.esp32p4rev1-eth @@ -60,3 +60,16 @@ CONFIG_ETH_DMA_TX_BUFFER_NUM=10 # (Same rationale as sdkconfig.defaults.esp32s3-n16r8.) CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n CONFIG_HEAP_HAS_EXEC_HEAP=y + +# esp-dsp's PORTABLE C kernels instead of its hand-written P4 assembly. The assembly FFT uses the +# P4's hardware-loop instruction (esp.lp.setup), and that unit carries a documented erratum - +# Espressif's own soc_caps: SOC_CPU_HAS_HWLOOP_STATE_BUG, "HWLOOP state doesn't go to DIRTY after +# executing the last instruction of a loop". FreeRTOS saves those registers lazily, keyed on that +# DIRTY flag, so a task switched out of the FFT has its loop state silently dropped and faults on +# resume (bench: a crash loop the moment HLS added a second task, Illegal instruction / Load +# access fault inside dsps_fft2r_fc32_arp4.S). IDF patches the erratum on the coprocessor RESTORE +# paths but not on SAVE. Measured cost of the portable kernels: audio tick ~615 us -> ~658 us. +# A WORKAROUND, not a fix: esp-idf#19025 (ours, naming the unguarded save path) and esp-dsp#119 +# (the same fault from another reporter, on IDF v5.5-beta1). Remove once upstream answers. +# Lives in the board fragment because every P4 image (rev1/rev3, eth/eth-wifi) layers on it. +CONFIG_DSP_ANSI=y diff --git a/esp32/sdkconfig.defaults.esp32s3-zero b/esp32/sdkconfig.defaults.esp32s3-zero new file mode 100644 index 00000000..703b3776 --- /dev/null +++ b/esp32/sdkconfig.defaults.esp32s3-zero @@ -0,0 +1,31 @@ +# ESP32-S3-Zero (Waveshare): 4 MB embedded flash, 2 MB embedded QUAD PSRAM. +# Append to sdkconfig.defaults (later fragment wins). +# +# The two things that make this its own variant rather than a reuse of the other S3 images: +# the flash is 4 MB where they assume 8 or 16, and the PSRAM is QUAD where they set +# SPIRAM_MODE_OCT. An octal-mode binary does not merely under-perform on quad hardware, it +# fails PSRAM init at boot, so neither existing S3 firmware can be flashed here. + +# The partition table comes from the moonbase-4mb fragment this variant layers on: a factory +# MoonBase recovery image plus ONE 2496 KB app slot, rather than the plain 4 MB table's two +# 1856 KB OTA slots. On a part this small that is the difference between ~690 KB of headroom +# and ~48 KB, and the S3 image is already ~1808 KB. The trade is the OTA slot pair: an update +# has no second app partition to roll back to, and MoonBase is the recovery path instead. +CONFIG_PARTITION_TABLE_CUSTOM=y + +# Flash size override (the ESP32-S3 default is 2 MB). +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y + +# PSRAM: 2 MB embedded, QUAD mode. Not SPIRAM_MODE_OCT, which the N8R8/N16R8 images use for +# their external octal parts. +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_QUAD=y + +# MoonLive native codegen needs an executable heap (allocExec -> MALLOC_CAP_EXEC IRAM). +# MALLOC_CAP_EXEC is gated behind CONFIG_HEAP_HAS_EXEC_HEAP, which IDF disables whenever +# memory protection (PMP/PMS W^X) is on. A JIT fundamentally needs writable-then-executable +# memory, so disable memprot (which enables the exec heap). Same configuration as the other +# S3 images; safety for scripted code is the staged bounds/watchdog checks, not the +# hardware W^X wall. +CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n +CONFIG_HEAP_HAS_EXEC_HEAP=y diff --git a/esp32/sdkconfig.defaults.moonbase-16mb b/esp32/sdkconfig.defaults.moonbase-16mb new file mode 100644 index 00000000..b5f16e4d --- /dev/null +++ b/esp32/sdkconfig.defaults.moonbase-16mb @@ -0,0 +1,11 @@ +# MoonBase on a 16 MB board: the factory recovery image, ONE app slot, and the flash the second +# OTA slot used to hold given to the filesystem. +# +# The same trade sdkconfig.defaults.moonbase-4mb makes, chosen here rather than forced. Dual-OTA +# spends half the app area on a second copy of the firmware that sits idle except during an +# update; MoonBase is a small image that owns the device while the app is replaced, so one app +# slot is enough and the recovery story is stronger (a power cut mid-install boots MoonBase and +# the user retries over the network). On 16 MB that returns 4 MB, and it goes to LittleFS: +# 11264 KB instead of 7168 KB, which is where the space is actually useful (MoonLive scripts, +# effect assets, captures). +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_16mb_moonbase.csv" diff --git a/moonbase/main/moonbase_main.cpp b/moonbase/main/moonbase_main.cpp index 1e2a2860..d778b253 100644 --- a/moonbase/main/moonbase_main.cpp +++ b/moonbase/main/moonbase_main.cpp @@ -31,8 +31,11 @@ #include "esp_system.h" #include "esp_wifi.h" #include "soc/gpio_num.h" +#include "soc/soc_caps.h" // SOC_EMAC_SUPPORTED: the S3 and other WiFi-only parts have no EMAC #include "esp_eth.h" -#include "esp_eth_mac_esp.h" +#if SOC_EMAC_SUPPORTED +#include "esp_eth_mac_esp.h" // esp_eth_mac_new_esp32: only exists on a chip with an EMAC +#endif #include "esp_eth_netif_glue.h" #include "freertos/FreeRTOS.h" #include "freertos/event_groups.h" @@ -210,6 +213,12 @@ esp_eth_handle_t ethHandle_ = nullptr; esp_netif_t* ethNetif_ = nullptr; bool ethStart() { +#if !SOC_EMAC_SUPPORTED + // No internal EMAC on this chip (the S3 and other WiFi-only parts). MoonBase's job is to + // get a recovery UI onto the network, and on such a board that is WiFi; the RMII path below + // would not link, and its esp_eth_mac_new_esp32 does not even exist there. + return false; +#else if (ethCfg_.type != 1) return false; // 1 = LAN8720/RMII in the app's ethType vocabulary esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH(); @@ -253,6 +262,7 @@ bool ethStart() { ethHandle_ = handle; ethNetif_ = netif; return true; +#endif // SOC_EMAC_SUPPORTED } // Tear Ethernet down again when no lease arrived in its window: like the app, MoonBase runs diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py index 3545ac36..6d91da3f 100644 --- a/moondeck/build/build_esp32.py +++ b/moondeck/build/build_esp32.py @@ -165,11 +165,12 @@ def check_idf_pin(idf_path: Path) -> None: "esp32-16mb": { "chip": "esp32", "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.16mb", - "sdkconfig.defaults.eth"], + "sdkconfig.defaults.eth", "sdkconfig.defaults.moonbase-16mb"], + "moonbase": True, # MoonBase + ONE app slot; the freed 4 MB goes to the filesystem "eth_only": False, "description": "ESP32 classic with 16 MB flash — WiFi + Ethernet. Same silicon " - "as `esp32`; this variant uses the extra flash for bigger OTA " - "slots + filesystem (Serg boards, QuinLED Dig-Octa).", + "as `esp32`; this variant uses the extra flash for a big app slot " + "+ an 11 MB filesystem (Serg boards, QuinLED Dig-Octa).", "ships": True, }, "esp32-wrover": { @@ -222,6 +223,23 @@ def check_idf_pin(idf_path: Path) -> None: # real way to try this before buying an S31. "panel_cards": True, }, + "esp32s3-zero": { + "chip": "esp32s3", + "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32s3-zero", + "sdkconfig.defaults.moonbase-4mb"], + "moonbase": True, # 4 MB: factory MoonBase + one big app slot (see moonbase/) + "eth_only": False, + "description": "ESP32-S3-Zero (N4R2: 4 MB embedded flash, 2 MB embedded QUAD " + "PSRAM) - WiFi only, no Ethernet. Its own variant because neither " + "other S3 image can boot here: both assume 8/16 MB flash and set " + "SPIRAM_MODE_OCT, and octal mode fails PSRAM init on this board's " + "quad part. A thumbnail-sized board for small installations.", + "ships": True, + # No Ethernet fragment: the Zero breaks out no SPI header for a W5500, and its + # appeal is the size, so a variant carrying an unusable PHY would only cost flash + # on a part that has 48 KB to spare. + "panel_cards": False, + }, "esp32p4rev1-eth": { "chip": "esp32p4", "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4rev1-eth"], diff --git a/moondeck/check/hook_prose.py b/moondeck/check/hook_prose.py new file mode 100644 index 00000000..811de778 --- /dev/null +++ b/moondeck/check/hook_prose.py @@ -0,0 +1,108 @@ +#!/usr/bin/env -S uv run --script +"""Claude Code PostToolUse hook: refuse a write that adds an em-dash or a British spelling. + +The rules themselves live in the coding standards and are checked by check_prose.py at the +commit gate. This hook moves that check to the MOMENT OF WRITING, which is the only place it +can actually change behavior: an author does not notice these in their own prose, so finding +out 200 lines later means a sweep, while finding out on the edit means a rewrite of the one +sentence still in mind. + +Wired in .claude/settings.json as a PostToolUse hook on Write|Edit. Exit 2 tells Claude Code +the tool call had a problem and feeds stderr back, so the fix happens in context. + +Only fires on the suffixes the standards govern (check_prose.py owns that list), and only on +ADDED lines, so pre-existing prose in a file being edited is never the writer's problem. +""" + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +CHECK = Path(__file__).with_name("check_prose.py") + + +def main() -> int: + # The hook payload arrives as JSON on stdin. A malformed or absent payload must not block + # the write: this check is a guardrail, not a gatekeeper on the tool protocol itself. + try: + payload = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return 0 + + path = (payload.get("tool_input") or {}).get("file_path", "") + if not path: + return 0 + # check_prose.py governs which suffixes carry prose; mirror its list rather than a second + # opinion that could drift from it. + sys.path.insert(0, str(CHECK.parent)) + try: + import check_prose # noqa: PLC0415 (deliberate: the suffix list has ONE home) + except ImportError: + return 0 + if not path.endswith(check_prose.SUFFIXES): + return 0 + + repo = CHECK.parents[2] + # Absolute tool paths and check=False on every call: a hook runs with whatever PATH the + # editor had, and an implicit check would raise rather than let the write proceed. + git = shutil.which("git") + uv = shutil.which("uv") + if not git or not uv: + return 0 + # A brand-new file is invisible to `git diff` until git knows of it, and a new file is + # exactly where fresh prose lands. `add -N` records the path without staging content, which + # is enough for the diff to show its lines and leaves the index otherwise untouched. + tracked = subprocess.run( + [git, "ls-files", "--error-unmatch", "--", path], + capture_output=True, text=True, cwd=repo, check=False, + ).returncode == 0 + if not tracked: + # `--` so a path that starts with a dash is a path, not a flag. + subprocess.run([git, "add", "-N", "--", path], capture_output=True, cwd=repo, check=False) + + result = subprocess.run( + [uv, "run", str(CHECK)], capture_output=True, text=True, cwd=repo, check=False + ) + # Undo the intent-to-add. The index is the product owner's: a file they have not reviewed + # must not appear staged in `git status`, where the "commit now covers only what was + # reviewed" rule reads it. + if not tracked: + subprocess.run([git, "reset", "-q", "--", path], capture_output=True, cwd=repo, + check=False) + if result.returncode == 0: + return 0 + + # Report only findings for the file just written. The check reports the whole diff, and a + # finding in some other file is not this write's business (it will be caught on its own + # write, or at the gate). Matched on the REPO-RELATIVE path, not the basename: two files + # can share a name in different folders, and a basename match would report one against the + # other. Detail lines are indented under their heading, so they are kept only while the + # heading above them is ours. + try: + rel = str(Path(path).resolve().relative_to(repo)) + except ValueError: + rel = path + lines, ours = [], False + for ln in result.stdout.splitlines(): + if ln.startswith(" "): + if ours: lines.append(ln) + continue + ours = rel in ln + if ours: lines.append(ln) + if not lines: + return 0 + + print( + "Prose rule (CLAUDE.md, coding-standards): American spelling, no em-dashes.\n" + + "\n".join(lines) + + "\n\nFix the line just written: a comma, colon or full stop where the em-dash is, " + "and the US spelling (color, serialize, behavior, analyze).", + file=sys.stderr, + ) + return 2 # tells Claude Code to surface stderr back to the model + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/repo_health.py b/moondeck/check/repo_health.py index 7cd39cc0..ca5e31a0 100644 --- a/moondeck/check/repo_health.py +++ b/moondeck/check/repo_health.py @@ -116,6 +116,53 @@ def measure_comments(): return out +# Firmwares whose binary was actually measured this run, as opposed to carried forward from the +# previous one. Without this the report cannot tell "built, and unchanged" from "not built", and a +# carried-forward row reads as a result: exactly the false reassurance the freshness rule exists +# to prevent, moved one step later. +MEASURED_THIS_RUN = set() + + +def app_partition_bytes(firmware): + """The app slot's size for `firmware`, from the partition CSV its build actually used. + + The ceiling a firmware is measured against is not a constant: the variants use different + tables (4 MB classic, 8 MB S3, 16 MB OTA), so a raw KB number says nothing about how close + to full a target is. Read from the GENERATED sdkconfig rather than the defaults fragments, + because that is what the build resolved after layering them. Returns 0 when it cannot be + determined, and the caller then simply omits the capacity rather than guessing one. + """ + cfg = ROOT / "build" / f"esp32-{firmware}" / "sdkconfig" + if not cfg.exists(): + return 0 + name = "" + for line in cfg.read_text(errors="ignore").splitlines(): + if line.startswith("CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="): + name = line.split("=", 1)[1].strip().strip('"') + break + csv = ROOT / "esp32" / name if name else None + if not csv or not csv.exists(): + return 0 + # The OTA slot, not merely the first app row: the MoonBase tables put a small `factory` + # recovery image first (896 KB), and measuring the firmware against THAT reports a target + # as 196% full when it is comfortably inside its real 2496 KB slot. The ota_0 slot is where + # the firmware actually lands, and ota_1 equals it by construction. + factory = 0 + for line in csv.read_text(errors="ignore").splitlines(): + if line.lstrip().startswith("#"): + continue + parts = [c.strip() for c in line.split(",")] + if len(parts) >= 5 and parts[1] == "app": + try: + size = int(parts[4], 0) + except ValueError: + continue + if parts[2].startswith("ota_"): + return size + factory = factory or size + return factory # a single-app table has no ota_ slot; its factory slot IS the ceiling + + def measure_flash(): """Built firmware size per variant, in bytes — STALE BINARIES EXCLUDED. @@ -152,6 +199,7 @@ def measure_flash(): if st.st_mtime <= newest: continue flash[firmware] = st.st_size + MEASURED_THIS_RUN.add(firmware) # The desktop binary, located by build_desktop.desktop_binary() so this and collect_kpi.py # cannot name different files in the same run. A bare build/projectMM matched nothing off # macOS, so this metric silently carried a foreign machine's number forward while reading as @@ -166,6 +214,7 @@ def measure_flash(): _, newest = newest_source() if st.st_mtime > newest: flash["desktop"] = st.st_size + MEASURED_THIS_RUN.add("desktop") # same rule as the firmwares: measured, so say so return flash @@ -410,10 +459,22 @@ def render_markdown(new, old): "growth visible, the judgment stays human.", ""] if new.get("flash"): - L += ["## Firmware size", "", "| Target | Flash |", "|---|---:|"] + # "Built" is its own column because a carried-forward number is indistinguishable from a + # genuinely unchanged one, and reads as "no growth" when it may mean "not measured". + # "Capacity" is the app slot from that firmware's partition table: KB alone does not say + # whether a target is comfortable or nearly full, and the variants differ (4/8/16 MB). + L += ["## Firmware size", "", + "| Target | Flash | Capacity | Used | Built |", "|---|---:|---:|---:|:--:|"] for k, v in sorted(new["flash"].items()): - L.append(f"| {k} | {_arrow(v, o.get('flash'), k, _kb)} |") - L.append("") + cap = app_partition_bytes(k) if k.startswith("esp32") else 0 + cap_s = _kb(cap) if cap else "-" + used = f"{(100.0 * v / cap):.0f}%" if cap else "-" + built = "yes" if k in MEASURED_THIS_RUN else "carried" + L.append(f"| {k} | {_arrow(v, o.get('flash'), k, _kb)} | {cap_s} | {used} | {built} |") + L += ["", + ("`Built: carried` means that firmware was NOT rebuilt this run and its number is " + "the previous one, so an absent delta says nothing about the change. `Used` is " + "against the app slot in the firmware's own partition table."), ""] if new.get("perf"): L += ["## Render performance", "", "| Target | Tick | FPS |", "|---|---:|---:|"] diff --git a/moondeck/ci/package_desktop.py b/moondeck/ci/package_desktop.py index 9209caaa..5327741b 100644 --- a/moondeck/ci/package_desktop.py +++ b/moondeck/ci/package_desktop.py @@ -64,7 +64,9 @@ def configure_and_build_macos(version: str = "") -> Path: "-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_OSX_ARCHITECTURES=arm64", ] + version_args(version)) - run(["cmake", "--build", bdir, "--config", "Release", "-j"]) + # --target projectMM: packaging ships one binary; the test suite builds and runs in the + # test workflow, and compiling its ~200 files here roughly doubled the packaging build. + run(["cmake", "--build", bdir, "--config", "Release", "-j", "--target", "projectMM"]) binary = BUILD_DIR_MACOS / "projectMM" if not binary.exists(): print(f"package_desktop: expected binary not found at {binary}") @@ -76,7 +78,7 @@ def configure_and_build_linux(version: str = "") -> Path: """Configure + build for Linux x86-64. Returns the built binary path.""" bdir = str(BUILD_DIR_LINUX.relative_to(ROOT)) run(["cmake", "-B", bdir, "-DCMAKE_BUILD_TYPE=Release"] + version_args(version)) - run(["cmake", "--build", bdir, "--config", "Release", "-j"]) + run(["cmake", "--build", bdir, "--config", "Release", "-j", "--target", "projectMM"]) binary = BUILD_DIR_LINUX / "projectMM" if not binary.exists(): print(f"package_desktop: expected binary not found at {binary}") @@ -182,7 +184,7 @@ def configure_and_build_windows(version: str = "") -> Path: "cmake", "-B", bdir, "-DCMAKE_BUILD_TYPE=Release", ] + version_args(version)) - run(["cmake", "--build", bdir, "--config", "Release"]) + run(["cmake", "--build", bdir, "--config", "Release", "--target", "projectMM"]) # MSVC multi-config places binaries under /Release/. binary = BUILD_DIR_WIN / "Release" / "projectMM.exe" if not binary.exists(): diff --git a/moondeck/docs/generate_test_docs.py b/moondeck/docs/generate_test_docs.py index 60731974..a4b91ed2 100644 --- a/moondeck/docs/generate_test_docs.py +++ b/moondeck/docs/generate_test_docs.py @@ -86,13 +86,20 @@ def _fmt_us(us: int) -> str: return f"{us}µs" +# What a cell shows when there is nothing to show. An em-dash is the typographic +# convention for "no value" in a table, and the coding standards exempt a literal one in +# a UI string from the no-em-dashes prose rule. Named once so the six cell renderers agree +# and so the character appears in exactly one place. +NO_VALUE = "\u2014" + + def _fps_from_us(us: int) -> str: """Convert tick_us → frames-per-second string (no decimals for ≥100 FPS, one decimal below; for headline display). Shared core of both `_fps_floor_from_contract` (single scalar → '≥ N FPS') and `_fps_range_from_observed_range` ([min, max] tick → 'lo-hi FPS').""" if us <= 0: - return "—" + return NO_VALUE fps = 1_000_000 / us if fps >= 100: return f"{int(round(fps)):,}" @@ -113,25 +120,35 @@ def _fps_floor_from_contract(tick_us) -> str: but render FPS as the headline number (project convention; see README § Performance).""" if tick_us in (None, 0): - return "—" + return NO_VALUE return f"≥ {_fps_from_us(int(tick_us))}" def _fps_range_from_observed_range(v) -> str: - """Observed tick range [min_us, max_us] → FPS range, inverted (slow tick - = low FPS). Collapses when the formatted endpoints would render the same. - Returns "—" when the input is missing.""" + """Observed tick → FPS range, inverted (slow tick = low FPS). Collapses when the + formatted endpoints would render the same. Returns the missing-value dash when there is + nothing to show. + + Reads the current observation shape (a dict of statistics over a rolling sample + window, moondeck/scenario/_observed.py) and renders p50 to p95: the typical cost and + the tail. NOT min to max, which spans one lucky run to the worst excursion ever + recorded and reads as a much wider performance envelope than the code actually has. + The older [min, max] list is still accepted so a file written before the change + renders rather than raising.""" if v is None: - return "—" - if isinstance(v, list) and len(v) == 2: - lo_us, hi_us = int(v[0]), int(v[1]) - # Higher FPS comes from the lower tick. - hi_fps = _fps_from_us(lo_us) - lo_fps = _fps_from_us(hi_us) - if lo_fps == hi_fps: - return lo_fps - return f"{lo_fps}-{hi_fps}" - return _fps_from_us(int(v)) + return NO_VALUE + pair = _observed_pair(v) + if pair is None: + return NO_VALUE + lo_us, hi_us = pair + # BOTH endpoints must convert: _fps_from_us returns the missing-value dash for a + # non-positive tick, and pairing that with a real number renders a range with a dash on one + # end, which reads as a measurement rather than as absent data. + if lo_us <= 0 or hi_us <= 0: + return NO_VALUE + # Higher FPS comes from the LOWER tick, so the pair inverts. + hi_fps, lo_fps = _fps_from_us(lo_us), _fps_from_us(hi_us) + return lo_fps if lo_fps == hi_fps else f"{lo_fps}-{hi_fps}" def _heap_contract_cell(v) -> str: @@ -139,26 +156,49 @@ def _heap_contract_cell(v) -> str: (the desktop platform reports free_heap=0 / max_alloc_block=0 to mean "no meaningful ceiling"; rendering them as missing was misleading).""" if v is None: - return "—" + return NO_VALUE if v == 0: return "unlimited" return f"≥ {_fmt_heap(int(v))}" -def _heap_observed_cell(v) -> str: - """Observed heap/block range → 'N KB' or 'N-M KB'. None → '—'. 0 → - 'unlimited' (matches the contract-cell semantics — desktop reports 0 - for "no meaningful value", which should display as 'unlimited' rather - than be silently dropped).""" +def _observed_pair(v): + """Any observation shape → the (low, high) pair the cell renderers display, or None. + + ONE place knows the stored shape. Observations are a dict of statistics over a + rolling sample window (moondeck/scenario/_observed.py); a file written before that + change holds a [min, max] list, and an older one a bare scalar. Each renderer used to + re-derive this, so the shape change broke them one at a time. + + For the dict, the pair is p50 to p95: the typical value and its tail. Min to max + would span one lucky run to the worst excursion ever recorded, which reads as a far + wider envelope than the code actually has. n=0 (never measured on this target, e.g. + another OS) returns None, so the cell shows a dash rather than a fabricated zero. + """ if v is None: - return "—" + return None + if isinstance(v, dict): + if not v.get("n"): + return None + return int(v.get("p50", 0)), int(v.get("p95", 0)) if isinstance(v, list) and len(v) == 2: - if int(v[0]) == 0 and int(v[1]) == 0: - return "unlimited" - return _fmt_heap_range(v) - if int(v) == 0: + return int(v[0]), int(v[1]) + if isinstance(v, (int, float)): + return int(v), int(v) + return None + + +def _heap_observed_cell(v) -> str: + """Observed heap/block as 'N KB' or 'N-M KB'; the missing-value dash when absent; and + 'unlimited' for 0, matching the contract cell (desktop reports 0 to mean "no + meaningful value", which should display as unlimited rather than be dropped).""" + pair = _observed_pair(v) + if pair is None: + return NO_VALUE + lo, hi = pair + if lo == 0 and hi == 0: return "unlimited" - return _fmt_heap(int(v)) + return _fmt_heap(lo) if lo == hi else _fmt_heap_range([lo, hi]) def _format_perf_table(step: dict) -> list[str]: @@ -196,7 +236,7 @@ def _format_perf_table(step: dict) -> list[str]: sb = c.get("set_by") or "?" rs = f' "{c["reason"]}"' if c.get("reason") else "" bits.append(f"contract set {sb}{rs}") - at = o.get("at") + at = o.get("last_updated", o.get("at")) if at: bits.append(f"observed {_fmt_at_range(at)}") if bits: @@ -234,10 +274,12 @@ def _fmt_heap_range(v) -> str: def _fmt_at_range(at) -> str: - """`at` is `[first_seen, last_updated]`; collapse when equal.""" + """The date this observation last took a sample (`last_updated`). The older `at` key + and its two-element [first_seen, last_updated] form are still accepted so a file + written before the change renders; only the last element is shown, the first having + described samples that had long aged out of the window.""" if isinstance(at, list) and len(at) == 2: - first, last = at[0], at[1] - return f"{first}" if first == last else f"{first} → {last}" + return str(at[1]) return str(at) diff --git a/moondeck/scenario/_observed.py b/moondeck/scenario/_observed.py index 0e95b496..122bcd46 100644 --- a/moondeck/scenario/_observed.py +++ b/moondeck/scenario/_observed.py @@ -1,88 +1,202 @@ -"""Shared widen-only range update for observed. blocks. +"""Shared observation update for observed. blocks: a rolling sample window +plus the statistics derived from it. -Both runners (moondeck/scenario/run_scenario.py and run_live_scenario.py) -persist a per-target rolling [min, max] range for each scalar in the -observed block. The range expands when a new measurement falls outside -its current bounds; otherwise the JSON isn't rewritten — drastically -reducing diff churn from routine runs. +Both runners (moondeck/scenario/run_scenario.py and run_live_scenario.py) persist +per-target measurements for each scalar in the observed block. + +WHY NOT [min, max]. The previous shape was a widen-only range, and widen-only is the +flaw: nothing ever narrows it, so the max converges on the worst thing that ever +happened on any machine rather than on what the code costs. One contended run pushed a +step from 156 to 2131 us permanently (2026-08-28, a desktop build streaming while the +scenarios measured). A number that only ever grows is not a signal, and comparing +against it hides the regressions it exists to catch. + +WHY NOT mean and standard deviation. Tick times are not normally distributed: there is +a hard floor (the real work) and a one-sided tail of excursions (scheduler, cache, +contention), which is roughly log-normal. The mean is dragged by that tail, so it moves +when the machine is busy rather than when the code changes, and "outside 1 or 2 sigma" +has no stable meaning on a skewed distribution -- 2 sigma is ~5% of samples only if the +data is normal, and this data is not. + +SO: keep a bounded window of raw samples and derive order statistics from it. + - p50 (median): what it normally costs. Outliers cannot drag it. + - p95: the tail, replacing max as the regression indicator. A real regression moves + it; a single bad run does not. + - min: the floor, i.e. the uncontended cost, which is genuinely informative. + - max: kept for continuity with the previous shape and as an outlier tell. + - n: how many samples back the numbers. n=1 is a first impression, not a baseline, + and the reader can see which they are looking at. Shape: observed. = { - "tick_us": [min, max], - "free_heap": [min, max], - "max_alloc_block": [min, max], - "at": [first_seen_iso, last_updated_iso], + "tick_us": {"p50":…, "p95":…, "min":…, "max":…, "n":…, "samples":[…]}, + "free_heap": {…}, + "max_alloc_block": {…}, + "last_updated": iso_date, } -Min/max is the literal numeric range. The "what to watch" mapping is a -property of the contract direction (tick contract = ceiling, so observed -max is the failure indicator; heap/block contract = floor, so observed -min is the failure indicator). See docs/testing.md § Persistent observations. +`last_updated` is the date this block last took a sample. It was `at` holding +`[first_seen, last_updated]`; the pair was dropped as misleading. After kWindow runs the +block's creation date describes samples that have long aged out, so it answered "when did +we start watching this" while reading as "how old is this data". The question a reader +actually has is whether the numbers are fresh, which one date answers. + +The window is bounded (kWindow) so the file cannot grow without limit and so an old +contended sample eventually ages out, which the widen-only shape could never do. -When the user explicitly renegotiates the contract (--update-contract), -the observed range resets to the current single-point measurement — the -historical range was for the *previous* contract and no longer applies. -That reset is the caller's responsibility (this module just widens or -seeds the range; the caller decides when to call which). +The "what to watch" mapping is a property of the contract direction (tick contract = +ceiling, so p95 is the failure indicator; heap/block contract = floor, so min is). +See docs/testing.md § Persistent observations. + +A target that cannot be run here (another OS) is reformatted into this shape with n=0 +and an empty window, so the file is uniform and the absence of data is explicit rather +than implied by a stale pair of numbers. """ from __future__ import annotations _FIELDS = ("tick_us", "free_heap", "max_alloc_block") +# 0 is never a measurement, in any field, so it never enters a window. +# +# A tick of 0 us means the step ran below the host clock's resolution, not that it was free; a +# window holding those reports a median of 0, a step that looks infinitely fast. A free_heap or +# max_alloc_block of 0 is the desktop platform saying "no meaningful ceiling", which is a +# CONSTANT: the value never varies (verified across every scenario file: desktop has exactly one +# distinct value, 0), so a 32-sample window of it is 32 copies of a fact that could not change. +# Recording nothing leaves n=0, which the report already renders as "not measured here" -- the +# honest answer for a target that has no such limit. Every ESP32 target reports real varying +# numbers and is untouched by this. + +# Samples kept per field. Enough for a p95 to mean something (the 95th percentile of 32 +# samples is the second-worst, which a one-off cannot reach), small enough that the JSON +# stays readable and a stale measurement ages out within a few dozen runs. +kWindow = 32 + + +def _pct(sorted_vals: list[int], q: float) -> int: + """The q-quantile by nearest-rank, the definition that returns an ACTUAL observed + sample rather than an interpolation between two. A measured number is what a reader + can go and reproduce; an interpolated one never happened.""" + if not sorted_vals: + return 0 + import math + rank = max(1, math.ceil(q * len(sorted_vals))) + return int(sorted_vals[min(rank, len(sorted_vals)) - 1]) + + +def _stats(samples: list[int]) -> dict: + """Derive the reported statistics from a sample window.""" + if not samples: + return {"p50": 0, "p95": 0, "min": 0, "max": 0, "n": 0, "samples": []} + s = sorted(int(v) for v in samples) + return { + "p50": _pct(s, 0.50), + "p95": _pct(s, 0.95), + "min": s[0], + "max": s[-1], + "n": len(s), + # Stored in ARRIVAL order, not sorted: the window is a history, and dropping the + # oldest is only meaningful if order is preserved. + "samples": [int(v) for v in samples], + } + + +def _window_of(block: dict, field: str) -> list[int]: + """The existing sample window for `field`, migrating the older shapes on the way. + + Three shapes have existed and a file can hold any of them: the current dict with a + window, the [min, max] range, and a bare scalar. + + A range seeds the window with its MIN only, not both ends. The min is a real + observation (something once ran that fast), while the max of a widen-only range is + the worst excursion ever recorded on any machine -- the very number this shape exists + to stop trusting. Importing it as a sample would carry the defect across the + migration and hold p95 up for 32 runs. + """ + cur = block.get(field) + if isinstance(cur, dict): + w = cur.get("samples") + return [int(v) for v in w] if isinstance(w, list) else [] + if isinstance(cur, list) and len(cur) == 2: + return [int(cur[0])] # the min: see the docstring + if isinstance(cur, (int, float)): + return [int(cur)] + return [] + def widen(existing: dict | None, sample: dict, today: str) -> tuple[dict, bool]: - """Return (new_observed_block, changed) given an existing block (or None) - and a fresh measurement sample {field: scalar, ...}. - - - If existing is None (first observation for this target), seed both ends - of the range to the sample value and stamp `at = [today, today]`. - - If the sample is inside the existing range for every field, return the - existing block unchanged and changed=False — the runner can skip writing. - - Otherwise, widen each field's range as needed and stamp `at = [first, - today]` (keeping the original first_seen).""" - if existing is None or not _is_range_shape(existing): - block = {f: [int(sample[f]), int(sample[f])] for f in _FIELDS if f in sample} - block["at"] = [today, today] - return block, True - - new_block = dict(existing) - changed = False + """Return (new_observed_block, changed) for a fresh measurement. + + Named `widen` for its callers' sake (both runners call it on every measured step); + it now appends to a rolling window rather than widening a range. + + `changed` is True whenever a sample was recorded, because the sample IS the state: + the window has to be persisted for the next run to build on it, and a caller that + skips the write drops the measurement entirely. + + This does mean a scenario file is rewritten on every run that measures it, which the + previous [min, max] shape avoided by being a no-op for in-bounds values. That trade + is deliberate and it is the price of percentiles: a statistic over a window can only + exist if the window survives, and a range that never narrows was the actual defect + (see the module docstring). The diff stays small -- one line per field plus the + window -- and `at` shows when a file last moved. + """ + block = dict(existing) if isinstance(existing, dict) else {} + recorded = False for f in _FIELDS: if f not in sample: continue - v = int(sample[f]) - cur = existing.get(f) - if not isinstance(cur, list) or len(cur) != 2: - new_block[f] = [v, v] - changed = True - continue - lo, hi = int(cur[0]), int(cur[1]) - new_lo, new_hi = min(lo, v), max(hi, v) - if new_lo != lo or new_hi != hi: - new_block[f] = [new_lo, new_hi] - changed = True + value = int(sample[f]) + if value == 0: + continue # not a measurement: see the note above _FIELDS + window = _window_of(block, f) + window.append(value) + if len(window) > kWindow: + window = window[-kWindow:] # drop the oldest, keep arrival order + block[f] = _stats(window) + recorded = True + + if not recorded: + return (existing if isinstance(existing, dict) else {}), False - if changed: - at = existing.get("at") - first = at[0] if isinstance(at, list) and len(at) == 2 else today - new_block["at"] = [first, today] - return new_block, changed + block["last_updated"] = today + return block, True def reset(sample: dict, today: str) -> dict: - """Build a fresh single-point observed block — called when the contract - is renegotiated and the previous range's history no longer applies.""" - block = {f: [int(sample[f]), int(sample[f])] for f in _FIELDS if f in sample} - block["at"] = [today, today] + """Build a fresh observed block from a single measurement -- called when the contract + is renegotiated and the previous window described the PREVIOUS contract.""" + block = {f: _stats([] if int(sample[f]) == 0 else [int(sample[f])]) + for f in _FIELDS if f in sample} + block["last_updated"] = today return block -def _is_range_shape(block: dict) -> bool: - """True if at least one numeric field is already in [min,max] list form. - Used to detect post-migration shape vs the older scalar shape.""" - for f in _FIELDS: - v = block.get(f) - if isinstance(v, list) and len(v) == 2: - return True - return False +def empty(fields: tuple[str, ...] = _FIELDS) -> dict: + """A block for a target that cannot be measured here (another OS): the current shape, + n=0, no samples. Explicit absence, rather than a stale number that reads as data.""" + return {f: _stats([]) for f in fields} + + +def compact_samples(text: str) -> str: + """Collapse each `"samples": [...]` array onto one line in already-serialized JSON. + + json.dump indents every array element, so a 32-sample window becomes 34 lines of + single numbers and buries the statistics that sit above it. The window is one value + conceptually, so it reads as one line. Applied to the serialized text because + json.dump cannot format one array differently from the rest. + + Only touches arrays of numbers, so a `samples` key holding anything else (there is + none today) is left exactly as written rather than silently reformatted. + """ + import re + + def one_line(m: "re.Match[str]") -> str: + body = m.group(2) + if not re.fullmatch(r"[\s\d,.\-]*", body): + return m.group(0) # not plain numbers: leave it alone + nums = [p.strip() for p in body.split(",") if p.strip()] + return f'{m.group(1)}"samples": [{", ".join(nums)}]' + + return re.sub(r'( *)"samples": \[([^\]]*)\]', one_line, text) diff --git a/moondeck/scenario/run_live_scenario.py b/moondeck/scenario/run_live_scenario.py index 69d5ffbb..77db945b 100644 --- a/moondeck/scenario/run_live_scenario.py +++ b/moondeck/scenario/run_live_scenario.py @@ -759,11 +759,10 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, print(f" PASS max_alloc_block {max_block} >= contract {exp_block} " f"(within -{heap_tol_pct}% tolerance)") - # observed. stores a rolling [min, max] range per scalar - # that only widens when a fresh measurement falls outside the - # current bounds. Routine runs that stay in range produce no JSON - # diff. When --update-contract is set, the historical range no - # longer reflects the new promise, so reset to the current point. + # observed. keeps a rolling window of samples per scalar and + # reports p50/p95/min/max/n over it, so a single slow run shifts the tail + # rather than the headline. When --update-contract is set, the window + # described the PREVIOUS promise, so reset to the current point. # See moondeck/scenario/_observed.py. sample = { "tick_us": int(tick_us), diff --git a/moondeck/scenario/run_scenario.py b/moondeck/scenario/run_scenario.py index a1a083e9..908f3999 100644 --- a/moondeck/scenario/run_scenario.py +++ b/moondeck/scenario/run_scenario.py @@ -219,12 +219,11 @@ def _run_one(path: Path, update_contract: bool, update_reason: str | None) -> in name = step.get("name") if name not in observations: continue - # observed. stores a rolling [min, max] range per scalar that - # only widens when a fresh measurement falls outside the current bounds - # — drops JSON churn on routine runs to near-zero while preserving full - # drift visibility. When --update-contract was passed, reset the range - # to the current single point (the historical range was for the - # previous contract). See moondeck/scenario/_observed.py. + # observed. keeps a rolling window of samples per scalar and reports + # p50/p95/min/max/n over it: the median is what the step normally costs and p95 + # is its tail, neither of which a single contended run can move far. When + # --update-contract was passed, reset to the current single point (the window + # described the PREVIOUS contract). See moondeck/scenario/_observed.py. existing_obs = step.get("observed", {}).get(target) if update_contract: new_obs = _observed.reset(observations[name], today) @@ -264,9 +263,12 @@ def _run_one(path: Path, update_contract: bool, update_reason: str | None) -> in touched_contract += 1 if touched_observed or touched_contract: + # Serialize, then put each sample window back on one line: a 32-element array + # spread over 32 lines hides the statistics it belongs to (_observed.py). + text = _observed.compact_samples( + json.dumps(scenario, indent=2, ensure_ascii=False)) with open(path, "w", encoding="utf-8") as f: - json.dump(scenario, f, indent=2, ensure_ascii=False) - f.write("\n") + f.write(text + "\n") what = [] if touched_observed: what.append(f"observed[{target}] × {touched_observed}") diff --git a/mooninstaller/deviceModels.json b/mooninstaller/deviceModels.json index 8d8df58d..67ddc3c4 100644 --- a/mooninstaller/deviceModels.json +++ b/mooninstaller/deviceModels.json @@ -1346,5 +1346,52 @@ } } ] + }, + { + "name": "ESP32-S3-Zero (N4R2)", + "chip": "ESP32-S3", + "firmwares": [ + "esp32s3-zero" + ], + "image": "assets/deviceModels/esp32-s3-zero-pinout.png", + "url": "https://www.waveshare.com/wiki/ESP32-S3-Zero", + "supported": [ + "LEDs", + "WiFi" + ], + "planned": [], + "modules": [ + { + "type": "System", + "id": "System", + "controls": { + "deviceModel": "ESP32-S3-Zero (N4R2)" + } + }, + { + "type": "RmtLedDriver", + "id": "RmtLed", + "parent_id": "Drivers", + "controls": { + "pins": "21", + "count": 1 + } + }, + { + "type": "ParallelLedDriver", + "id": "ParallelLed", + "parent_id": "Drivers", + "controls": { + "pins": "2" + } + }, + { + "type": "NetworkModule", + "id": "Network", + "controls": { + "txPowerSetting": 8 + } + } + ] } ] diff --git a/mooninstaller/firmwares.json b/mooninstaller/firmwares.json index f5cfcfa5..7fbebf37 100644 --- a/mooninstaller/firmwares.json +++ b/mooninstaller/firmwares.json @@ -12,7 +12,7 @@ "chip": "esp32", "eth_only": false, "ships": true, - "description": "ESP32 classic with 16 MB flash — WiFi + Ethernet. Same silicon as `esp32`; this variant uses the extra flash for bigger OTA slots + filesystem (Serg boards, QuinLED Dig-Octa)." + "description": "ESP32 classic with 16 MB flash — WiFi + Ethernet. Same silicon as `esp32`; this variant uses the extra flash for a big app slot + an 11 MB filesystem (Serg boards, QuinLED Dig-Octa)." }, { "name": "esp32-wrover", @@ -42,6 +42,13 @@ "ships": true, "description": "ESP32-S3 (N8R8: 8 MB flash, 8 MB octal PSRAM) — WiFi + W5500 SPI Ethernet. Half the flash of N16R8; the N16R8 binary overruns an 8 MB board, so N8R8 boards (LightCrafter etc.) need this variant." }, + { + "name": "esp32s3-zero", + "chip": "esp32s3", + "eth_only": false, + "ships": true, + "description": "ESP32-S3-Zero (N4R2: 4 MB embedded flash, 2 MB embedded QUAD PSRAM) - WiFi only, no Ethernet. Its own variant because neither other S3 image can boot here: both assume 8/16 MB flash and set SPIRAM_MODE_OCT, and octal mode fails PSRAM init on this board's quad part. A thumbnail-sized board for small installations." + }, { "name": "esp32p4rev1-eth", "chip": "esp32p4", diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 22eb08bd..80cda29e 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -202,7 +202,11 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { // counter cannot wrap. // NOLINTNEXTLINE(bugprone-too-small-loop-variable) for (uint8_t o = 0; o < c.max; o++) { - sink.appendf("%s\"%s\"", o > 0 ? "," : "", options[o]); + if (o > 0) sink.append(","); + // Escaped, not a raw %s: most option lists are our own literals, but the panel-card + // interface Select carries OS-supplied adapter descriptions, and one containing a + // quote or a backslash would make all of /api/state invalid and blank the UI. + sink.writeJsonString(options[o] ? options[o] : ""); } sink.append("]"); return; @@ -393,12 +397,32 @@ ApplyResult applyControlValue(const ControlDescriptor& c, const bool overlong = std::strlen(label) >= sizeof(label) - 1; if (label[0]) { auto* options = reinterpret_cast(c.aux); - if (options && !overlong) + if (options && !overlong) { for (int i = 0; i <= hi; i++) if (options[i] && std::strcmp(options[i], label) == 0) return clampInto(static_cast(c.ptr), i, 0, hi); + // Then on the STABLE HEAD of the label, the part before ", ". An option may + // carry a live detail after that separator (the panel-card NIC list appends a + // link speed, "Realtek PCIe GbE, 1 Gb"), and matching the whole string would + // lose the user's pick the moment that detail changed: a renegotiated link, or + // the same NIC at 100 Mb instead of 1 Gb, would silently fall back to row 0. + // BOTH sides are cut at the separator: the persisted label carries the + // detail it was written with, and the option carries the current one, so + // comparing a whole label against a head never matches. + const char* lsep = std::strstr(label, ", "); + const size_t lhead = lsep ? static_cast(lsep - label) + : std::strlen(label); + for (int i = 0; i <= hi; i++) { + if (!options[i]) continue; + const char* sep = std::strstr(options[i], ", "); + const size_t head = sep ? static_cast(sep - options[i]) + : std::strlen(options[i]); + if (head == lhead && std::strncmp(options[i], label, head) == 0) + return clampInto(static_cast(c.ptr), i, 0, hi); + } + } // A label that names no current option (a peripheral this board can't run, or one too long - // to be any real option) is not an error in Lenient policy — the driver keeps its default; + // to be any real option) is not an error in Lenient policy: the driver keeps its default; // Strict rejects it. if (policy == ApplyPolicy::Strict) return ApplyResult::OutOfRange; return ApplyResult::Ok; diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 81e5774d..f09bcc78 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -16,10 +16,10 @@ #include "core/FilesystemModule.h" #include "core/FirmwareUpdateModule.h" #include "core/SystemModule.h" // deviceName() for the WLED /json/info shim -#include "light/Palette.h" // Palettes::nearestForHue — maps HA's RGB color picker onto our +#include "light/Palette.h" // Palettes::nearestForHue: maps HA's RGB color picker onto our // hue→palette convention (same core→light bridge MqttModule uses // for hsv/set; see the note in MqttModule.cpp:7-14). -#include "light/drivers/Drivers.h" // Drivers::latestSummary() — the real light count/channels for +#include "light/drivers/Drivers.h" // Drivers::latestSummary(): the real light count/channels for // the WLED /json shim (same one-narrow-reach as Palette above). #include "platform/platform.h" #include "ui/ui_embedded.h" @@ -27,9 +27,9 @@ #include #include #include -#include // strtol — bounded Content-Length parse +#include // strtol: bounded Content-Length parse #include // tolower, case-insensitive header names (findHeaderCI) -#include // errno / ERANGE — Content-Length overflow check +#include // errno / ERANGE: Content-Length overflow check #include #include @@ -86,7 +86,7 @@ void HttpServerModule::tick20ms() MM_NONBLOCKING { // Drain the in-flight resumable preview frame on the TRANSPORT-poll cadence (20 ms), NOT the // per-render-tick tick(): pushing frame bytes to the socket must not be charged to the LED // render hot path. The render tick stays free of preview work; the preview frame rate is - // bounded by this 20 ms drain cadence (a few fps at large full-res frames) — an acceptable + // bounded by this 20 ms drain cadence (a few fps at large full-res frames): an acceptable // trade, since the preview is a *view* and the LEDs are not. This drain is the consumer-side // transport step, kept as a standalone call so it sits cleanly on the render/transport seam // (architecture.md § Parallelism). Drain BEFORE accept so a connection burst can't starve an @@ -101,12 +101,12 @@ void HttpServerModule::tick20ms() MM_NONBLOCKING { // from up to ~1 s + drain down to a few tens of ms. No-op in the common (no-resync) case. if (fullResyncPending_) pushStateToWebSockets(); // Read any inbound WS frames: the native WLED app SETS state (its on/off + brightness - // slider) by SENDING a {on,bri} text frame over /ws, not by HTTP POST — so we must read + // slider) by SENDING a {on,bri} text frame over /ws, not by HTTP POST: so we must read // the socket, not only push to it. Cheap (non-blocking, usually nothing pending). pollWledStateFromWebSockets(); // Accept and serve a bounded BATCH of HTTP connections per tick, not one. A browser page-load opens // the HTML + several JS/CSS files + the WS upgrade in parallel (~8 connections); accepting one per - // 20 ms tick drains that burst over ~160 ms and — worse — lets the accept backlog fill and drop the + // 20 ms tick drains that burst over ~160 ms and: worse: lets the accept backlog fill and drop the // slower connections (the WS among them), so the page loads but the clock/preview never start until a // refresh. Draining up to kAcceptsPerTick clears a whole first-load burst in ~2 ticks. It stays bounded // so one tick can't serve an unbounded run of requests (the hot-path rule): accept() returns an invalid @@ -137,7 +137,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { // Read the request. read() is non-blocking (-1 = nothing pending yet), so the render // loop is never stalled waiting for bytes (a blocking socket timeout used to freeze the // whole loop). A just-accepted connection's request normally lands in the same read; if - // not, allow a SHORT bounded wait (≤ ~5 ms total) for it, then bail — an idle/half-open + // not, allow a SHORT bounded wait (≤ ~5 ms total) for it, then bail: an idle/half-open // connection costs at most that, and the steady-state (nothing pending) costs ~0. for (int empties = 0; totalRead < static_cast(sizeof(buf) - 1);) { int n = conn.read(buf + totalRead, sizeof(buf) - 1 - totalRead); @@ -145,12 +145,12 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { totalRead += n; buf[totalRead] = 0; if (std::strstr(reinterpret_cast(buf), "\r\n\r\n")) break; - empties = 0; // got data — reset the patience counter + empties = 0; // got data: reset the patience counter } else if (n == 0) { return; // peer closed } else { // -1 = nothing pending yet - if (totalRead > 0) break; // had a partial then nothing more — process it - if (++empties > 5) break; // fresh conn, no bytes after ~5 ms — give up + if (totalRead > 0) break; // had a partial then nothing more: process it + if (++empties > 5) break; // fresh conn, no bytes after ~5 ms: give up platform::delayMs(1); } } @@ -161,7 +161,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { // If headers arrived but the body is still in flight, read the rest. read() is // non-blocking (-1 = nothing pending yet), so the body can land a TCP segment after the - // headers — wait briefly between empty reads (the same bounded retry as the header + // headers: wait briefly between empty reads (the same bounded retry as the header // phase) instead of breaking on the first -1, which would route a TRUNCATED body into // the permissive JSON helpers (a silent partial control write). If the full declared // body still hasn't arrived within the budget, reject with 400 rather than process it. @@ -173,7 +173,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { if (clh) { hasContentLen = true; // Bounded parse (not atoi): a malformed/negative/overflowing Content-Length must not - // flow downstream, where it's cast to size_t — a negative int would become a huge + // flow downstream, where it's cast to size_t: a negative int would become a huge // length that UploadSource/handleFirmwareUpload would treat as "gigabytes still to // come". We reject anything that isn't a clean unsigned integer: strtol with an end // pointer catches non-numeric, trailing junk ("123abc"), and ERANGE overflow; then we @@ -198,7 +198,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { int headerSize = static_cast(headerEnd + 4 - req); int bodyNeeded = headerSize + contentLen; // Only the STREAMING routes (/api/file, /api/firmware/upload) may carry a body larger than - // buf — they take the buffered prefix and pull the remainder straight off the socket. For + // buf: they take the buffered prefix and pull the remainder straight off the socket. For // every OTHER route the body is parsed whole from buf, so a body over the buffer must be // REJECTED (413), not truncated: a capped read would parse a JSON prefix as if complete // (its own bodyNeeded check wouldn't fire, since the cap makes the short read "enough"). @@ -233,12 +233,12 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { char method[8] = {}; char path[128] = {}; std::sscanf(req, "%7s %127s", method, path); - // Strip any query string before route matching — every strcmp() below + // Strip any query string before route matching: every strcmp() below // expects a bare path. RFC 3986 §3.4: the query starts at the first '?' // and is not part of the path. Browsers send `/?foo=bar` for query-on- // root; without this split the GET / route falls through to 404. The web // installer's Inject button hits us as `/?deviceModel=` to hand off the - // deviceModels.json entry — see docs/moonmodules/core/moxygen/SystemModule.md. + // deviceModels.json entry: see docs/moonmodules/core/moxygen/SystemModule.md. char* queryStart = std::strchr(path, '?'); if (queryStart) *queryStart = 0; @@ -250,7 +250,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { if (std::strcmp(method, "GET") == 0 && (isWs || isWsp) && findHeaderCI(req, "Upgrade: websocket")) { handleWebSocketUpgrade(conn, req, isWsp); - return; // don't close — connection is now a WebSocket + return; // don't close: connection is now a WebSocket } // Read POST body if present @@ -286,7 +286,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { // WLED-compatibility shim: the native WLED apps (and Home Assistant's WLED // integration) discover a device via mDNS `_wled._tcp` then VALIDATE it by // GETting /json/info and checking it's WLED-shaped. Serving a minimal - // WLED-compatible info makes a projectMM device appear in those apps — and is a + // WLED-compatible info makes a projectMM device appear in those apps: and is a // useful independent cross-check that our mDNS advertise resolves. else if (std::strcmp(path, "/json/info") == 0) serveWledInfo(conn); // WLED state + the combined state+info (`/json/si`) the app reads for its device @@ -296,16 +296,16 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { else if (std::strcmp(path, "/json/si") == 0) serveWledStateInfo(conn); // Home Assistant's WLED integration fetches `/json` (the full combined blob, not `/json/si`), // and its Python `wled` library rejects a response missing any of Info.fs, State.nl, - // State.udpn, State.lor — so the `/json/info` + `/json/state` shim (tuned to the WLED Android + // State.udpn, State.lor: so the `/json/info` + `/json/state` shim (tuned to the WLED Android // app's minimal Moshi model) can't answer this endpoint. serveWledDeviceJson writes the // fuller shape python-wled parses; /json/info and /json/state stay minimal (Android-app path). else if (std::strcmp(path, "/json") == 0) serveWledDeviceJson(conn); - // /presets.json — the second endpoint HA's WLED lib fetches after /json (on every state - // update where info.uptime/info.fs.pmt are zero — see python-wled's _check_presets_changed). + // /presets.json: the second endpoint HA's WLED lib fetches after /json (on every state + // update where info.uptime/info.fs.pmt are zero: see python-wled's _check_presets_changed). // If it 404s, python-wled raises WLEDEmptyResponseError and HA's config flow aborts with // HTTP 500. We don't implement WLED presets, so return a TRUTHY-but-empty presets object // (`{"0":{}}`): python-wled's __pre_deserialize__ maps it into `{0: Preset(0)}` then discards - // 0 per its "Nobody cares about 0" rule — result is HA seeing zero presets. `{}` alone would + // 0 per its "Nobody cares about 0" rule: result is HA seeing zero presets. `{}` alone would // fail the `not presets` guard in wled.py; we need a non-empty dict. else if (std::strcmp(path, "/presets.json") == 0) serveWledPresets(conn); else sendResponse(conn, 404, "text/plain", "Not found"); @@ -328,7 +328,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { } else if (std::strcmp(path, "/api/file") == 0 && body) { // File Manager: POST /api/file?path=, the body → streamed atomic write. `body` // points at the bytes already buffered (initialLen); the full length is Content-Length, - // and handleWriteFile pulls any remainder straight off the socket — so an upload of any + // and handleWriteFile pulls any remainder straight off the socket: so an upload of any // size streams to the file without a whole-request buffer or a strlen (binary-safe). const size_t initialLen = static_cast(totalRead) - static_cast(body - req); // No declared length (a chunked client) is 411 Length Required: acting on it would @@ -344,7 +344,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { } else if (std::strcmp(path, "/api/dir") == 0) { // File Manager: POST /api/dir?path= → mkdir. The path is the whole operation (a // create is a filesystem action, not a stored control), so it rides the request query - // — same path-as-query shape as /api/file, no persisted control holds it. + //: same path-as-query shape as /api/file, no persisted control holds it. handleMakeDir(conn, queryStart ? queryStart + 1 : ""); } else if (std::strcmp(path, "/api/modules") == 0 && body) { handleAddModule(conn, body); @@ -355,7 +355,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { } else if (isMoveRoute && body) { char nameBuf[32] = {}; size_t nameLen = pathLen - 13 - 5; // strip "/api/modules/" prefix and "/move" suffix - // Reject rather than truncate — a truncated name could match a + // Reject rather than truncate: a truncated name could match a // different module than the client intended. if (nameLen >= sizeof(nameBuf)) { sendResponse(conn, 400, "application/json", "{\"error\":\"module name too long\"}"); @@ -388,7 +388,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { // where the browser holds the image and re-POSTs it to MoonBase once it answers. handleBootMoonBase(conn); } else if (std::strcmp(path, "/api/firmware/upload") == 0 && body) { - // OTA from an uploaded .bin body (no URL, no host to serve it) — the browser POSTs the + // OTA from an uploaded .bin body (no URL, no host to serve it): the browser POSTs the // firmware image straight to the device, which streams it into the OTA partition. Same // streamed-body handling as /api/file (initial buffered bytes + socket remainder). const size_t initialLen = static_cast(totalRead) - static_cast(body - req); @@ -397,7 +397,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { sendResponse(conn, 404, "text/plain", "Not found"); } } else if (std::strcmp(method, "PATCH") == 0) { - // Editable list: PATCH /api/list/// edits one row — a field + // Editable list: PATCH /api/list/// edits one row: a field // ({"field":F,"value":V}) or a reorder ({"to":N}). PATCH is the REST verb for a // partial update of an existing resource (the row); create is POST, delete is DELETE. if (std::strncmp(path, "/api/list/", 10) == 0 && body) { @@ -434,7 +434,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { // preflight to known API routes; we don't bother because the // device's HTTP surface is tiny and lives behind the user's LAN. // A scanner hitting OPTIONS /random gets a CORS-OK 204 rather - // than a 404 — informational only, no behaviour change. + // than a 404: informational only, no behavior change. sendPreflightResponse(conn); } else { sendResponse(conn, 405, "text/plain", "Method not allowed"); @@ -488,24 +488,24 @@ void HttpServerModule::sendResponse(platform::TcpConnection& conn, int status, c // // A file body isn't a control value, so these are their own small endpoints (not /api/control). // The path comes as a query param `path=`; parseFilePath vets it (reject "..", root at the -// mount) — the single traversal guard shared by every filesystem HTTP entry (read, write, dir +// mount): the single traversal guard shared by every filesystem HTTP entry (read, write, dir // listing, mkdir, delete). // // Read + write both stream: the write pulls the request body chunk-by-chunk straight to the file -// (fsWriteStream), the read pulls the file into a size-fit buffer — so a file of any size up- and +// (fsWriteStream), the read pulls the file into a size-fit buffer: so a file of any size up- and // downloads intact without a fixed cap. kUploadMax is a per-request sanity ceiling; a legit upload // is additionally rejected up front if it wouldn't fit the free filesystem space. -static constexpr size_t kUploadMax = 256 * 1024; // 256 KB — sanity bound on one upload +static constexpr size_t kUploadMax = 256 * 1024; // 256 KB: sanity bound on one upload // Copy the `path=` query value into `out` (decoding %XX and '+' minimally), rooted at the mount. // Returns false on a missing/empty path or a ".." traversal attempt. // // Deliberately NOT a `.config`/dotfile denylist (PO decision): the File Manager is a device-admin // tool on a trusted LAN, and reading the persisted `.config/*.json` is a feature (inspect/back up -// the device's own config), not a leak — there are no third-party secrets on the device, and the +// the device's own config), not a leak: there are no third-party secrets on the device, and the // WiFi password is XOR-obfuscated in what it writes. The weak-protection is `show hidden` defaulting // off (FileManagerModule), so `.config` isn't shown unless the operator asks. Reviewers periodically -// flag this as a secrets-exposure — it's an accepted design, not an oversight; leave it. +// flag this as a secrets-exposure: it's an accepted design, not an oversight; leave it. // See the header for why case-insensitive. MSVC has no strcasestr, so the loop is spelled out. // The textbook header scan: match only at the START of a header line, and stop at the blank line // ending the header section, so neither an X-Prefixed lookalike nor bytes in a buffered body @@ -566,9 +566,9 @@ bool HttpServerModule::parseFilePath(const char* query, char* out, size_t cap) { // --- File Manager directory listing (the /api/dir endpoint) --- // // One directory's children as a JSON array, the source the lazy tree loads a node's children from. -// Single-level only (platform::fsList) — the recursion is the UI's job, one fetch per expanded node, +// Single-level only (platform::fsList): the recursion is the UI's job, one fetch per expanded node, // the standard file-tree shape. The `hidden` query flag (hidden=1) includes dot-prefixed entries. -// The listing streams straight to the socket (as serveState does) — no whole-listing buffer. The +// The listing streams straight to the socket (as serveState does): no whole-listing buffer. The // fsList C callback carries the streaming sink + the hidden filter + a first-row flag via `user`. namespace { struct DirListState { @@ -612,7 +612,7 @@ void HttpServerModule::serveDirListing(platform::TcpConnection& conn, const char // POST /api/dir?path= → mkdir. The path rides the query and is vetted by parseFilePath (the // same `..`-reject + root-at-mount guard /api/file and /api/dir GET use). A create is a filesystem -// action, not a stored control — no persisted `path` control holds it, so no flash write. +// action, not a stored control: no persisted `path` control holds it, so no flash write. void HttpServerModule::handleMakeDir(platform::TcpConnection& conn, const char* query) { char path[160]; if (!parseFilePath(query, path, sizeof(path))) { @@ -660,9 +660,9 @@ void HttpServerModule::streamFsFile(platform::TcpConnection& conn, const char* p const size_t want = static_cast(size - offset) < sizeof(chunk) ? static_cast(size - offset) : sizeof(chunk); const int got = platform::fsReadAt(path, offset, chunk, want); - if (got <= 0) break; // read error / early EOF — the client sees a short (truncated) body + if (got <= 0) break; // read error / early EOF: the client sees a short (truncated) body // write() returns false on a real socket error or its bounded deadline (a stalled client); STOP - // then — retrying every remaining chunk would burn deadline-worth of render-thread time per chunk. + // then: retrying every remaining chunk would burn deadline-worth of render-thread time per chunk. if (!conn.write(reinterpret_cast(chunk), static_cast(got))) return; offset += got; } @@ -694,20 +694,44 @@ void HttpServerModule::serveHlsFile(platform::TcpConnection& conn, const char* n if (dot && std::strcmp(dot, ".m3u8") == 0) mime = "application/vnd.apple.mpegurl"; else if (dot && std::strcmp(dot, ".ts") == 0) mime = "video/mp2t"; else if (dot && (std::strcmp(dot, ".mp4") == 0 || std::strcmp(dot, ".m4s") == 0)) mime = "video/mp4"; + + // RAM first, then the filesystem: the serveFile disk-then-embedded precedent. A platform that + // keeps its segments in memory (the P4) answers here; one whose encoder writes them to disk + // (desktop ffmpeg) declines and the fs path below serves them. + const uint8_t* ram = nullptr; + size_t ramLen = 0; + if (platform::hlsSegment(name, &ram, &ramLen)) { + char header[224]; + const int hn = std::snprintf(header, sizeof(header), + "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %zu\r\n" + "Cache-Control: no-cache\r\nConnection: close\r\n" + "Access-Control-Allow-Origin: *\r\n\r\n", mime, ramLen); + // One write for the body, not streamFsFile's 1 KB loop: that loop exists because it + // reads a KB at a time from the filesystem, and conn.write already sends all bytes + // (platform.h). The segment is a resident RAM buffer, so chunking it would only give + // each piece a fresh deadline. Release on every exit; the platform holds the segment + // reserved until then, and a truncated snprintf must not skip that. + if (hn > 0 && hn < static_cast(sizeof(header)) && + conn.write(reinterpret_cast(header), static_cast(hn))) { + conn.write(ram, ramLen); + } + platform::hlsSegmentRelease(); + return; + } streamFsFile(conn, path, mime, "Cache-Control: no-cache\r\n"); } // Source state for the streamed upload: yields the body bytes already sitting in the request buffer, -// then reads the remainder straight off the socket — feeding fsWriteStream in fixed chunks so the +// then reads the remainder straight off the socket: feeding fsWriteStream in fixed chunks so the // device never holds the whole upload in RAM. namespace { -// This drain runs SYNCHRONOUSLY on the tick20ms() tick, which is inside Scheduler::tick — so it +// This drain runs SYNCHRONOUSLY on the tick20ms() tick, which is inside Scheduler::tick: so it // blocks rendering for the duration of the transfer (LEDs freeze until the upload completes or a // bound trips). Accepted trade-off: an upload is user-initiated and transient (and a firmware upload // reboots the device anyway), so a brief freeze is fine where a persistent one wouldn't be. The two // bounds cap how long that freeze can last, because neither alone is enough: // - kUploadIdleMs: max wait for the NEXT byte, reset on every successful read. Scales to -// any size the endpoint accepts — a big but steady upload (256 KB over slow LittleFS + +// any size the endpoint accepts: a big but steady upload (256 KB over slow LittleFS + // weak WiFi) never trips it, because progress keeps resetting the clock. But idle-only // lets a slowloris trickle one byte just under the idle limit forever, freezing rendering // (and the HTTP server) for as long as it keeps dribbling. @@ -720,15 +744,15 @@ namespace { constexpr uint32_t kUploadIdleMs = 5000; // max gap between successful reads before abort constexpr uint32_t kUploadHardMs = 60000; // absolute whole-request ceiling (anti-slowloris) // A firmware image is MB-scale (1.5+ MB), not the KB-scale of a config file, and pushing it over weak -// WiFi can legitimately take minutes — past kUploadHardMs (60 s), which sized the whole-request cap for +// WiFi can legitimately take minutes: past kUploadHardMs (60 s), which sized the whole-request cap for // a 256 KB file and aborted a real firmware push at ~87%. So the firmware path gets its own larger // ceiling. Sizing: 1.5 MB at a poor-but-real 10 KB/s is ~2.5 min, so 3 min covers any firmware over any -// LAN link with margin — deliberately NOT more, because this cap also bounds the worst-case render +// LAN link with margin: deliberately NOT more, because this cap also bounds the worst-case render // freeze: like the file upload, the firmware drain runs SYNCHRONOUSLY (otaWriteStream loops uploadPull // to completion inside one tick20ms tick), so a slow-but-steady transfer freezes rendering for its whole // duration. kUploadIdleMs (5 s, reset per read) still bounds a *stalled* transfer; this bounds a *slow* // one. The proper fix is the same zero-freeze drain-a-chunk-per-tick pattern drainPreviewSend uses -// (backlogged, see the kUploadHardMs comment above) — until it lands, keep this ceiling as tight as a +// (backlogged, see the kUploadHardMs comment above): until it lands, keep this ceiling as tight as a // real upload allows. A firmware push reboots on success, so the freeze is at least terminal, not a // lingering degradation. constexpr uint32_t kFirmwareUploadHardMs = 180000; // 3 min absolute ceiling for a firmware push @@ -754,9 +778,9 @@ size_t uploadPull(char* out, size_t cap, void* user, bool* abort) { return n; } // Then pull the rest off the socket, bounded by BOTH the per-pull idle deadline (recomputed - // here, only advances while we wait — bounds a stall) and the request-lifetime hardDeadline - // (set once at construction — bounds the total). If the body is still incomplete when the - // socket closes early or either deadline lapses, signal *abort — fsWriteStream then discards + // here, only advances while we wait: bounds a stall) and the request-lifetime hardDeadline + // (set once at construction: bounds the total). If the body is still incomplete when the + // socket closes early or either deadline lapses, signal *abort: fsWriteStream then discards // the temp file rather than committing a truncated upload (a 0 here is NOT a clean end). Both // compares are subtraction-based, wraparound-safe across the ~49.7-day millis() rollover. const size_t want = s->remaining < cap ? s->remaining : cap; @@ -785,7 +809,7 @@ void HttpServerModule::handleWriteFile(platform::TcpConnection& conn, const char return; } // Reject up front if it wouldn't fit the free filesystem space (friendlier than filling the FS - // and failing mid-write — fsWriteStream also fails cleanly + discards the temp if it does fill). + // and failing mid-write: fsWriteStream also fails cleanly + discards the temp if it does fill). // total − used = free. An overwrite would reclaim the old file's space, but treat free // conservatively (don't credit the overwrite) so the check never over-promises. const size_t total = platform::filesystemTotal(); @@ -829,7 +853,7 @@ void HttpServerModule::applyFileChanged(const char* path) { } // OTA from an uploaded .bin body: stream the request body straight into the OTA partition -// (platform::otaWriteStream), reusing the exact uploadPull the file-upload path uses — the only +// (platform::otaWriteStream), reusing the exact uploadPull the file-upload path uses: the only // difference is the sink (OTA partition vs a file). On success the device reboots into the new // image; the 200 goes out first (otaWriteStream's ~600 ms pre-reboot delay covers the round-trip). void HttpServerModule::handleFirmwareUpload(platform::TcpConnection& conn, const char* initialBody, @@ -844,7 +868,7 @@ void HttpServerModule::handleFirmwareUpload(platform::TcpConnection& conn, const return; } const size_t initial = initialLen < contentLen ? initialLen : contentLen; - // Firmware gets the MB-scale ceiling, not the file path's 60 s — a 1.5 MB push over WiFi + // Firmware gets the MB-scale ceiling, not the file path's 60 s: a 1.5 MB push over WiFi // outruns kUploadHardMs and would abort mid-flash (the exact "upload aborted" a real firmware // push hit at ~87%). See kFirmwareUploadHardMs. UploadSource src{&conn, initialBody, initial, contentLen, @@ -852,7 +876,7 @@ void HttpServerModule::handleFirmwareUpload(platform::TcpConnection& conn, const g_otaBytesTotal = static_cast(contentLen); // the UI's "Y KB" (Content-Length up front) g_otaBytesRead = 0; // clear any stale count from a prior OTA // Stream the body into the OTA partition. otaWriteStream commits the image + flips the boot - // pointer but does NOT reboot — it returns so we can send a 200 first, then reboot the same + // pointer but does NOT reboot: it returns so we can send a 200 first, then reboot the same // way /api/reboot does (response, close, brief drain, platform::reboot). That gives the browser // a clean "flashed" response instead of an aborted socket it can't tell from a real failure. const bool ok = platform::otaWriteStream(&uploadPull, &src, contentLen, @@ -867,11 +891,11 @@ void HttpServerModule::handleFirmwareUpload(platform::TcpConnection& conn, const sendResponse(conn, 200, "application/json", "{\"ok\":true}"); conn.close(); platform::delayMs(200); - platform::reboot(); // noreturn — boots the flashed image + platform::reboot(); // noreturn: boots the flashed image } void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* filename, const char* contentType) { - // Try disk first (desktop development — live editing without rebuild) + // Try disk first (desktop development: live editing without rebuild) char filepath[256]; std::snprintf(filepath, sizeof(filepath), "%s/%s", uiPath_, filename); @@ -897,7 +921,7 @@ void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* file size_t toRead = size > static_cast(sizeof(chunk)) ? sizeof(chunk) : static_cast(size); size_t bytesRead = std::fread(chunk, 1, toRead, f); if (bytesRead == 0) break; - // Stop on a write failure (socket error or the bounded deadline for a stalled client) — else + // Stop on a write failure (socket error or the bounded deadline for a stalled client): else // every remaining chunk retries and burns deadline-worth of render-thread time each. if (!conn.write(chunk, bytesRead)) break; size -= static_cast(bytesRead); @@ -908,7 +932,7 @@ void HttpServerModule::serveFile(platform::TcpConnection& conn, const char* file // Fall back to embedded data (ESP32 or when disk files not found). The text // assets are embedded gzipped (see embed_ui.cmake) and served with - // Content-Encoding: gzip — the browser inflates them. gzipped is false only + // Content-Encoding: gzip: the browser inflates them. gzipped is false only // for already-compressed binaries (the PNG), which are embedded raw. const uint8_t* data = nullptr; size_t dataLen = 0; @@ -964,7 +988,7 @@ void HttpServerModule::buildStateJson(JsonSink& sink) { bool first = true; for (uint8_t m = 0; m < scheduler_->moduleCount(); m++) { auto* mod = scheduler_->module(m); - // Skip modules that opt out of the UI via appearsInUi() — the one mechanism for + // Skip modules that opt out of the UI via appearsInUi(): the one mechanism for // "not a card in /api/state": HttpServerModule (the server itself) and FilesystemModule // (a pure persistence engine, no controls) both return false. if (!mod || !mod->appearsInUi()) continue; @@ -977,7 +1001,7 @@ void HttpServerModule::buildStateJson(JsonSink& sink) { sink.append("]}"); } -// FNV-1a 32-bit — a small, fast, recognisable string hash. Used to digest a control's serialised +// FNV-1a 32-bit: a small, fast, recognizable string hash. Used to digest a control's serialized // value (and the leaf's path) for the diff-on-the-wire cache, so the cache holds an 8-byte // {path,value} hash per leaf rather than the value string. Not cryptographic; a hash collision (two // different values, same 32-bit digest) at worst skips ONE update and self-heals on the next change. @@ -987,10 +1011,10 @@ static uint32_t fnv1a(const char* s, size_t len) { return h; } -// The diff-on-the-wire core. Visit every UI leaf the periodic push would send — each module's live -// header telemetry (tickTimeUs / dynamicBytes, which the UI shows per card) and each control's value — +// The diff-on-the-wire core. Visit every UI leaf the periodic push would send: each module's live +// header telemetry (tickTimeUs / dynamicBytes, which the UI shows per card) and each control's value - // in the SAME order buildStateJson emits, so a leaf's path "/" is stable across ticks. -// For each leaf: build its path-hash + a hash of its serialised value; `fn(pathHash, valueHash, path, +// For each leaf: build its path-hash + a hash of its serialized value; `fn(pathHash, valueHash, path, // valueSink)` decides what to do (emit a patch entry, or just (re)baseline the cache). Names are unique // tree-wide (deduplicateNamesInTree at setup/load + ensureUniqueName on every runtime add/replace, // both before the resync that re-baselines), so "/" uniquely identifies a leaf. @@ -1009,7 +1033,7 @@ template void HttpServerModule::visitModuleLeaves(MoonModule* mod, Fn&& fn) { char path[80]; // Module-header telemetry leaves the UI shows live per card. `@` prefixes a header field so it can't - // collide with a control name. Only the fields that actually change per tick (timing/memory) — role, + // collide with a control name. Only the fields that actually change per tick (timing/memory): role, // classSize, enabled are static and ride the full state. auto leaf = [&](const char* fieldPath, const char* valueJson) { JsonSink vs; vs.append(valueJson); @@ -1020,16 +1044,16 @@ void HttpServerModule::visitModuleLeaves(MoonModule* mod, Fn&& fn) { std::snprintf(num, sizeof(num), "%u", static_cast(mod->tickTimeUs())); leaf(path, num); std::snprintf(path, sizeof(path), "%s/@dynamicBytes", mod->name()); std::snprintf(num, sizeof(num), "%u", static_cast(mod->dynamicBytes())); leaf(path, num); - // Status + severity change per tick too — a driver can fault at any moment (a Hue pairing result, a + // Status + severity change per tick too: a driver can fault at any moment (a Hue pairing result, a // loopback verdict, a bus that won't init). They MUST ride the patch: the diff push is the only thing // that runs every second, so a status carried by the full state alone sits stale until an unrelated - // resync — and a module whose card is collapsed behind a tab would surface no fault at all. The + // resync: and a module whose card is collapsed behind a tab would surface no fault at all. The // value-hash gate means an unchanged status costs nothing on the wire. Same wire strings writeStatus // emits (a null status is the empty string, which the UI treats as "no status"). { JsonSink sv; // writeJsonString ALREADY emits the surrounding quotes (and escapes). Wrapping it in manual - // quotes double-quoted the value (`""driving…""`), which is invalid JSON — the browser rejected + // quotes double-quoted the value (`""driving…""`), which is invalid JSON: the browser rejected // the WHOLE patch frame, so the @status change it carried never applied (the UI only updated on a // manual /api/state refresh). A status with no special chars just happened to look fine in the // full-state path; the patch is where it broke. One writeJsonString, no manual quotes. @@ -1052,14 +1076,14 @@ void HttpServerModule::visitModuleLeaves(MoonModule* mod, Fn&& fn) { JsonSink vs; writeControlValue(vs, c); fn(fnv1a(path, std::strlen(path)), fnv1a(vs.data(), vs.size()), path, vs); } - // `fn`, not `std::forward(fn)` — same reason as the caller above: forwarding inside a + // `fn`, not `std::forward(fn)`: same reason as the caller above: forwarding inside a // loop moves the callable into the first child, leaving every later sibling a moved-from one. for (uint8_t i = 0; i < mod->childCount(); i++) if (auto* ch = mod->child(i)) visitModuleLeaves(ch, fn); } // Look up a leaf's cached value-hash by path-hash; returns nullptr if not yet seen. Linear over the -// flat cache — the tree is ~92 leaves, so this is a handful of int compares per leaf (cheap, no map). +// flat cache: the tree is ~92 leaves, so this is a handful of int compares per leaf (cheap, no map). HttpServerModule::LeafHash* HttpServerModule::findLeaf(uint32_t pathHash) { for (uint16_t i = 0; i < leafHashCount_; i++) if (leafHashes_[i].path == pathHash) return &leafHashes_[i]; @@ -1084,9 +1108,9 @@ uint16_t HttpServerModule::buildStatePatch(JsonSink& sink) { uint16_t changed = 0; forEachStateLeaf([&](uint32_t ph, uint32_t vh, const char* path, JsonSink& vs) { LeafHash* h = findLeaf(ph); - if (h && h->value == vh) return; // unchanged — the common case, emit nothing + if (h && h->value == vh) return; // unchanged: the common case, emit nothing if (h) h->value = vh; // known leaf, value changed → update cache - // A leaf NOT in the baseline means the tree grew without a re-baseline — which can't happen on + // A leaf NOT in the baseline means the tree grew without a re-baseline: which can't happen on // any real path: every structural mutation calls requestFullResync() → baselineLeafHashes() // before the next patch, so the baseline always covers the current tree. We therefore do NOT // try to grow the cache here: ScratchBuffer::resize is non-preserving (frees + reallocs), so a @@ -1097,7 +1121,7 @@ uint16_t HttpServerModule::buildStatePatch(JsonSink& sink) { sink.append("{\"path\":\""); sink.append(path); sink.append("\",\"value\":"); - sink.append(vs.data()); // the already-serialised value + sink.append(vs.data()); // the already-serialized value sink.append("}"); }); sink.append("]}"); @@ -1121,7 +1145,7 @@ void HttpServerModule::writeModuleJson(JsonSink& sink, MoonModule* mod) { static_cast(mod->classSize()), static_cast(mod->dynamicBytes())); writeStatus(sink, mod); - // userEditable: omit when true (the common case) to save bytes — the UI + // userEditable: omit when true (the common case) to save bytes: the UI // treats absent as editable, same convention as the control hidden/readonly // flags. Emitted only for modules that opt out (e.g. PreviewDriver), so the // UI hides their delete/replace affordance. @@ -1145,14 +1169,14 @@ void HttpServerModule::writeModuleJson(JsonSink& sink, MoonModule* mod) { } void HttpServerModule::writeStatus(JsonSink& sink, MoonModule* mod) { - // Only emit when the module has a status — keeps the common case lean. + // Only emit when the module has a status: keeps the common case lean. // Severity strings are stable wire format: "status", "warning", "error" // (matches the C++ enum names lowercased; documented in HttpServerModule.md). const char* s = mod->status(); if (!s) return; static const char* sevStr[] = {"status", "warning", "error"}; // Escape the status value through writeJsonString (it emits its own quotes) rather than a raw %s in - // manual quotes — a status with a `"` or `\` would otherwise produce invalid JSON. Severity is a fixed + // manual quotes: a status with a `"` or `\` would otherwise produce invalid JSON. Severity is a fixed // vocabulary (no special chars), so it stays a plain %s. Mirrors the patch path (@status leaf), which // hit exactly this: a manually-quoted value broke the frame. See writeMetricsPatch. sink.append(",\"status\":"); @@ -1168,7 +1192,7 @@ void HttpServerModule::writeControls(JsonSink& sink, MoonModule* mod) { // Common wrapper for every control: {"name":...,"type":...,"value":VALUE,EXTRAS,"hidden":?} // Per-type VALUE + EXTRAS rendering lives in Control.cpp so the // wire format isn't duplicated across HttpServer/FS/scenario. - // Password is the one exception — its API serialization XOR-obfuscates + + // Password is the one exception: its API serialization XOR-obfuscates + // base64-encodes (writeControlValue emits plaintext, which is what // FilesystemModule's writeValue wants); handle it here in-line so // writeControlValue stays sink-neutral. @@ -1178,8 +1202,8 @@ void HttpServerModule::writeControls(JsonSink& sink, MoonModule* mod) { // The password is sent XOR-obfuscated + base64-encoded, NOT // in plaintext. This is deliberate obfuscation, not security: // the XOR key is a fixed shared constant (also in app.js), so - // anyone can reverse it. It is a first line of defence — the - // value is not readable at a glance in `curl /api/state` — and + // anyone can reverse it. It is a first line of defense: the + // value is not readable at a glance in `curl /api/state`: and // it lets the UI's hold-to-peek reveal the stored password. const char* pw = static_cast(c.ptr); uint8_t scrambled[64]; @@ -1221,13 +1245,13 @@ void HttpServerModule::writeControls(JsonSink& sink, MoonModule* mod) { } // Apply-core: set one control's value. `valueJson` is a small JSON object holding -// the value under the "value" key ({"value":8}) — the same body the HTTP handler +// the value under the "value" key ({"value":8}): the same body the HTTP handler // receives, so applyControlValue (which reads by key) is reused verbatim. Transport- // 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) { // The generic control-set is a Scheduler primitive (it owns the tree + persistence hook), - // shared with every other control writer — Improv, the WLED bridge, IrService. This wrapper + // shared with every other control writer: Improv, the WLED bridge, IrService. 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)) { @@ -1245,7 +1269,7 @@ HttpServerModule::OpResult HttpServerModule::applySetControl( } void HttpServerModule::handleSetControl(platform::TcpConnection& conn, const char* body) { - // Parse: {"module":"Noise","control":"scale","value":8} — the apply-core reads + // Parse: {"module":"Noise","control":"scale","value":8}: the apply-core reads // the value out of `body` itself (so it sees the exact same JSON the API got). char moduleName[32] = {}; char controlName[32] = {}; @@ -1279,7 +1303,7 @@ void HttpServerModule::handleSetControl(platform::TcpConnection& conn, const cha // The Scheduler owns the module tree, so the tree-walk-by-name lives there (firstByName); // this only adds the scheduler_ null-guard the request handlers rely on (scheduler_ is unset -// until setScheduler() runs), then delegates — one recursive lookup, not two. +// until setScheduler() runs), then delegates: one recursive lookup, not two. MoonModule* HttpServerModule::findModuleByName(const char* name) { return scheduler_ ? scheduler_->firstByName(name) : nullptr; } @@ -1294,7 +1318,7 @@ void HttpServerModule::serveSystem(platform::TcpConnection& conn) { conn.write(reinterpret_cast(header), std::strlen(header)); JsonSink sink(conn); - // maxBlock = internal-only (maxInternalAllocBlock) — the all-memory + // maxBlock = internal-only (maxInternalAllocBlock): the all-memory // variant reports ~8 MB on PSRAM boards and is meaningless as a // pressure signal. Same rationale as main.cpp's tick log line. sink.appendf( @@ -1318,15 +1342,15 @@ void HttpServerModule::serveSystem(platform::TcpConnection& conn) { sink.flush(); } -// WLED-compatibility `/json/info` — the subset of WLED's info object the native WLED +// WLED-compatibility `/json/info`: the subset of WLED's info object the native WLED // apps + Home Assistant validate when they probe a device they discovered via // `_wled._tcp`. The clients gate on a WLED-shaped identity: `brand:"WLED"`, a real // `vid` (build id; they reject 0), a WLED-major `ver`, and `leds.count`. We declare -// `brand:"WLED"` because the apps key on it — the same thing WLED-MM (the MoonModules -// WLED fork) does — while `product:"MoonModules"` says what this actually is. We speak +// `brand:"WLED"` because the apps key on it: the same thing WLED-MM (the MoonModules +// WLED fork) does: while `product:"MoonModules"` says what this actually is. We speak // WLED's info shape to interoperate, not to impersonate. Built fresh against WLED's // public JSON, not copied. (Reference real WLED carries far more; this is the trimmed, -// known-sufficient field set — see docs/moonmodules/core/moxygen/HttpServerModule.md.) +// known-sufficient field set: see docs/moonmodules/core/moxygen/HttpServerModule.md.) void HttpServerModule::serveWledInfo(platform::TcpConnection& conn) { const char* header = "HTTP/1.1 200 OK\r\n" @@ -1343,11 +1367,11 @@ void HttpServerModule::serveWledInfo(platform::TcpConnection& conn) { // Field set reverse-engineered from the WLED-Android app's `Info` Moshi model // (model/wledapi/Info.kt): the ONLY non-nullable fields it requires are `name`, `leds` - // (object), and `wifi` (object) — a missing one fails the JSON parse and the device is + // (object), and `wifi` (object): a missing one fails the JSON parse and the device is // silently dropped. `DeviceFirstContactService.kt` additionally rejects a device whose // body `mac` is empty. Every other field in the model is nullable. So this is the // minimal object the native app accepts: name + leds{} + wifi{} + a non-empty mac. The - // inner Leds/Wifi fields are themselves all nullable, so empty `{}` objects parse — we + // inner Leds/Wifi fields are themselves all nullable, so empty `{}` objects parse: we // send a real `mac` and otherwise the smallest shapes that satisfy the parser. `brand`/ // `product` identify us as the MoonModules WLED-compatible product (interoperate, not // impersonate). Confirmed on the bench: projectMM devices list in the WLED native app. @@ -1359,13 +1383,13 @@ void HttpServerModule::serveWledInfo(platform::TcpConnection& conn) { // See header. Extracts the deviceName / IP / MAC lookup the WLED shim needs at four // call sites (/json/info, /json/state /json/si, /json), so a future change to how identity // is discovered updates one place. -// /presets.json — the device's LOOK presets in WLED's format, so Home Assistant's WLED integration +// /presets.json: the device's LOOK presets in WLED's format, so Home Assistant's WLED integration // shows them in its preset dropdown (its native preset support, unlike the MQTT path where the same // presets ride as "effects"). // // Format: an object keyed by preset SLOT, each holding at least a name `n`. Slot 0 is reserved // ("Nobody cares about 0" in python-wled, which discards it), so slots are emitted 1-based. The -// object must be non-empty or python-wled's `not presets` guard treats the response as a failure — +// object must be non-empty or python-wled's `not presets` guard treats the response as a failure - // hence the `{"0":{}}` floor when the device has no looks yet. // // Only look presets appear: a Drivers or Layouts preset rewires pins or geometry, which must not be @@ -1411,7 +1435,7 @@ void HttpServerModule::resolveWledIdentity(const char*& name, uint8_t mac[6], ui // /json/info and the `info` half of /json/si. // Emit the WLED `name` field with the 💫 projectMM marker prefixed, so a projectMM board stands out // among plain WLED devices in Home Assistant's device list (which keys everything off the WLED -// integration). The marker lives ONLY in the WLED-compat name HA reads — the real deviceName (UI, +// integration). The marker lives ONLY in the WLED-compat name HA reads: the real deviceName (UI, // mDNS hostname, MQTT topics) stays unprefixed, so identity/hostnames carry no emoji. writeJsonString // owns the quotes + escaping; the marker is a plain UTF-8 literal that passes through unescaped. void HttpServerModule::writeWledName(JsonSink& sink, const char* name) { @@ -1437,12 +1461,12 @@ void HttpServerModule::writeWledInfoBody(JsonSink& sink, const char* name, const } // The WLED state object, written into an open sink. `on` + `bri` mirror Drivers on/brightness. -// `seg[0].col[0]` reports the ACTIVE PALETTE's identity color, not the live first-LED — so +// `seg[0].col[0]` reports the ACTIVE PALETTE's identity color, not the live first-LED: so // every WLED consumer (the WLED native app's device card, HA's WLED integration color picker, // Homebridge's HSV via the MQTT pair, the /ws push) sees the same stable palette-representative // value and matches the palette-picker → RGB round-trip. Live first-LED was tried first and // dropped: it dimmed the picker under low master brightness (near-black) and jittered with the -// effect animation ("the picked color moves" — user report). `Palettes::representativeRgb` +// effect animation ("the picked color moves": user report). `Palettes::representativeRgb` // returns V=255, so brightness stays HA's `state.bri × seg.bri` responsibility and doesn't // double-dim. Rationale for the seg[0].on / seg[0].bri fields lives inline below. void HttpServerModule::writeWledStateBody(JsonSink& sink) { @@ -1450,14 +1474,14 @@ void HttpServerModule::writeWledStateBody(JsonSink& sink) { const RGB pc = Palettes::representativeRgb(driversPalette(scheduler_)); // nl/udpn/lor/transition/ps/pl/mainseg are additive to the Android-app minimum (Moshi ignores // unknown/extra fields), and REQUIRED for HA's WLED integration: `python-wled` parses the POST - // /json/state response through State.from_dict too — the same required-fields contract as /json. + // /json/state response through State.from_dict too: the same required-fields contract as /json. // Without them, HA `light.turn_on` succeeds on the device but the response parse raises, which HA // wraps as HTTP 500 on `services/light/turn_on`. nl/udpn as empty objects satisfy the parser via // their dataclass defaults; lor=0 is LiveDataOverride.OFF. // seg[0].on MUST be present: HA WLED's is_on for a WLEDSegmentLight reads // state.segments[].on (light.py:244), NOT top-level state.on. Without it, // python-wled parses segment.on as its dataclass default None, `bool(None)` is - // False, and HA's UI shows the light off even when the device is on — the + // False, and HA's UI shows the light off even when the device is on: the // "brightness/color work but the toggle doesn't" symptom pinned on the bench. const char* onStr = driversOn(scheduler_) ? "true" : "false"; // seg[0].pal = the active palette index, so HA's WLED integration highlights the current entry @@ -1486,17 +1510,17 @@ void HttpServerModule::writeWledStateBody(JsonSink& sink) { // from state.segments[].brightness (light.py's _attr_brightness), NOT top-level // state.bri. Without it python-wled parses segment.brightness as the dataclass default // 0, so HA renders the slider at zero even when the device is at full. Same on-the- - // bench root-cause as seg[0].on — HA's SegmentLight class reads *segment* fields. - // seg[0].bri = 255 (segment is 100% of master), state.bri = actual — the real WLED + // bench root-cause as seg[0].on: HA's SegmentLight class reads *segment* fields. + // seg[0].bri = 255 (segment is 100% of master), state.bri = actual: the real WLED // convention. HA WLEDSegmentLight with the default has_main_light=False computes // (segment.bri × state.bri) / 255 (coordinator.py + light.py:220-222), so sending // 255 in the segment lets HA render the actual master value. Sending `bri` in both - // would show bri²/255 instead — verified against ha-core wled/coordinator.py. + // would show bri²/255 instead: verified against ha-core wled/coordinator.py. // fx=0 accompanies pal: a real WLED segment always reports BOTH the effect and the - // palette index, and python-wled's Segment model (HA's WLED integration) pairs them — + // palette index, and python-wled's Segment model (HA's WLED integration) pairs them - // sending pal without fx yields a half-populated segment real WLED never produces, and // HA's light-platform setup then leaves the light entity stuck `restored`/unavailable - // (the sensors still work — only the segment-derived light breaks). fx=0 = "Solid", the + // (the sensors still work: only the segment-derived light breaks). fx=0 = "Solid", the // single effect this shim exposes (fxcount=1), so the pair is consistent. "\"seg\":[{\"id\":0,\"on\":%s,\"bri\":255,\"fx\":0,\"pal\":%u,\"col\":[[%u,%u,%u]]}]}", onStr, bri, currentPs, onStr, pal, pc.r, pc.g, pc.b); @@ -1512,10 +1536,10 @@ void HttpServerModule::serveWledState(platform::TcpConnection& conn) { sink.flush(); } -// /json — the FULL combined blob Home Assistant's WLED integration fetches (frenck/python-wled). The +// /json: the FULL combined blob Home Assistant's WLED integration fetches (frenck/python-wled). The // crucial deltas from /json/si (which targets the WLED Android app's minimal Moshi model): python-wled // requires `info.fs` (Filesystem), `state.nl` (Nightlight), `state.udpn` (UDPSync), and `state.lor` -// (LiveDataOverride) — every other field carries a default in the dataclass and is optional. We also +// (LiveDataOverride): every other field carries a default in the dataclass and is optional. We also // send `ver >= "0.14.0"` because python-wled's __pre_deserialize__ raises WLEDUnsupportedVersionError // on anything below (skipped only when `ver` is absent, but sending it makes HA's update-badge behave). // `effects` and `palettes` each carry one entry so HA renders a one-option picker rather than none. @@ -1533,27 +1557,27 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { resolveWledIdentity(name, mac, ip); JsonSink sink(conn); - // state — writeWledStateBody emits the {on,bri,seg,...} block reused by /json/state and + // state: writeWledStateBody emits the {on,bri,seg,...} block reused by /json/state and // /json/si; wrap it under "state":. Keeping one authoritative writer avoids the two paths // drifting on which seg[0] fields HA actually reads. sink.appendf("{\"state\":"); writeWledStateBody(sink); - // info — `ver` is a sentinel `"99.0.0"`, NOT the projectMM semver. Reason: HA's WLED + // info: `ver` is a sentinel `"99.0.0"`, NOT the projectMM semver. Reason: HA's WLED // integration parses WLED tags as CalVer (`16.0.1` is year-16, not `0.16.1`), so a // projectMM semver like `2.1.0-dev` compares LOWER than WLED's current `16.0.1` (2 < 16) // and HA flags a bogus "update to WLED 16.0.1" whose `.bin` would brick a projectMM // device. `AwesomeVersion("99.0.0") > AwesomeVersion("")` in the CalVer // regime, so HA's WLED update-check is always silent for us. First tried `mm::kVersion` - // (assuming SemVer parsing) — the bench P4 showed HA still flagging 16.0.1 after the flash + // (assuming SemVer parsing): the bench P4 showed HA still flagging 16.0.1 after the flash // because the CalVer branch was the actual one taken. Real projectMM version lives on the // MQTT `update/state` topic (`installed_version` under the HA update entity), which is where - // "did projectMM ship a new release" belongs — the WLED shim is for the LIGHT ENTITY, not + // "did projectMM ship a new release" belongs: the WLED shim is for the LIGHT ENTITY, not // the firmware version. `arch`/`brand`/`product`/`mac`/`ip` populate HA's device card // (mf/mdl/sw_version rendered from these); `leds`/`wifi`/`fs` are the objects python-wled's // Info dataclass requires or expects for the sensor entities (heap, uptime, signal). // Real values for the diagnostic sensors HA renders from the `wifi` + `freeheap` blocks. // signal maps rssi→0-100 the way WLED does (0 at -100 dBm, 100 at -50 dBm); bssid/channel come - // from the associated AP. On an ETHERNET device there is no Wi-Fi AP, so these read 0/empty — and + // from the associated AP. On an ETHERNET device there is no Wi-Fi AP, so these read 0/empty: and // `info.wifi` is Optional in python-wled, so we OMIT the whole `wifi` object rather than send a // zeroed one. HA then creates no Wi-Fi sensors for an eth device (a real WLED-on-eth behaves the // same), instead of the greyed "Wi-Fi RSSI/BSSID/channel/signal" rows an all-zero block produces. @@ -1582,7 +1606,7 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { // capability bitmask (1 = RGB), then LIGHT_CAPABILITIES_COLOR_MODE_MAPPING[seglc[0]] // gives the color mode. Putting the LED count here (e.g. seglc:[24]) has no mapping, // so WLEDSegmentLight ends up with NO supported color modes and HA refuses to add the - // light entity ("does not set supported color modes") — it stays `restored`/unavailable + // light entity ("does not set supported color modes"): it stays `restored`/unavailable // while the sensors still work. seglc is therefore the constant 1, matching lc; the LED // count lives only in `count`. fps = the real render rate (scheduler_->fps()). "\"leds\":{\"count\":%u,\"fps\":%u,\"rgbw\":%s,\"wv\":false,\"cct\":false," @@ -1590,7 +1614,7 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { mac[0], mac[1], mac[2], mac[3], mac[4], mac[5], ip[0], ip[1], ip[2], ip[3], ledCount, renderFps, rgbw); - // wifi — only for a Wi-Fi device (omitted on Ethernet; see the comment above the getters). + // wifi: only for a Wi-Fi device (omitted on Ethernet; see the comment above the getters). if (!onEth) { sink.appendf("\"wifi\":{\"bssid\":\"%02x:%02x:%02x:%02x:%02x:%02x\"," "\"rssi\":%d,\"channel\":%d,\"signal\":%d},", @@ -1603,10 +1627,10 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { // figure when the platform has no meaningful number rather than lying about a real one. // pmt is the presets-modified time, and it is how Home Assistant decides whether to re-fetch // /presets.json. A CONSTANT here means HA keeps the copy it took at setup forever, so a preset - // saved, renamed or deleted afterwards never appears — the endpoint was already dynamic, but + // saved, renamed or deleted afterwards never appears: the endpoint was already dynamic, but // nothing ever asked it again. ControlModule stamps this whenever the preset set changes; 1 is // the fallback for a build with no ControlModule, preserving the previous stable-since-boot - // behaviour rather than forcing a re-fetch on every state update. + // behavior rather than forcing a re-fetch on every state update. unsigned pmt = 1; if (auto* control = static_cast(findModuleByName("Control"))) pmt = static_cast(control->presetsRevision()); // >= 1 once setup's rescan ran @@ -1620,8 +1644,8 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { // unsupported in this build. Its __post_deserialize__ maps -1 to None, and its // coordinator falls back to HTTP polling. Sending 0 (the WLED convention for // "supported, no clients yet") makes HA open a WS to our own /ws endpoint, which - // serves projectMM-native state frames — not the WLED-shaped Info+State updates the - // python-wled parser requires — and floods HA's log with `MissingField: filesystem` + // serves projectMM-native state frames: not the WLED-shaped Info+State updates the + // python-wled parser requires: and floods HA's log with `MissingField: filesystem` // on every frame. Fix pinned on the bench with `sudo docker logs homeassistant`. "\"lm\":\"\",\"lip\":\"\",\"ws\":-1," // palcount = the real built-in count (matches the palettes[] array below); fxcount @@ -1633,18 +1657,18 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { static_cast(platform::freeHeap() ? platform::freeHeap() : 32768u), static_cast(platform::millis() / 1000u), static_cast(mm::palettes::kCount)); - // effects + palettes — python-wled's __pre_deserialize__ turns each array into an indexed dict. + // effects + palettes: python-wled's __pre_deserialize__ turns each array into an indexed dict. // effects stays one real entry ("Solid"): this shim drives a single Layer, so a longer effect list // would be a lie. palettes is the REAL built-in list (Palette.h paletteNames / kBuiltins) so HA's // palette dropdown offers every palette the device has, indexed to match seg[0].pal and the Drivers - // `palette` control — the same one-narrow-reach into light/ that the representative color uses. + // `palette` control: the same one-narrow-reach into light/ that the representative color uses. sink.appendf(",\"effects\":[\"Solid\"],\"palettes\":["); mm::paletteNames(sink); sink.appendf("]}"); sink.flush(); } -// /json/si — the combined {state, info} the WLED app reads in one call for its card. +// /json/si: the combined {state, info} the WLED app reads in one call for its card. void HttpServerModule::serveWledStateInfo(platform::TcpConnection& conn) { const char* header = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n" @@ -1694,7 +1718,7 @@ void HttpServerModule::applyWledState(const char* body) { } // WLED palette: seg[0].pal is the palette index. HA's WLED integration writes here when a user // picks from the palette dropdown (the entries served by paletteNames in /json). It maps straight - // to the Drivers `palette` control — the direct-index counterpart to the col[] nearest-match below; + // to the Drivers `palette` control: the direct-index counterpart to the col[] nearest-match below; // both feed the same control, so the dropdown and the color picker stay one value. Parsed from the // segment object so a top-level stray "pal" can't hijack it. const char* segStart = std::strstr(body, "\"seg\":"); @@ -1710,7 +1734,7 @@ void HttpServerModule::applyWledState(const char* body) { // WLED color: seg[0].col[0] is [r,g,b]. HA's WLED integration writes here when a user picks a // color in the RGB picker. Palettes::nearestForRgb is the canonical RGB→palette entry (see the // comment at its declaration): it applies the same RGB→(hue,sat) conversion representativeHueSat - // uses on the palette side, then runs the 2D-distance sweep. Value channel is ignored — HA's own + // uses on the palette side, then runs the 2D-distance sweep. Value channel is ignored: HA's own // brightness slider handles bri via the `bri` field above. const char* colStart = std::strstr(body, "\"col\":[["); if (colStart) { @@ -1727,7 +1751,7 @@ void HttpServerModule::applyWledState(const char* body) { } } -// POST /json/state — the WLED app's HTTP control channel (its system quick-tiles + Home +// POST /json/state: the WLED app's HTTP control channel (its system quick-tiles + Home // Assistant). Apply, then echo the resulting state (the app expects a State response). void HttpServerModule::handleWledState(platform::TcpConnection& conn, const char* body) { applyWledState(body); @@ -1759,17 +1783,17 @@ HttpServerModule::OpResult HttpServerModule::applyAddModule( if (!typeName || typeName[0] == 0) return OpResult::BadRequest; // Top-level modules (Layouts/Effects/Drivers/Filesystem/System/Network/HttpServer) - // are policy-fixed and wired in main.cpp at boot. Only *child* adds are allowed — + // are policy-fixed and wired in main.cpp at boot. Only *child* adds are allowed - // anything else would orphan the module (never ticked, leaked). if (!parentId || parentId[0] == 0) return OpResult::BadRequest; - // Idempotent: an existing module with this name is success, not an error — so a + // Idempotent: an existing module with this name is success, not an error: so a // re-run of the catalog inject (or a double APPLY_OP) is a no-op, not a dup. The // distinct AlreadyExists (vs Ok) lets the HTTP handler report "already exists" so a // client can tell created-now from already-there; both are success. if (id && id[0] != 0 && findModuleByName(id)) return OpResult::AlreadyExists; - // Resolve the parent before allocating — failure means we never make an orphan. + // Resolve the parent before allocating: failure means we never make an orphan. auto* parent = findModuleByName(parentId); if (!parent) return OpResult::ModuleNotFound; @@ -1782,7 +1806,7 @@ HttpServerModule::OpResult HttpServerModule::applyAddModule( return OpResult::BadRequest; // parent rejected the child } - // Disambiguate a colliding name (a second "Layer" etc.) — same pass the Scheduler + // Disambiguate a colliding name (a second "Layer" etc.): same pass the Scheduler // runs after persistence load; single source of truth. if (scheduler_) scheduler_->ensureUniqueName(mod); @@ -1813,7 +1837,7 @@ void HttpServerModule::handleAddModule(platform::TcpConnection& conn, const char // The created module's final name (post-disambiguation) rides back in the response so the UI can // select + focus the new module. A client-supplied `id` can contain any character (parseString - // decodes \" and \\), so the name is NOT quote-safe — escape it through JsonSink::writeJsonString + // decodes \" and \\), so the name is NOT quote-safe: escape it through JsonSink::writeJsonString // (which emits its own quotes) rather than a raw %s, the same precedent as the module-status // serialize above. A raw %s with a name containing a `"` would produce invalid JSON. char createdName[32] = {}; @@ -1847,7 +1871,7 @@ void HttpServerModule::handleAddModule(platform::TcpConnection& conn, const char } // Apply-core: DELETE every user-editable child of `parentName` (the catalog -// inject's replaceChildren — an entry's effects replace the boot defaults instead +// inject's replaceChildren: an entry's effects replace the boot defaults instead // of stacking). Same removeChild → release → deleteTree the HTTP delete does. // Code-wired children (Preview, Improv) are left in place; they aren't what a // catalog entry replaces. Transport-free. @@ -1875,16 +1899,16 @@ HttpServerModule::OpResult HttpServerModule::applyClearChildren(const char* pare } // Apply-core dispatcher: one REST op as a JSON object. This is the wire shape the -// Improv APPLY_OP frame carries — "REST over serial". The op is a small flat object: +// Improv APPLY_OP frame carries: "REST over serial". The op is a small flat object: // {"op":"add","type":"...","id":"...","parent":"..."} // {"op":"set","module":"...","control":"...","value":...} // {"op":"clearChildren","parent":"..."} // For "set" the whole op JSON is handed to applySetControl, which reads "value" by -// key — the same way the HTTP /api/control handler reads it from the request body, +// key: the same way the HTTP /api/control handler reads it from the request body, // so any value type rides through unchanged. // The wire shape the Improv APPLY_OP frame carries. NOTE the serial op's add uses the // key "parent", while the HTTP POST /api/modules body uses "parent_id" for the same -// field — both feed the one applyAddModule() core, but the two transports parse different +// field: both feed the one applyAddModule() core, but the two transports parse different // JSON keys, so an HTTP payload is NOT a drop-in APPLY_OP (rename parent_id → parent). The // serial op stays terse because every byte counts against the 128-byte frame budget; the // discrepancy is documented in docs/moonmodules/core/moxygen/ImprovProvisioningModule.md. @@ -1921,7 +1945,7 @@ void HttpServerModule::handleDeleteModule(platform::TcpConnection& conn, const c } // Top-level modules (Layouts/Effects/Drivers/Filesystem/System/Network/HttpServer) - // have no parent — they're registered via Scheduler::addModule in main.cpp and the + // have no parent: they're registered via Scheduler::addModule in main.cpp and the // top-level shape is policy-fixed. Reject the delete here instead of release+delete'ing // a module that the scheduler still holds a pointer to (which would dangle on next tick). auto* parent = mod->parent(); @@ -1931,7 +1955,7 @@ void HttpServerModule::handleDeleteModule(platform::TcpConnection& conn, const c } // Non-editable submodules (Board, Preview, Improv) are apparatus, not - // swappable pipeline content — refuse here so the API enforces it, not just + // swappable pipeline content: refuse here so the API enforces it, not just // the UI's hidden delete button. They can still be disabled via their enable // toggle; they just can't be removed from the tree. if (!mod->userEditable()) { @@ -1952,7 +1976,7 @@ void HttpServerModule::handleDeleteModule(platform::TcpConnection& conn, const c if (scheduler_) scheduler_->requestPrepareTree(); requestFullResync(); // structural change (see requestFullResync) - // Persist the new tree shape — marking the parent dirty rewrites its file + // Persist the new tree shape: marking the parent dirty rewrites its file // without the deleted child slot. The parent is guaranteed non-null by the // top-of-function check (top-level deletes are rejected as 400). parent->markDirty(); @@ -1972,7 +1996,7 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const sendResponse(conn, 400, "application/json", "{\"error\":\"top-level modules cannot be replaced\"}"); return; } - // Non-editable submodules (Board, Preview, Improv) are apparatus — replacing + // Non-editable submodules (Board, Preview, Improv) are apparatus: replacing // one swaps it for a different type, which is as much a removal as a delete. // Refuse, mirroring handleDeleteModule's guard, so the editability contract // holds across both endpoints. @@ -1998,7 +2022,7 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const return; } - // Create the replacement before touching the tree — if the factory fails, + // Create the replacement before touching the tree: if the factory fails, // return early and leave the tree intact (never leave a hole). auto* fresh = ModuleFactory::create(typeName); if (!fresh) { @@ -2010,24 +2034,24 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const // user-renamed slot) so callers can keep addressing the slot by it. But if // the old name was just the old type's factory display name ("Multiply" for // a MultiplyModifier), let the fresh module keep its own factory name - // ("Checkerboard") — otherwise a Multiply→Checkerboard replace leaves a + // ("Checkerboard"): otherwise a Multiply→Checkerboard replace leaves a // Checkerboard mislabelled "Multiply". `fresh` already arrives with its // correct default name from ModuleFactory::create, so we only override for a // custom name; then re-run uniqueness so two same-type siblings don't collide. const char* oldDefault = ModuleFactory::displayNameFor(mod->typeName(), mod->role()); if (std::strcmp(mod->name(), oldDefault) != 0) { - fresh->setName(mod->name()); // custom name — preserve the slot identity + fresh->setName(mod->name()); // custom name: preserve the slot identity } // Swap in place; replaceChildAt returns the old module, which we own. MoonModule* old = parent->replaceChildAt(index, fresh); - // Lifecycle on the fresh module — same phase order as the add path. + // Lifecycle on the fresh module: same phase order as the add path. fresh->defineControls(); fresh->setup(); fresh->applyState(); - // Tear down the old subtree (release + recursive delete) — same pair + // Tear down the old subtree (release + recursive delete): same pair // FilesystemModule::applyNode uses; a bare delete would leak its children. if (old) { old->release(); @@ -2042,7 +2066,7 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const if (scheduler_) scheduler_->ensureUniqueName(fresh); // Re-run prepare across the tree so Layer LUT / Drivers buffer - // wiring re-forms — a replaced effect/driver re-wires like a freshly added one. + // wiring re-forms: a replaced effect/driver re-wires like a freshly added one. if (scheduler_) scheduler_->requestPrepareTree(); requestFullResync(); // structural change (see requestFullResync) @@ -2056,7 +2080,7 @@ void HttpServerModule::handleReplaceModule(platform::TcpConnection& conn, const void HttpServerModule::serveModule(platform::TcpConnection& conn, const char* name) { // Percent-decode into a bounded buffer: a module name may contain a space ("File Manager"), - // which a browser sends as %20. Same decoding parseFilePath does, over a name-sized buffer — + // which a browser sends as %20. Same decoding parseFilePath does, over a name-sized buffer - // MoonModule::name_ is 16 bytes, so anything longer cannot match a module anyway. char decoded[24] = {}; size_t i = 0; @@ -2185,12 +2209,12 @@ void HttpServerModule::handleMoveModule(platform::TcpConnection& conn, const cha return; } if (!parent->moveChildTo(mod, static_cast(to))) { - // Either already at position N or some other no-op — not an error per se, + // Either already at position N or some other no-op: not an error per se, // but report so the UI can avoid a refetch storm on rapid drags. sendResponse(conn, 200, "application/json", "{\"ok\":true,\"noop\":true}"); return; } - // A move changes the parent's child ordering — mark the parent dirty so its + // A move changes the parent's child ordering: mark the parent dirty so its // file is rewritten with the new order (same as add/delete handlers). parent->markDirty(); FilesystemModule::noteDirty(); @@ -2263,14 +2287,14 @@ ListSource* HttpServerModule::resolveEditableList(platform::TcpConnection& conn, // (a driver referencing a preset by id) picks up the change on the next prepare. Mirrors the // add/delete/move module handlers' dirty + prepareTree tail. void HttpServerModule::afterListMutation() { - // Mark the owning module dirty so its subtree is actually written — noteDirty() alone only sets + // Mark the owning module dirty so its subtree is actually written: noteDirty() alone only sets // the debounce flag; the flush loop skips a subtree whose module isn't dirty (subtreeDirty). This // is the same markDirty()+noteDirty() pair the add/delete/move module handlers use; without the // markDirty a mutated list persisted nothing and was lost on reboot. if (listMutationModule_) listMutationModule_->markDirty(); FilesystemModule::noteDirty(); if (scheduler_) { - // Rebuild EVERY module's controls: a list mutation can change what OTHER modules present — + // Rebuild EVERY module's controls: a list mutation can change what OTHER modules present - // adding/removing a light preset changes the option set of every driver's `preset` Select // (which is built from the library). Without this, a driver's Select keeps its stale option // count and a just-added preset is unselectable ("value out of range"). Mirrors the phase-2b @@ -2280,7 +2304,7 @@ void HttpServerModule::afterListMutation() { // Re-resolve each driver's preset → correction so an EDIT flows to output immediately. This // is a tier-1 correction refresh (rebuildCorrection → onCorrectionChanged), NOT a tier-3 // prepareTree(): a preset edit changes correction data, not pipeline STRUCTURE, so it must - // not re-run prepare() — that reinits each driver's output peripheral (an RMT channel + // not re-run prepare(): that reinits each driver's output peripheral (an RMT channel // teardown blanks the strip for a tick, even on drivers not using the edited preset), which // Live-reconfiguration forbids (a config change applies with no visible glitch). Drivers is // the one container that owns driver corrections; core already couples to it (latestSummary). @@ -2456,7 +2480,7 @@ void HttpServerModule::handleFirmwareUrl(platform::TcpConnection& conn, const ch sendResponse(conn, 500, "application/json", err); return; } - // 202 Accepted — task running; UI polls FirmwareUpdate.update_status. + // 202 Accepted: task running; UI polls FirmwareUpdate.update_status. sendResponse(conn, 202, "application/json", "{\"ok\":true}"); } @@ -2539,7 +2563,7 @@ void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, con return; } } - // No slot available — close. A slot frees when a dead client's next send/poll fails (reaped within a + // No slot available: close. A slot frees when a dead client's next send/poll fails (reaped within a // tick or two), so MAX_WS_CLIENTS is sized well above the realistic concurrent count PLUS the transient // overlap of a refresh (the browser opens the new socket before the old socket's FIN lands, so both // briefly hold slots). The browser's own WS backoff retries a genuinely-full moment. @@ -2554,10 +2578,10 @@ void HttpServerModule::pushStateToWebSockets() { if (!hasClients) return; if (fullResyncPending_) { - // FULL STATE — sent on connect and after a structural change (a value patch can't describe a + // FULL STATE: sent on connect and after a structural change (a value patch can't describe a // reshaped tree). It's the one large frame (~30 KB), so route it through the resumable sender // to drain in chunks on tick20ms, NOT a blocking write on the render tick. buildStateJson - // serialises the WHOLE tree — the expensive path — but only when fullResyncPending_, not every + // serializes the WHOLE tree: the expensive path: but only when fullResyncPending_, not every // second. // A prior full state still draining finishes first, the slot is single-occupancy, and a // half-then-half state is worse than one whole one arriving a tick later. fullResyncPending_ @@ -2570,15 +2594,15 @@ void HttpServerModule::pushStateToWebSockets() { const size_t len = sink.size(); char* owned = sink.detach(); // move ownership to the sender (frees on drain-complete) if (owned && startBufferedTextSend(owned, len)) { - baselineLeafHashes(); // the full state IS the new baseline — next tick patches from here + baselineLeafHashes(); // the full state IS the new baseline: next tick patches from here fullResyncPending_ = false; // cleared only on a confirmed accept; a failed start retries } } else { - // PATCH — the steady-state path. buildStatePatch walks the tree, value-hashes each leaf, and + // PATCH: the steady-state path. buildStatePatch walks the tree, value-hashes each leaf, and // emits ONLY the ones whose value changed since the last push (typically a handful of telemetry - // leaves, ~1–2 KB). This is the whole fix: the 30 KB of unchanging option/detail metadata is - // NEVER serialised or sent here, so tick1s no longer spikes the render thread. The patch is - // small, so it sends inline (no resumable drain) — a non-blocking per-client write of ~2 KB. + // leaves, ~1-2 KB). This is the whole fix: the 30 KB of unchanging option/detail metadata is + // NEVER serialized or sent here, so tick1s no longer spikes the render thread. The patch is + // small, so it sends inline (no resumable drain): a non-blocking per-client write of ~2 KB. // While a full state is mid-drain, hold the patch: a small frame written into the middle // of the chunked big one would interleave inside a WS message on that client. One skipped // second of telemetry; the drained full state carries the fresh values anyway. @@ -2596,9 +2620,9 @@ void HttpServerModule::pushStateToWebSockets() { // Also push a WLED-shaped {state, info} frame. The native WLED app connects to this // same /ws and reads live state (color, brightness, on/off) from a DeviceStateInfo - // message — it has no /json/si GET. Our own UI ignores this frame (its JS keys on + // message: it has no /json/si GET. Our own UI ignores this frame (its JS keys on // `modules`); the WLED app ignores our module frame (its Moshi keys on `state`/`info`). - // Two small frames, each consumer parses its own — no client needs to know about the + // Two small frames, each consumer parses its own: no client needs to know about the // other. This is what makes the device's card show the live color + a working slider. pushWledStateToWebSockets(); } @@ -2697,7 +2721,7 @@ void HttpServerModule::pollWledStateFromWebSockets() { break; // >64 KB control message: not ours, stop } const size_t frameLen = hdr + 4 + len; // header + mask key + payload (client = masked) - if (!masked || off + frameLen > total) break; // incomplete/unmasked — leave for later + if (!masked || off + frameLen > total) break; // incomplete/unmasked: leave for later if (opcode == 0x1 && len < 200) { // a text frame small enough to be a state-set const uint8_t* mask = fr + hdr; char body[200]; @@ -2778,19 +2802,19 @@ static size_t writeWsFrameHeader(uint8_t* h, uint8_t opcode, size_t payloadLen) bool HttpServerModule::sendBufferedFrame(const uint8_t* header, size_t headerLen, const uint8_t* body, size_t bodyLen) { // Drop-new backpressure: one frame in flight at a time. A caller that asks while a send is active - // is told "busy" — the in-flight frame is kept and this new one is rejected, which the producer + // is told "busy": the in-flight frame is kept and this new one is rejected, which the producer // reads as "link is behind" and uses to shed frame rate (it requeues nothing, so the loop runs on). if (previewSend_.active) return false; const size_t totalLen = headerLen + bodyLen; // WS payload length = app header + body // Build the WS frame header (binary opcode) directly into previewSend_.hdr, followed by the app - // header — so the cursor streams them as one span. + // header: so the cursor streams them as one span. const size_t wsLen = writeWsFrameHeader(previewSend_.hdr, 0x82, totalLen); // The app header follows the WS header in the same buffer. sizeof(hdr)=16 holds the 10-byte WS // form + the preview app headers (≤10 bytes); guard so a future larger header can't overrun. if (wsLen + headerLen > sizeof(previewSend_.hdr)) return false; // memcpy, not a hand-rolled byte loop: the loop indexed hdr[wsLen + i], and the compiler cannot - // see through writeWsFrameHeader that wsLen is at most 10 — so it must assume the index could be + // see through writeWsFrameHeader that wsLen is at most 10: so it must assume the index could be // anywhere and warns on the write (-Wstringop-overflow). memcpy states the same intent with the // destination and length in one expression, which it CAN check against the guard above. std::memcpy(previewSend_.hdr + wsLen, header, headerLen); @@ -2801,8 +2825,8 @@ bool HttpServerModule::sendBufferedFrame(const uint8_t* header, size_t headerLen for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) previewSend_.sent[i] = 0; previewSend_.active = true; // Deliberately do NOT drain here. sendBufferedFrame is called from PreviewDriver's tick() on the - // RENDER thread; a socket writeSome is variable-cost (0..~ms) and would land that cost — and its - // jitter — directly on the render tick, hitching the LEDs. So we only queue the frame (copy the + // RENDER thread; a socket writeSome is variable-cost (0..~ms) and would land that cost: and its + // jitter: directly on the render tick, hitching the LEDs. So we only queue the frame (copy the // header, point at the body) and let drainPreviewSend() push bytes purely on tick20ms, off the // render hot path. The frame starts draining within one transport poll (≤20 ms). return true; @@ -2811,7 +2835,7 @@ bool HttpServerModule::sendBufferedFrame(const uint8_t* header, size_t headerLen // Queue a TEXT frame whose body this module OWNS, through the same resumable slot. Used by the state // push so the 20 KB JSON drains in chunks on tick20ms rather than a blocking write on the render tick. bool HttpServerModule::startBufferedTextSend(char* ownedBody, size_t bodyLen) { - // A send already in flight: drop this one and free its buffer — the next second's state is fresher. + // A send already in flight: drop this one and free its buffer: the next second's state is fresher. if (stateSend_.active) { platform::free(ownedBody); return false; } // No app header for the state frame (the JSON is the whole payload), just the WS text header. const size_t wsLen = writeWsFrameHeader(stateSend_.hdr, 0x81, bodyLen); @@ -2831,7 +2855,7 @@ void HttpServerModule::drainPreviewSend() { // Core-0 side of the sender lease. The offloaded PreviewDriver (core 1) holds this while it arms a // frame or streams the coordinate table; taking it here keeps this drain's socket writes from // interleaving with that stream inside one WS frame, and keeps us off a half-armed previewSend_. - // try_lock, not a wait: this runs on the render thread's tick20ms, where blocking is forbidden — + // try_lock, not a wait: this runs on the render thread's tick20ms, where blocking is forbidden - // core 1 releases within one message, so we simply drain on the next 20 ms tick instead. LockGuard lease{wsLock_}; if (!lease) return; @@ -2859,7 +2883,7 @@ void HttpServerModule::drainPreviewSend() { if (clientSink_) clientSink_->onClientGone(i); break; } - if (n == 0) break; // WouldBlock — leave the rest for next tick (no spin) + if (n == 0) break; // WouldBlock: leave the rest for next tick (no spin) cur += static_cast(n); budget -= static_cast(n); } @@ -2906,7 +2930,7 @@ void HttpServerModule::drainStateSend() { // Per-tick per-client chunk cap, derived from free contiguous memory: a tight board takes small // bites (so one drain can't dominate the tick), a roomy board drains a big frame in a tick or two. -// Bounded both ways — never below a floor (forward progress) nor above a ceiling (tick occupancy). +// Bounded both ways: never below a floor (forward progress) nor above a ceiling (tick occupancy). size_t HttpServerModule::drainChunkBytes() const { constexpr size_t kFloor = 2048; // always make real progress, even on a fragmented board constexpr size_t kCeil = 65536; // cap tick occupancy regardless of how much RAM is free diff --git a/src/light/MpegTs.h b/src/light/MpegTs.h new file mode 100644 index 00000000..809fe54a --- /dev/null +++ b/src/light/MpegTs.h @@ -0,0 +1,239 @@ +#pragma once +/// MPEG-TS muxing: H.264 access units into the 188-byte transport packets an HLS segment is +/// made of. Written for the ESP32-P4 HLS path, where the hardware encoder hands us Annex-B NALs +/// and nothing else does the packaging (desktop hands the whole job to ffmpeg). +/// +/// **Pure logic, no platform calls**, so the packet structure is pinned by host unit tests rather +/// than only on a P4: feed it canned NALs, assert the bytes. That is the entire reason this is a +/// header of its own and not folded into the encoder file. +/// +/// Scope is exactly what an HLS segment of our own stream needs: one video program, one H.264 +/// elementary stream, no audio, no PCR-only packets. MPEG-TS is ISO/IEC 13818-1; the H.264-in-TS +/// mapping is its Annex, and the byte layout below follows them directly. +/// +/// Author: projectMM original + +#include +#include +#include + +namespace mm::ts { + +/// One transport packet. Everything in MPEG-TS is this size, always. +static constexpr size_t kPacketSize = 188; + +/// The PIDs we emit. PAT is fixed at 0 by the standard; the rest are our choice and match what +/// ffmpeg's muxer uses, so a stream from either source looks the same to a player. +static constexpr uint16_t kPidPat = 0x0000; +static constexpr uint16_t kPidPmt = 0x1000; +static constexpr uint16_t kPidVideo = 0x0100; + +/// The 90 kHz clock every TS timestamp is counted in (ISO/IEC 13818-1). +static constexpr uint32_t kClockHz = 90000; + +/// The continuity counters, which belong to the STREAM rather than to any one write. MPEG-TS +/// requires each PID's counter to advance by one per packet without interruption, across frames +/// and across segment boundaries alike: a player treats any gap as lost packets. The caller keeps +/// one of these for the whole stream and hands it to every Writer. +struct Continuity { + uint8_t pat = 0, pmt = 0, video = 0; +}; + +/// Writes packets into a caller-owned buffer, reporting overflow rather than growing: a segment +/// buffer is a fixed PSRAM slot on the P4, and a muxer that allocates would be the one thing in +/// the hot path doing so. +class Writer { +public: + /// `cc` must outlive the Writer and be the SAME object for every write in a stream: a Writer + /// is created per frame, and per-Writer counters would restart at zero on each one (bench: + /// ffmpeg reported "Packet corrupt" at every frame, and players stalled and re-buffered). + Writer(uint8_t* dst, size_t cap, Continuity& cc) : dst_(dst), cap_(cap), cc_(cc) {} + + /// Bytes written so far. + size_t size() const { return len_; } + /// Did any write not fit? Once true the output is incomplete and must be discarded. + bool overflowed() const { return overflow_; } + + /// Start a segment: PAT then PMT, so a player tuning in mid-stream can decode from the first + /// packet it sees. HLS players join at segment boundaries, so every segment repeats them. + void writeTables() { + writeTable(kPidPat, patPayload, sizeof(patPayload), cc_.pat); + writeTable(kPidPmt, pmtPayload, sizeof(pmtPayload), cc_.pmt); + } + + /// Write one access unit (the NALs of a single frame, Annex-B start codes included) as a PES + /// packet split across as many TS packets as it needs. + /// + /// `pts90` is the presentation time in 90 kHz ticks. We emit PTS only, never a separate DTS: + /// the encoder produces no B-frames, so decode and presentation order are identical and the + /// standard's own rule is to omit DTS when it would equal PTS. `keyframe` marks an IDR, which + /// gets the PCR (a player needs a clock reference at the point it can start decoding). + void writeAccessUnit(const uint8_t* au, size_t len, uint32_t pts90, bool keyframe) { + if (!au || len == 0) return; + + // PES header: start code, stream id 0xE0 (video), then a length that we deliberately + // leave 0. A video PES may legally declare "unbounded" this way, which is what lets a + // frame exceed 65535 bytes -- routine at wall resolutions -- and is what ffmpeg emits too. + uint8_t pes[20]; // 9-byte PES header + 5-byte PTS + the 6-byte access unit delimiter + size_t p = 0; + pes[p++] = 0x00; pes[p++] = 0x00; pes[p++] = 0x01; pes[p++] = 0xE0; + pes[p++] = 0x00; pes[p++] = 0x00; // length: unbounded + pes[p++] = 0x80; // '10' marker, no scrambling + pes[p++] = 0x80; // PTS present, DTS absent + pes[p++] = 5; // PTS is 5 bytes + writePts(pes + p, pts90); + p += 5; + + // An access unit delimiter tells the decoder where a frame starts without it having to + // infer boundaries from slice headers. ffmpeg inserts one too. + static const uint8_t aud[] = {0x00, 0x00, 0x00, 0x01, 0x09, 0xF0}; + std::memcpy(pes + p, aud, sizeof(aud)); + p += sizeof(aud); + + bool first = true; + size_t pesOff = 0, auOff = 0; + while (pesOff < p || auOff < len) { + uint8_t* pkt = claim(); + if (!pkt) return; + + // Payload room is what is left after the 4-byte header and any adaptation field. The + // FIRST packet of a frame carries the payload-unit-start flag, and a keyframe's first + // packet also carries the PCR, so both live in the same branch. + size_t avail = kPacketSize - 4; + uint8_t adaptLen = 0; + const bool wantPcr = first && keyframe; + if (wantPcr) adaptLen = 1 + 1 + 6; // length byte + flags + 48-bit PCR + + const size_t remaining = (p - pesOff) + (len - auOff); + // A short tail must be padded out with a stuffing adaptation field: TS packets are + // never partially filled. + if (remaining + adaptLen < avail) { + const size_t need = avail - remaining; + adaptLen = static_cast(adaptLen ? adaptLen + need : (need >= 1 ? need : 1)); + } + + pkt[0] = 0x47; // sync byte, every packet + pkt[1] = static_cast((first ? 0x40 : 0x00) | ((kPidVideo >> 8) & 0x1F)); + pkt[2] = static_cast(kPidVideo & 0xFF); + pkt[3] = static_cast((adaptLen ? 0x30 : 0x10) | (cc_.video & 0x0F)); + cc_.video++; + + size_t o = 4; + if (adaptLen) { + pkt[o++] = static_cast(adaptLen - 1); // length excludes itself + if (adaptLen >= 2) { + pkt[o++] = wantPcr ? 0x50 : 0x00; // random-access + PCR flags + if (wantPcr) { writePcr(pkt + o, pts90); o += 6; } + // Remaining adaptation bytes are stuffing, which must be 0xFF. + const size_t stuffEnd = 4 + adaptLen; + while (o < stuffEnd) pkt[o++] = 0xFF; + } + avail = kPacketSize - o; + } + + // PES header first, then frame bytes, until the packet is full. + while (o < kPacketSize && pesOff < p) pkt[o++] = pes[pesOff++]; + const size_t take = kPacketSize - o < len - auOff ? kPacketSize - o : len - auOff; + if (take) { std::memcpy(pkt + o, au + auOff, take); auOff += take; o += take; } + while (o < kPacketSize) pkt[o++] = 0xFF; // only reachable when nothing is left + + first = false; + } + } + +private: + // PAT: one program (number 1) whose map lives on kPidPmt. + static constexpr uint8_t patPayload[] = { + 0x00, // table id: program association + 0xB0, 0x0D, // section syntax indicator + length 13 + 0x00, 0x01, // transport stream id + 0xC1, // version 0, current + 0x00, 0x00, // section 0 of 0 + 0x00, 0x01, // program number 1 + // The PMT's PID, with the top 3 reserved bits set: 0b111 | 0x1000 = 0xF0 0x00. Getting + // this wrong points the PAT at PID 0 instead, and a player that trusts the PAT (VLC does; + // ffmpeg probes for streams anyway) never finds the PMT, so it sees no video track at all. + 0xF0, 0x00, // -> kPidPmt (0x1000) + 0x00, 0x00, 0x00, 0x00, // CRC, filled in by writeTable + }; + // PMT: one elementary stream, type 0x1B (H.264), which is also the PCR PID. + static constexpr uint8_t pmtPayload[] = { + 0x02, // table id: program map + 0xB0, 0x12, // section syntax indicator + length 18 + 0x00, 0x01, // program number + 0xC1, // version 0, current + 0x00, 0x00, // section 0 of 0 + 0xE1, 0x00, // PCR carried on the video PID + 0xF0, 0x00, // no program info + 0x1B, // stream type: H.264 + 0xE1, 0x00, // elementary PID = kPidVideo + 0xF0, 0x00, // no ES info + 0x00, 0x00, 0x00, 0x00, // CRC + }; + + /// Reserve one packet's worth of output, or report overflow. + uint8_t* claim() { + if (len_ + kPacketSize > cap_) { overflow_ = true; return nullptr; } + uint8_t* p = dst_ + len_; + len_ += kPacketSize; + return p; + } + + /// A PSI table is one packet: pointer byte, the section, CRC, then 0xFF to the end. + void writeTable(uint16_t pid, const uint8_t* section, size_t sectionLen, uint8_t& cc) { + uint8_t* pkt = claim(); + if (!pkt) return; + pkt[0] = 0x47; + pkt[1] = static_cast(0x40 | ((pid >> 8) & 0x1F)); // payload starts here + pkt[2] = static_cast(pid & 0xFF); + pkt[3] = static_cast(0x10 | (cc & 0x0F)); + cc++; + pkt[4] = 0x00; // pointer field: section starts next + std::memcpy(pkt + 5, section, sectionLen); + // The CRC covers the section from its table id up to (not including) the CRC itself. + const uint32_t crc = crc32Mpeg(pkt + 5, sectionLen - 4); + uint8_t* c = pkt + 5 + sectionLen - 4; + c[0] = static_cast(crc >> 24); c[1] = static_cast(crc >> 16); + c[2] = static_cast(crc >> 8); c[3] = static_cast(crc); + std::memset(pkt + 5 + sectionLen, 0xFF, kPacketSize - 5 - sectionLen); + } + + /// PTS in the standard's split-across-marker-bits layout (13818-1 table 2-21). + static void writePts(uint8_t* d, uint32_t pts) { + d[0] = static_cast(0x21 | ((pts >> 29) & 0x0E)); + d[1] = static_cast((pts >> 22) & 0xFF); + d[2] = static_cast(0x01 | ((pts >> 14) & 0xFE)); + d[3] = static_cast((pts >> 7) & 0xFF); + d[4] = static_cast(0x01 | ((pts << 1) & 0xFE)); + } + + /// PCR: a 33-bit base at 90 kHz plus a 9-bit extension we leave at zero (we have no finer + /// clock to report, and players only need the base). + static void writePcr(uint8_t* d, uint32_t pcr) { + d[0] = static_cast(pcr >> 25); + d[1] = static_cast(pcr >> 17); + d[2] = static_cast(pcr >> 9); + d[3] = static_cast(pcr >> 1); + d[4] = static_cast(((pcr & 1) << 7) | 0x7E); + d[5] = 0x00; + } + + /// CRC-32/MPEG-2: the PSI section checksum (polynomial 0x04C11DB7, MSB-first, no final xor). + static uint32_t crc32Mpeg(const uint8_t* data, size_t len) { + uint32_t crc = 0xFFFFFFFFu; + for (size_t i = 0; i < len; i++) { + crc ^= static_cast(data[i]) << 24; + for (int b = 0; b < 8; b++) + crc = (crc & 0x80000000u) ? (crc << 1) ^ 0x04C11DB7u : crc << 1; + } + return crc; + } + + uint8_t* dst_; + size_t cap_; + Continuity& cc_; + size_t len_ = 0; + bool overflow_ = false; +}; + +} // namespace mm::ts diff --git a/src/light/draw.h b/src/light/draw.h index 1c8d6102..a369e735 100644 --- a/src/light/draw.h +++ b/src/light/draw.h @@ -760,16 +760,23 @@ struct Sprite { /// (transparent), an out-of-range palette index renders nothing (visible degrade, never UB), /// `frame` clamps to the last one. `scale` is nearest-neighbor integer magnification (each /// sprite pixel becomes a scale x scale block), which keeps pixel art crisp on a big grid. +/// `flipX` mirrors the sprite horizontally, so art drawn facing one way serves both directions +/// without a second copy of every frame. /// Every write goes through draw::pixel, so clipping at all four edges is offsetOf's sentinel, /// exactly as glyph clips. inline void sprite(const Canvas& cv, const sprites::Sprite& s, uint8_t frame, - lengthType x, lengthType y, uint8_t scale = 1) { + lengthType x, lengthType y, uint8_t scale = 1, bool flipX = false) { if (!s.pixels || !s.palette || s.w == 0 || s.h == 0 || s.frames == 0 || scale == 0) return; if (frame >= s.frames) frame = static_cast(s.frames - 1); const uint8_t* rows = s.pixels + static_cast(frame) * s.w * s.h; for (uint8_t ry = 0; ry < s.h; ry++) { for (uint8_t rx = 0; rx < s.w; rx++) { - const uint8_t idx = rows[static_cast(ry) * s.w + rx]; + // flipX mirrors the READ, not the write, so the sprite still lands at (x, y) with the + // same footprint. Art that faces one way (a fish, a car, a walking figure) otherwise + // needs a second copy of every frame purely to face the other, which doubles the art + // and its maintenance for a transform this costs one subtraction. + const uint8_t sx0 = flipX ? static_cast(s.w - 1 - rx) : rx; + const uint8_t idx = rows[static_cast(ry) * s.w + sx0]; if (idx == 0 || idx >= s.paletteCount) continue; const RGB c = s.palette[idx]; for (uint8_t sy = 0; sy < scale; sy++) diff --git a/src/light/drivers/HlsDriver.h b/src/light/drivers/HlsDriver.h index 1b72d27e..0840c028 100644 --- a/src/light/drivers/HlsDriver.h +++ b/src/light/drivers/HlsDriver.h @@ -3,30 +3,43 @@ /// /// The rendered frame, pixel-exact, reaches a TV, VLC or a browser as H.264 over HLS from the /// device's own HTTP server. Complements NdiDriver: NDI is the pro-tools path, HLS is the -/// consumer-playback path. Spec: docs/backlog/hls-driver-spec.md. +/// consumer-playback path. /// -/// **Why an ffmpeg pipe.** One general implementation for every desktop OS and the Pi: the -/// platform spawns the `ffmpeg` found on PATH and this driver pipes raw RGB frames to its stdin; -/// ffmpeg encodes and writes HLS segments the HTTP server serves from `/.hls/`. Nothing is -/// vendored or linked (GPL x264 / patent-encumbered openh264 stay out of the tree), the same -/// runtime-dependency arrangement as Npcap and the NDI runtime. Per-OS encoder integrations were -/// rejected for the same reason NDI beat Spout/Syphon: coverage decides. +/// **The driver states numbers; the platform encodes.** This driver packs the corrected frame and +/// hands it over with the geometry, rate and bitrate (`platform::EncoderConfig`); how those become +/// H.264 differs completely per platform and is none of its business. On desktop the platform +/// spawns the `ffmpeg` found on PATH and pipes frames to its stdin, ffmpeg doing the encode and +/// the HLS segmenting; nothing is vendored or linked (GPL x264 / patent-encumbered openh264 stay +/// out of the tree), the same runtime-dependency arrangement as Npcap and the NDI runtime. On the +/// ESP32-P4 the platform drives the chip's hardware H.264 encoder and muxes the segments itself. +/// Per-OS desktop encoder integrations were rejected for the reason NDI beat Spout/Syphon: +/// coverage decides. /// -/// **Pixel-exact contract.** The encoded frame IS the grid, width x height from the layer, no -/// scaling anywhere in the pipeline: every written pixel is one video pixel, letterboxed by the -/// display. Latency is HLS's, not ours: the encode adds milliseconds, segmentation plus player -/// buffering adds the seconds (2-5 s live-tuned). Documented on the card so nobody expects -/// preview-grade feedback. +/// **Pixel-exact contract.** The encoded frame is the grid from the layer, letterboxed by the +/// display. Where `scale` > 1 a light becomes a solid square BLOCK rather than one pixel: still +/// pixel-exact in the sense that matters, since replication invents no color the wall does not +/// have and keeps every light individually visible, unlike an interpolating resize. Scaling +/// exists because the P4's hardware encoder refuses a frame under 80x80 and because a small wall +/// streamed 1:1 is a postage stamp in the player; `autoScale()` therefore lifts a small wall to +/// that floor and leaves everything else at 1:1. One factor for both axes keeps the aspect ratio +/// exact. Latency is HLS's, not ours: the encode adds milliseconds, segmentation plus player +/// buffering adds the seconds (2-5 s live-tuned). /// -/// **Desktop only** (`platform::hasHls`): H.264 needs a desktop-class CPU or hardware encoder, -/// and there is no process to spawn on ESP32, which reaches viewers through Preview instead. +/// **Where it runs** (`platform::hasHls`): H.264 needs a desktop-class CPU or a hardware encoder, +/// so desktop and the ESP32-P4. Every other ESP32 reaches viewers through Preview instead. +/// +/// **Frame pacing is a fixed schedule**, not a last-sent timestamp: `1000/fps` truncates (30 fps +/// asks for 33 ms, so 30 frames span 990 ms and the stream runs ~1% fast) and re-basing on each +/// frame's arrival lets one late tick shift the schedule for good. Either drifts against the +/// player's clock until it stalls to re-buffer, which is a periodic hiccup rather than an +/// obvious fault. The bitrate is DERIVED for the same reason a control was removed: it follows +/// from pixels x fps (see autoBitrateKbit), and `targetFps` is the knob a user actually wants. /// /// Prior art: HLS is Apple's (RFC 8216); ffmpeg does the encoding. The frame-pacing, packing and /// status shape follow NdiDriver, the other driver that turns the rendered buffer into video. /// Author: projectMM original #include "core/Control.h" -#include "core/HttpServerModule.h" // servedPort: the url control must not lie after a port change #include "core/ScratchBuffer.h" #include "light/drivers/DriverBase.h" #include "platform/platform.h" @@ -38,7 +51,7 @@ namespace mm { -/// Driver that publishes the layer as an H.264/HLS stream via a spawned ffmpeg. +/// Driver that publishes the layer as an H.264/HLS stream. class HlsDriver : public DriverBase { public: static constexpr const char* kTags = "🖥️"; @@ -54,16 +67,23 @@ class HlsDriver : public DriverBase { void defineDriverControls() override { controls_.addControl("targetFps", targetFps, 1, 120); - // Bitrate is an identity-like magnitude a user types, not sweeps: a number field. - controls_.addControl("bitrate", bitrateKbit, 500, 40000); - controls_.setNumberField(controls_.count() - 1); - // The ffmpeg video encoder. libx264 exists in every ffmpeg build; the hardware entries - // offload the encode entirely, worth picking on large grids. One this ffmpeg lacks - // fails the spawn and the status says so. - controls_.addSelect("encoder", encoderSel_, kEncoderOptions, kEncoderOptionCount); - // Persisted by LABEL: ffmpeg encoder names are stable identities, and editing the - // option list (vaapi's removal) must never silently remap an index-persisted pick. - controls_.setPersistLabel(controls_.count() - 1); + // How many video pixels each light becomes. 0 = auto, which picks the smallest factor + // that clears the encoder's minimum frame size, so a small wall is never rejected and + // never arrives as a postage stamp in the player. One factor for both axes: the aspect + // ratio is preserved by construction, and every light stays a clean square block rather + // than being interpolated across a fractional boundary. + controls_.addControl("scale", scale, 0, kMaxScale); + // The video encoder, where the platform has more than one. libx264 exists in every ffmpeg + // build; the hardware entries offload the encode entirely, worth picking on large grids. + // One this ffmpeg lacks fails the spawn and the status says so. A platform with a single + // hardware encoder (the P4) offers no choice, so the control is absent rather than a + // one-entry dropdown. + if constexpr (platform::hasEncoderChoice) { + controls_.addSelect("encoder", encoderSel_, kEncoderOptions, kEncoderOptionCount); + // Persisted by LABEL: ffmpeg encoder names are stable identities, and editing the + // option list (vaapi's removal) must never silently remap an index-persisted pick. + controls_.setPersistLabel(controls_.count() - 1); + } // The playable address, one copy away from VLC or a Safari AirPlay hand-off. controls_.addReadOnly("url", urlBuf_, sizeof(urlBuf_)); } @@ -71,7 +91,7 @@ class HlsDriver : public DriverBase { /// A pacing or rate change needs a new encode (ffmpeg fixes both at spawn); geometry changes /// arrive through prepare() already. bool affectsPrepare(const char* name) const override { - return std::strcmp(name, "targetFps") == 0 || std::strcmp(name, "bitrate") == 0 || + return std::strcmp(name, "targetFps") == 0 || std::strcmp(name, "scale") == 0 || std::strcmp(name, "encoder") == 0 || isCorrectionControl(name); } @@ -79,8 +99,45 @@ class HlsDriver : public DriverBase { release(); if (!layer_) return; - width_ = layer_->physicalWidth() > 0 ? layer_->physicalWidth() : 1; - height_ = layer_->physicalHeight() > 0 ? layer_->physicalHeight() : 1; + srcWidth_ = layer_->physicalWidth() > 0 ? layer_->physicalWidth() : 1; + srcHeight_ = layer_->physicalHeight() > 0 ? layer_->physicalHeight() : 1; + // An explicit scale is honoured, EXCEPT where the platform's encoder has a minimum frame + // it will not go below: scale 1 on a 20x10 wall asks the P4's hardware block for a 20x10 + // frame, which it refuses outright, so the setting would produce no stream at all. The + // floor is raised to autoScale() there and only there. A desktop ffmpeg has no such + // limit, so a small 1:1 stream stays exactly that. + const uint8_t floorScale = platform::hasEncoderChoice ? 1 : autoScale(); + scale_ = scale ? (scale > floorScale ? scale : floorScale) : autoScale(); + // Widen BEFORE multiplying, and reject before narrowing. Both operands are individually + // sane while their product need not be: lengthType is int16_t, so an 821x4 wall at + // scale 80 wraps to 144x320, the frame buffer is sized from the wrapped number, and the + // pixel loop then walks the REAL 821x4 source straight past the end of it. + uint32_t scaledW = static_cast(srcWidth_) * scale_; + uint32_t scaledH = static_cast(srcHeight_) * scale_; + // 4:2:0 chroma is sampled in 2x2 blocks, so the P4's encoder takes even dimensions only + // and an odd wall (21x15, say) would be refused with nothing but a generic start failure + // to explain it. Doubling the scale is the fix that keeps the picture: every source pixel + // still maps to a whole square block, so the result is even on both axes and the aspect + // ratio is untouched. Desktop ffmpeg accepts odd sizes, so it keeps the scale it asked for. + if constexpr (!platform::hasEncoderChoice) { + if ((scaledW & 1u) || (scaledH & 1u)) { + scale_ = static_cast(scale_ * 2); + scaledW *= 2; + scaledH *= 2; + } + } + if (scaledW > kMaxEncodeWidth || scaledH > kMaxEncodeHeight) { + std::snprintf(statusBuf_, sizeof(statusBuf_), + "%ux%u at scale %u exceeds the encoder's %ux%u", + static_cast(srcWidth_), static_cast(srcHeight_), + static_cast(scale_), + static_cast(kMaxEncodeWidth), + static_cast(kMaxEncodeHeight)); + setStatus(statusBuf_, Severity::Warning); + return; + } + width_ = static_cast(scaledW); + height_ = static_cast(scaledH); const size_t pixels = static_cast(width_) * height_; if (!rgb_.resize(pixels * 3)) { @@ -89,17 +146,34 @@ class HlsDriver : public DriverBase { } if (correction_.outChannels > 3) corrScratch_.resize(correction_.outChannels); - platform::fsMkdir(kSegmentDir); - clearSegments(); // a stale playlist must not serve the old geometry + // Only where the encoder writes segments to disk. A platform that keeps them in RAM + // (the P4) has no directory to make and drops its ring on encoderStart. + if constexpr (platform::hasFsSegments) { + platform::fsMkdir(kSegmentDir); + clearSegments(); // a stale playlist must not serve the old geometry + } restartsLeft_ = kMaxRestarts; + sendEpochMs_ = platform::millis(); // the schedule the frame pacing counts from + nextSendMs_ = sendEpochMs_; + frameIndex_ = 0; if (!startEncoder()) return; // startEncoder set the status - const uint16_t port = HttpServerModule::servedPort(); - std::snprintf(urlBuf_, sizeof(urlBuf_), "http://%s:%u/hls/stream.m3u8", - platform::hostIp(), port ? port : 8080u); - std::snprintf(statusBuf_, sizeof(statusBuf_), "streaming %ux%u at %u fps", - static_cast(width_), static_cast(height_), - static_cast(targetFps)); + // A RELATIVE url. The device's own address is not the platform layer's to know on an + // ESP32 (platform::hostIp() is empty there by design: NetworkModule owns the IP), and a + // hard-coded host would go stale on every DHCP change anyway. The browser already knows + // which host it loaded from, so it resolves this against that and renders an absolute, + // clickable link; anything copied out of the UI is therefore correct by construction. + std::snprintf(urlBuf_, sizeof(urlBuf_), "/hls/stream.m3u8"); + if (scale_ > 1) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "streaming %ux%u as %ux%u at %u fps", + static_cast(srcWidth_), static_cast(srcHeight_), + static_cast(width_), static_cast(height_), + static_cast(targetFps)); + } else { + std::snprintf(statusBuf_, sizeof(statusBuf_), "streaming %ux%u at %u fps", + static_cast(width_), static_cast(height_), + static_cast(targetFps)); + } setStatus(statusBuf_, Severity::Status); } @@ -107,7 +181,7 @@ class HlsDriver : public DriverBase { if (open_) { platform::encoderStop(); open_ = false; - clearSegments(); // transient output; nothing to keep + if constexpr (platform::hasFsSegments) clearSegments(); // transient; nothing to keep } DriverBase::release(); } @@ -124,14 +198,41 @@ class HlsDriver : public DriverBase { // targetFps is a CEILING, the NdiDriver/PreviewDriver pacing pattern: the render loop // runs faster, frames beyond the rate are simply not encoded. + // + // Paced on a FIXED schedule rather than from each frame's arrival time. Two reasons, both + // measured on the bench: `1000/fps` truncates (30 fps asks for 33 ms, so 30 frames span + // 990 ms and the stream runs ~1% fast), and re-basing on `now` lets every late tick shift + // the schedule permanently. A player fed segments that drift against its own clock stalls + // to re-buffer, which is the periodic hiccup this replaced. Milliseconds are accumulated + // in the numerator so the rate is exact at any fps, and a long stall (a re-prepare, a + // paused render loop) resyncs rather than trying to catch up with a burst. const uint32_t now = platform::millis(); - if (now - lastSendMs_ < 1000u / targetFps) return; - lastSendMs_ = now; + const uint32_t periodMs = 1000u / targetFps; + if (static_cast(now - nextSendMs_) < 0) return; + frameIndex_++; + nextSendMs_ = sendEpochMs_ + static_cast( + (static_cast(frameIndex_) * 1000u) / targetFps); + if (static_cast(now - nextSendMs_) > static_cast(periodMs * 4)) { + // Far behind (a re-prepare, or a render loop that was paused): restart the schedule + // from this frame rather than firing a burst to catch up. The next frame is due one + // period out, NOT immediately -- resyncing to `now` makes the very next tick due and + // sends a second frame straight away (caught by the rate-ceiling test). + sendEpochMs_ = now; + // ONE, not zero: this tick's frame is the schedule's frame 0, so the next one is + // frame 1. Leaving it at 0 made the following tick recompute its due time back to + // this instant, firing a second frame with a 0 ms gap. + frameIndex_ = 1; + nextSendMs_ = now + periodMs; + } - const nrOfLightsType want = static_cast(width_) * height_; - const nrOfLightsType have = sourceBuffer_->count(); - const nrOfLightsType n = want < have ? want : have; - if (n == 0 || rgb_.count() < static_cast(want) * 3) return; + // size_t, not nrOfLightsType: that type is uint16_t on a board without PSRAM, and the + // product overflows it above 65535 lights (a 640x480 grid is 307200), which would + // silently truncate the frame to its low 16 bits. + const size_t lights = static_cast(srcWidth_) * srcHeight_; + const size_t have = sourceBuffer_->count(); + const size_t n = lights < have ? lights : have; + const size_t frameBytes = static_cast(width_) * height_ * 3; + if (n == 0 || rgb_.count() < frameBytes) return; const uint8_t* src = sourceBuffer_->data(); const uint8_t srcCh = sourceBuffer_->channelsPerLight(); @@ -139,28 +240,55 @@ class HlsDriver : public DriverBase { if (srcCh < 3) return; // a non-color buffer (DMX roles) has no frame to send // Pack tight RGB with the per-driver output correction, exactly as NdiDriver does: the - // stream shows what the wall shows. + // stream shows what the wall shows. At scale_ > 1 each light becomes a solid square + // block, so the picture is the wall magnified rather than interpolated: no new colors + // appear and every light stays individually visible. uint8_t* dst = &rgb_[0]; const bool wide = outCh > 3 && corrScratch_.count() >= outCh; + const size_t rowBytes = static_cast(width_) * 3; for (nrOfLightsType i = 0; i < n; i++) { const uint8_t* s = src + static_cast(i) * srcCh; - uint8_t* d = dst + static_cast(i) * 3; + uint8_t rgb[3]; if (outCh == 3) { - correction_.apply(s, d); + correction_.apply(s, rgb); } else if (wide) { uint8_t* c = &corrScratch_[0]; correction_.apply(s, c); - d[0] = c[0]; d[1] = c[1]; d[2] = c[2]; + rgb[0] = c[0]; rgb[1] = c[1]; rgb[2] = c[2]; } else { - d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; + rgb[0] = s[0]; rgb[1] = s[1]; rgb[2] = s[2]; + } + const lengthType sx = static_cast(i % srcWidth_); + const lengthType sy = static_cast(i / srcWidth_); + // Write the block's first row, then copy it to the rest: one color conversion per + // light, and the replication is memcpy rather than per-pixel work. + uint8_t* row0 = dst + (static_cast(sy) * scale_) * rowBytes + + (static_cast(sx) * scale_) * 3; + for (uint8_t px = 0; px < scale_; px++) { + row0[px * 3 + 0] = rgb[0]; + row0[px * 3 + 1] = rgb[1]; + row0[px * 3 + 2] = rgb[2]; + } + for (uint8_t py = 1; py < scale_; py++) + std::memcpy(row0 + static_cast(py) * rowBytes, row0, + static_cast(scale_) * 3); + } + // Lights the source buffer never supplied leave their blocks black. + if (n < lights) { + for (nrOfLightsType i = n; i < lights; i++) { + const lengthType sx = static_cast(i % srcWidth_); + const lengthType sy = static_cast(i / srcWidth_); + uint8_t* row0 = dst + (static_cast(sy) * scale_) * rowBytes + + (static_cast(sx) * scale_) * 3; + for (uint8_t py = 0; py < scale_; py++) + std::memset(row0 + static_cast(py) * rowBytes, 0, + static_cast(scale_) * 3); } } - if (n < want) std::memset(dst + static_cast(n) * 3, 0, - static_cast(want - n) * 3); // Non-blocking hand-off: a full pipe drops the frame (the encoder is behind, H.264 // carries on from the next one), a dead process schedules a restart from tick1s. - const int wrote = platform::encoderWrite(dst, static_cast(want) * 3); + const int wrote = platform::encoderWrite(dst, frameBytes); if (wrote == 0) droppedFrames_++; else if (wrote < 0) encoderDied_ = true; } @@ -200,50 +328,31 @@ class HlsDriver : public DriverBase { } } - /// The exact argv handed to ffmpeg, exposed for the unit test that pins it: raw RGB in at the - /// grid size, zerolatency x264, 1 s segments with a short rolling playlist (the live tuning - /// that puts glass-to-glass at 2-5 s), segments deleted as they fall off the playlist. - size_t buildArgs(const char* argv[], size_t cap, char* geo, size_t geoCap, - char* rate, size_t rateCap, char* gop, size_t gopCap, - char* bv, size_t bvCap, char* out, size_t outCap) const { - std::snprintf(geo, geoCap, "%ux%u", static_cast(width_), - static_cast(height_)); - std::snprintf(rate, rateCap, "%u", static_cast(targetFps)); - // GOP = one segment: hls_time can only cut on a keyframe, so a 2x-fps GOP silently - // doubles every segment (and the latency) past the 1 s design. - std::snprintf(gop, gopCap, "%u", static_cast(targetFps)); - std::snprintf(bv, bvCap, "%uk", static_cast(bitrateKbit)); - std::snprintf(out, outCap, "%s%s/stream.m3u8", platform::fsRootPath(), kSegmentDir); - // Assembled by index so the x264-only tuning flags stay off other encoders - // (h264_videotoolbox rejects -tune) without duplicated slots. - const char* encoder = kEncoderOptions[encoderSel_ < kEncoderOptionCount ? encoderSel_ : 0]; - const bool x264 = std::strcmp(encoder, "libx264") == 0; - size_t i = 0; - auto add = [&](const char* a) { if (i + 1 < cap) argv[i++] = a; }; - for (const char* a : std::initializer_list{ - "ffmpeg", "-hide_banner", "-loglevel", "error", - "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", geo, - "-r", rate, "-i", "-", - "-c:v", encoder }) add(a); - if (x264) { add("-preset"); add("veryfast"); add("-tune"); add("zerolatency"); } - for (const char* a : std::initializer_list{ - "-g", gop, "-b:v", bv, - "-f", "hls", "-hls_time", "1", "-hls_list_size", "6", - "-hls_flags", "delete_segments+temp_file", out }) add(a); // temp_file: the playlist lands by RENAME, never served half-written - argv[i] = nullptr; - return i; - } - // Controls + /// Video pixels per light, 0 = auto (see autoScale). One factor for both axes, so the + /// aspect ratio is preserved and each light stays a square block. + uint8_t scale = 0; /// Encode-rate ceiling; the render loop runs faster and extra frames are not encoded. uint8_t targetFps = 30; - /// H.264 target bitrate in kbit/s. 8000 carries a 512x512 grid comfortably. - uint16_t bitrateKbit = 8000; /// The ffmpeg encoder pick; hardware entries offload the encode (see the control's comment). uint8_t encoderSel_ = 0; // index into kEncoderOptions; 0 = libx264, the universal default private: static constexpr uint8_t kMaxRestarts = 3; + /// The smallest frame the strictest supported encoder accepts (the P4 hardware block's + /// 80x80 floor; ffmpeg has no such limit, but one rule keeps the platforms interchangeable). + static constexpr uint16_t kMinEncodeSize = 80; + /// The largest frame the strictest supported encoder accepts (the P4 hardware block). + /// Checked against the SCALED geometry, in a wide type: see prepare(). + static constexpr uint16_t kMaxEncodeWidth = 1920; + static constexpr uint16_t kMaxEncodeHeight = 2032; + /// A ceiling on the blow-up, so a slider cannot ask for a frame no encoder will take (the + /// P4 tops out at 1920x2032). It must be at least kMinEncodeSize, or auto could not lift a + /// 1-light-wide wall to the floor and would hand the encoder a frame it refuses -- the + /// feature failing exactly where it is needed most. + static constexpr uint8_t kMaxScale = 80; + static_assert(kMaxScale >= kMinEncodeSize, + "auto-scale must be able to reach the encoder's minimum from a 1-pixel axis"); static constexpr uint32_t kWarmupMs = 750; // encoder init headroom before the first frame // vaapi is deliberately absent: it needs -vaapi_device + hwupload filter plumbing this @@ -258,13 +367,58 @@ class HlsDriver : public DriverBase { }; static constexpr uint8_t kEncoderOptionCount = 4; + /// The smallest whole factor that lifts BOTH axes to the encoder's minimum frame size. The + /// P4's hardware encoder refuses anything under 80x80, and a player showing a 32x32 stream + /// renders a postage stamp, so the default blows a small wall up rather than failing or + /// under-filling. Upscaling is integer, so each light is a solid square and the picture stays + /// exactly what the wall shows -- 1:1 in the sense that matters, just bigger. + uint8_t autoScale() const { + uint32_t f = 1; + while (f < kMaxScale && + (srcWidth_ * f < kMinEncodeSize || srcHeight_ * f < kMinEncodeSize)) f++; + return static_cast(f); + } + + /// The encode bitrate, derived rather than asked for. There is no bitrate control: the value + /// follows from what the picture costs (bits-per-pixel-per-frame x pixels x fps), and the one + /// knob a user actually wants for bandwidth is `targetFps`, which is the more meaningful trade + /// for LED content. 0.1 bpp is the usual working figure for H.264 on this kind of material + /// (flat regions, strong temporal correlation): a 512x512 grid at 30 fps lands near 800 kbit, + /// and a 128x128 under the 500 kbit floor. Clamped so a tiny grid still looks clean and a + /// huge one cannot ask for more than an encoder accepts. + uint16_t autoBitrateKbit() const { + const uint64_t pixels = static_cast(width_) * height_; + const uint64_t kbit = (pixels * targetFps) / 10000u; // 0.1 bpp, expressed in kbit + if (kbit < 500) return 500; + if (kbit > 40000) return 40000; + return static_cast(kbit); + } + bool startEncoder() { - const char* argv[40]; - char geo[16], rate[8], gop[8], bv[12], out[192]; - buildArgs(argv, sizeof(argv) / sizeof(argv[0]), geo, sizeof(geo), rate, sizeof(rate), - gop, sizeof(gop), bv, sizeof(bv), out, sizeof(out)); - if (!platform::encoderStart(argv)) { - setStatus("ffmpeg not found - see the docs", Severity::Warning); + char outDir[192]; + std::snprintf(outDir, sizeof(outDir), "%s%s", platform::fsRootPath(), kSegmentDir); + platform::EncoderConfig cfg{}; + cfg.width = static_cast(width_); + cfg.height = static_cast(height_); + cfg.fps = targetFps; + cfg.bitrateKbit = autoBitrateKbit(); + cfg.encoderName = kEncoderOptions[encoderSel_ < kEncoderOptionCount ? encoderSel_ : 0]; + cfg.outDir = outDir; + if (!platform::encoderStart(cfg)) { + // Why it failed differs per platform, and a wrong reason sends the user hunting: on + // desktop the encoder is an ffmpeg that may not be installed, while a hardware + // encoder is always present and refuses only a frame size it cannot do (the P4's is + // 80x80 to 1920x2032, in even steps). + if constexpr (platform::hasEncoderChoice) { + setStatus("ffmpeg not found - see the docs", Severity::Warning); + } else if (width_ < 80 || height_ < 80 || width_ > 1920 || height_ > 2032) { + std::snprintf(statusBuf_, sizeof(statusBuf_), + "%ux%u is outside the encoder's 80x80 to 1920x2032", + static_cast(width_), static_cast(height_)); + setStatus(statusBuf_, Severity::Warning); + } else { + setStatus("the hardware encoder did not start", Severity::Error); + } return false; } warmupUntilMs_ = platform::millis() + kWarmupMs; @@ -291,8 +445,11 @@ class HlsDriver : public DriverBase { } Buffer* sourceBuffer_ = nullptr; - lengthType width_ = 0; + lengthType srcWidth_ = 0; // the wall + lengthType srcHeight_ = 0; + lengthType width_ = 0; // the encoded frame: the wall times scale_ lengthType height_ = 0; + uint8_t scale_ = 1; // the factor actually in use (the control, or autoScale) // tick() runs on the encode worker while tick1s() runs on the render task: the fields both // touch are atomics (each an independent flag/counter; the default ordering is plenty). std::atomic open_{false}; @@ -301,7 +458,11 @@ class HlsDriver : public DriverBase { uint8_t restartsLeft_ = kMaxRestarts; uint8_t restartWaitS_ = 0; // backoff countdown, in tick1s steps uint8_t healthySecs_ = 0; // sustained-health counter that replenishes the budget - uint32_t lastSendMs_ = 0; // tick-only + // Frame pacing, tick-only. A fixed schedule (epoch + frame count) rather than a + // last-sent timestamp, so the rate is exact and a late tick cannot shift it (see tick()). + uint32_t sendEpochMs_ = 0; + uint32_t nextSendMs_ = 0; + uint32_t frameIndex_ = 0; std::atomic warmupUntilMs_{0}; std::atomic droppedFrames_{0}; uint32_t lastReportedDrops_ = 0; // tick1s-only diff --git a/src/light/drivers/PanelCardDriver.h b/src/light/drivers/PanelCardDriver.h index 54c094c0..9d47efa9 100644 --- a/src/light/drivers/PanelCardDriver.h +++ b/src/light/drivers/PanelCardDriver.h @@ -148,6 +148,9 @@ class PanelCardDriver : public DriverBase { /// configs that stored a typed name load unchanged: the Select apply path matches labels. /// Not built on ESP32, which has one MAC and nothing to pick. uint8_t interfaceSel_ = 0; + /// The LABEL behind interfaceSel_, so a re-enumeration that reorders the list can restore the + /// same NIC rather than whatever now sits at that index. Sized to the enumeration's own cap. + char chosenIf_[64] = {}; /// Send-rate ceiling (Hz); tick() rate-limits so a fast render tick doesn't saturate the link. uint8_t fps = 40; @@ -161,6 +164,34 @@ class PanelCardDriver : public DriverBase { if constexpr (platform::hasNamedNetInterfaces) { const char* const* ifOptions = nullptr; const size_t n = platform::rawInterfaces(&ifOptions); + // The list is re-enumerated on every rebuild, and the OS does not promise a stable + // order: a hot-plugged NIC can shift the rest. The INDEX is therefore meaningless + // across rebuilds, so re-point it at the label the user actually picked before the + // Select binds to it. Persisting by label (below) covers reboots; this covers the + // same list changing under a running session, which would otherwise silently send + // panel data out of a different adapter. + if (chosenIf_[0] && ifOptions) { + // Compare the STABLE HEAD, the part before ", ": a label may carry the adapter's + // live link speed after it ("Realtek PCIe GbE, 1 Gb"), and a renegotiated link + // would otherwise read as a different NIC and drop the selection to row 0. + const char* mySep = std::strstr(chosenIf_, ", "); + const size_t mine = mySep ? static_cast(mySep - chosenIf_) + : std::strlen(chosenIf_); + // Back to capture-only FIRST: if the remembered adapter is gone, the old index + // now points at whatever took its place, and the driver would send panel data + // out of a NIC the user never chose. No match means no NIC, explicitly. + interfaceSel_ = 0; + for (size_t i = 0; i < n && i < 255; i++) { + if (!ifOptions[i]) continue; + const char* sep = std::strstr(ifOptions[i], ", "); + const size_t head = sep ? static_cast(sep - ifOptions[i]) + : std::strlen(ifOptions[i]); + if (head == mine && std::strncmp(ifOptions[i], chosenIf_, head) == 0) { + interfaceSel_ = static_cast(i); + break; + } + } + } controls_.addSelect("interface", interfaceSel_, ifOptions, static_cast(n < 255 ? n : 255)); controls_.setPersistLabel(controls_.count() - 1); @@ -206,8 +237,15 @@ class PanelCardDriver : public DriverBase { // failure is a Warning rather than an Error: the driver still runs and still records frames, // which is what a test or a dry run wants — it just is not driving panels. const char* ifName = nullptr; - if constexpr (platform::hasNamedNetInterfaces) + if constexpr (platform::hasNamedNetInterfaces) { + // Remember the adapter behind the current row, so a later rebuild that re-enumerates + // in a different order can find this same NIC again rather than trusting the index. + const char* const* ifOptions = nullptr; + const size_t n = platform::rawInterfaces(&ifOptions); + if (ifOptions && interfaceSel_ < n && ifOptions[interfaceSel_]) + std::snprintf(chosenIf_, sizeof(chosenIf_), "%s", ifOptions[interfaceSel_]); ifName = platform::rawInterfaceName(interfaceSel_); + } if (!platform::ethBindRawInterface(ifName)) { // Two very different causes reach here and the fixes are opposite: a name that matches // no adapter (a typo, or an OS naming the NIC differently) versus the privilege raw L2 diff --git a/src/light/effects/FishTankEffect.h b/src/light/effects/FishTankEffect.h new file mode 100644 index 00000000..a8b62f09 --- /dev/null +++ b/src/light/effects/FishTankEffect.h @@ -0,0 +1,337 @@ +#pragma once +// Fish tank: an aquarium screensaver. Fish of several species swim across a dark tank, each +// tinted from the active palette, the smaller ones drifting slower as if further back. +// +// Inspired by the aquarium screensavers of the After Dark era (and the SereneScreen Marine +// Aquarium that followed). Inspiration only: the art below is drawn fresh for this effect. +// +// The construction is the sprites-spec's division of labor, the same one FlyingToastersEffect +// uses: movement is the existing particles::Pool (constant velocity, zero forces, respawn-wrap), +// appearance is the stateless draw::sprite power function. What differs is COLOR. A Sprite holds +// a pointer to its palette rather than owning one, so each fish is drawn through a palette this +// effect fills per fish from the user's active palette: one sprite shape, as many colorways as +// there are fish. That is why the art below is a shape with SHADE indices (body, dark, light) +// rather than fixed colors. +// Author: projectMM original + +#include "core/math16.h" // BeatPhase: the shared tail-beat clock +#include "core/math8.h" // Random8: fixed-seed spawn variation, golden-reproducible +#include "light/effects/EffectBase.h" +#include "light/particles.h" // Pool: the movable-things kernel the fish ride + +namespace mm { + +namespace fishart { + +// Palette LAYOUT, not colors: every fish sprite indexes these slots and the effect fills them +// per fish (see FishTankEffect::paletteFor). Slot 0 is the transparent key draw::sprite skips. +enum : uint8_t { kClear = 0, kBody = 1, kDark = 2, kLight = 3, kFin = 4, kEye = 5, kBand = 6 }; +inline constexpr uint8_t kPaletteCount = 7; + +// A broad reef fish (the angelfish/clownfish build): tall, blunt-nosed, a deep body with a +// vertical band. 16x11, 3 frames of tail beat. Drawn facing RIGHT; draw::sprite's flipX serves +// the other direction, so one drawing swims both ways. +inline constexpr uint8_t W = 16, H = 11, F = 3; +inline constexpr uint8_t kFish[] = { + // frame 0: tail spread + 0,0,0,0,0,0,2,2,2,2,0,0,0,0,0,0, + 0,0,0,0,2,2,1,1,6,1,2,2,0,0,0,0, + 0,0,4,4,2,1,1,6,6,1,1,1,2,0,0,0, + 0,4,4,4,2,1,1,6,6,1,1,1,1,2,0,0, + 0,0,4,2,1,1,6,6,1,1,1,1,1,1,2,0, + 2,2,2,1,1,1,6,6,1,1,1,3,5,1,1,2, + 0,0,4,2,1,1,6,6,1,1,1,1,1,1,2,0, + 0,4,4,4,2,1,1,6,6,1,1,1,1,2,0,0, + 0,0,4,4,2,1,1,6,6,1,1,1,2,0,0,0, + 0,0,0,0,2,2,1,1,6,1,2,2,0,0,0,0, + 0,0,0,0,0,0,2,2,2,2,0,0,0,0,0,0, + // frame 1: tail up + 0,0,0,0,0,0,2,2,2,2,0,0,0,0,0,0, + 0,0,4,0,2,2,1,1,6,1,2,2,0,0,0,0, + 0,4,4,4,2,1,1,6,6,1,1,1,2,0,0,0, + 0,0,4,4,2,1,1,6,6,1,1,1,1,2,0,0, + 0,0,4,2,1,1,6,6,1,1,1,1,1,1,2,0, + 2,2,2,1,1,1,6,6,1,1,1,3,5,1,1,2, + 0,0,2,2,1,1,6,6,1,1,1,1,1,1,2,0, + 0,0,0,2,2,1,1,6,6,1,1,1,1,2,0,0, + 0,0,0,0,2,1,1,6,6,1,1,1,2,0,0,0, + 0,0,0,0,2,2,1,1,6,1,2,2,0,0,0,0, + 0,0,0,0,0,0,2,2,2,2,0,0,0,0,0,0, + // frame 2: tail down + 0,0,0,0,0,0,2,2,2,2,0,0,0,0,0,0, + 0,0,0,0,2,2,1,1,6,1,2,2,0,0,0,0, + 0,0,0,2,2,1,1,6,6,1,1,1,2,0,0,0, + 0,0,2,2,1,1,1,6,6,1,1,1,1,2,0,0, + 0,0,4,2,1,1,6,6,1,1,1,1,1,1,2,0, + 2,2,2,1,1,1,6,6,1,1,1,3,5,1,1,2, + 0,0,4,2,1,1,6,6,1,1,1,1,1,1,2,0, + 0,0,4,4,2,1,1,6,6,1,1,1,1,2,0,0, + 0,4,4,4,2,1,1,6,6,1,1,1,2,0,0,0, + 0,0,4,0,2,2,1,1,6,1,2,2,0,0,0,0, + 0,0,0,0,0,0,2,2,2,2,0,0,0,0,0,0, +}; +static_assert(sizeof(kFish) == static_cast(W) * H * F, "fish: 3 frames of 16x11"); + +// A slender fish (the tetra/danio build): long, low, a forked tail. A different SILHOUETTE, not +// a recolor: a tank of one outline reads as a repeat however the colors vary. 13x7, 3 frames. +inline constexpr uint8_t SW = 13, SH = 7, SF = 3; +inline constexpr uint8_t kSlim[] = { + 0,0,0,0,0,2,2,2,2,2,0,0,0, + 0,4,2,2,2,1,1,1,1,1,2,2,0, + 4,4,2,1,1,1,1,1,1,1,1,1,2, + 4,2,1,1,1,1,1,1,1,3,5,1,2, + 4,4,2,1,1,1,1,1,1,1,1,1,2, + 0,4,2,2,2,1,1,1,1,1,2,2,0, + 0,0,0,0,0,2,2,2,2,2,0,0,0, + // tail up + 0,0,4,0,0,2,2,2,2,2,0,0,0, + 0,4,4,2,2,1,1,1,1,1,2,2,0, + 0,0,2,1,1,1,1,1,1,1,1,1,2, + 0,2,1,1,1,1,1,1,1,3,5,1,2, + 0,0,2,1,1,1,1,1,1,1,1,1,2, + 0,0,2,2,2,1,1,1,1,1,2,2,0, + 0,0,0,0,0,2,2,2,2,2,0,0,0, + // tail down + 0,0,0,0,0,2,2,2,2,2,0,0,0, + 0,0,2,2,2,1,1,1,1,1,2,2,0, + 0,0,2,1,1,1,1,1,1,1,1,1,2, + 0,2,1,1,1,1,1,1,1,3,5,1,2, + 0,0,2,1,1,1,1,1,1,1,1,1,2, + 0,4,4,2,2,1,1,1,1,1,2,2,0, + 0,0,4,0,0,2,2,2,2,2,0,0,0, +}; +static_assert(sizeof(kSlim) == static_cast(SW) * SH * SF, "slim: 3 frames of 13x7"); + +// A tiny schooling fish: 6x4, one frame. Too small for a tail beat to read, and a school is +// several of these moving together, the shape the reference image's cluster has. +inline constexpr uint8_t TW = 6, TH = 4; +inline constexpr uint8_t kTiny[] = { + 0,0,2,2,2,0, + 4,2,1,1,1,2, + 4,2,1,5,1,2, + 0,0,2,2,2,0, +}; +static_assert(sizeof(kTiny) == static_cast(TW) * TH, "tiny: one 6x4 frame"); + +} // namespace fishart + +/// Effect: colorful fish swim across a dark tank, each tinted from the active palette. +/// @card FishTankEffect.gif +class FishTankEffect : public EffectBase { +public: + static constexpr uint8_t kPool = 24; // the control maxima, summed + + const char* tags() const override { return "📊"; } // audio-reactive when soundReactive is set + Dim dimensions() const override { return Dim::D2; } + + /// How many of each swim, and how fast. + uint8_t fish = 3; // the broad tropical shape + uint8_t slim = 3; // the slender shape + uint8_t tiny = 5; // the school + uint8_t speed = 80; + uint8_t spriteSize = 0; // 0 = auto: scale with the grid, as FlyingToasters does + bool soundReactive = false; // move to the music: each sprite on its own band, still in silence + + void defineControls() override { + controls_.addControl("fish", fish, 0, 8); + controls_.addControl("slim", slim, 0, 8); + controls_.addControl("school", tiny, 0, 8); + controls_.addControl("speed", speed, 1, 255); + controls_.addControl("spriteSize", spriteSize, 0, 12); + controls_.addControl("soundReactive", soundReactive); + } + + void prepare() override { + const bool ok = x_.resize(kPool) && y_.resize(kPool) && vx_.resize(kPool) && + vy_.resize(kPool) && ttl_.resize(kPool) && kind_.resize(kPool) && + entry_.resize(kPool); + if (!ok) { pool_ = particles::Pool{}; return; } + pool_ = particles::Pool{}; + pool_.x = &x_[0]; pool_.y = &y_[0]; + pool_.vx = &vx_[0]; pool_.vy = &vy_[0]; + pool_.ttl = &ttl_[0]; + pool_.hue = &kind_[0]; // the SPECIES; the palette entry has its own array (entry_) + pool_.count = kPool; + pool_.clear(); + rng_.seed(kSeed); + for (uint16_t i = 0; i < wanted(); i++) launch(i, /*anywhere=*/true); + time_.reset(); + beat_ = BeatPhase{}; + } + + /// The sprite magnification: the `spriteSize` control, or grid-proportional when 0, so a fish + /// reads as a fish on a 768-wide desktop grid AND on a 16x16 matrix (where x1 already fills + /// most of the width). Same rule as FlyingToasters, so the two agree on any wall. + uint8_t spriteScale() const { + if (spriteSize > 0) return spriteSize; + const lengthType m = width() < height() ? width() : height(); + const lengthType autoScale = m / 40; + return static_cast(autoScale < 1 ? 1 : (autoScale > 12 ? 12 : autoScale)); + } + + void tick() MM_NONBLOCKING override { + const draw::Canvas cv = canvas(); + const lengthType w = width(); + // No grid-size guard (the no-grid-guards rule): draw::sprite clips per pixel. + if (!pool_.valid()) return; + const uint8_t sc = spriteScale(); + + draw::fill(cv, RGB{0, 0, 0}); + + const uint32_t scale = time_.advance(elapsed()); + if (scale > 0) pool_.stepDriven(scale, soundReactive, wanted()); + + // One shared tail-beat clock, offset per fish so the tank never pulses in unison. + beat_.advance(elapsed(), 200); + + syncPopulation(); + + for (uint16_t i = 0; i < pool_.count; i++) { + if (!pool_.ttl[i]) continue; + const lengthType px = draw::toPixel(pool_.x[i]); + const lengthType py = draw::toPixel(pool_.y[i]); + const uint8_t species = pool_.hue[i]; + const uint8_t entry = entry_[i]; // the FULL byte: 256 places on the palette + + // Swum off the left edge: respawn at the right, a new fish in the same slot. + if (px < -static_cast(fishart::W) * sc) { launch(i, /*anywhere=*/false); continue; } + if (px > w + fishart::W * sc) { launch(i, /*anywhere=*/false); continue; } + + RGB pal[fishart::kPaletteCount]; + paletteFor(entry, pal); + const uint8_t frame = static_cast((beat_.phase(3) + (i * 5) % 3) % 3); + // The art faces RIGHT, so a fish swimming left is drawn mirrored. A tank where every + // fish faces the same way regardless of travel reads as wallpaper, not as swimming. + const bool flip = pool_.vx[i] < 0; + + if (species == kTiny) { + const draw::sprites::Sprite s{fishart::kTiny, pal, fishart::TW, fishart::TH, 1, + fishart::kPaletteCount}; + draw::sprite(cv, s, 0, px, py, sc, flip); + } else if (species == kSlim) { + const draw::sprites::Sprite s{fishart::kSlim, pal, fishart::SW, fishart::SH, + fishart::SF, fishart::kPaletteCount}; + draw::sprite(cv, s, frame, px, py, sc, flip); + } else { + const draw::sprites::Sprite s{fishart::kFish, pal, fishart::W, fishart::H, + fishart::F, fishart::kPaletteCount}; + draw::sprite(cv, s, frame, px, py, sc, flip); + } + } + } + +private: + enum : uint8_t { kBroad = 0, kSlim = 1, kTiny = 2 }; + static constexpr uint32_t kSeed = 0x0F157A9Bu; + + /// Fill a sprite palette for one fish from the ACTIVE palette. The sprite art carries shade + /// roles (body / dark / light / fin / eye / band) rather than colors, so one shape yields as + /// many colorways as there are palette entries: the reference aquarium's appeal is the mix, + /// and a tank of identically colored fish is not that. + void paletteFor(uint8_t entry, RGB (&pal)[fishart::kPaletteCount]) const { + const RGB body = colorFromPalette(*Palettes::active(), entry); + pal[fishart::kClear] = RGB{0, 0, 0}; // never read + pal[fishart::kBody] = body; + pal[fishart::kDark] = blend(body, RGB{0, 0, 0}, 150); // outline / shading + pal[fishart::kLight] = blend(body, RGB{255, 255, 255}, 120); + pal[fishart::kFin] = blend(body, RGB{255, 255, 255}, 60); + pal[fishart::kEye] = RGB{20, 20, 24}; + // The band is the fish's marking: a much paler version of its own color, the way a + // clownfish's white band works. A second palette PICK was tried and read as two fish + // fused together, because an arbitrary entry clashes rather than contrasts. + pal[fishart::kBand] = blend(body, RGB{255, 255, 255}, 200); + } + + uint16_t wanted() const { + const uint16_t n = static_cast(fish) + slim + tiny; + return n > kPool ? kPool : n; + } + + /// Top up or retire slots when a count control changes, live, without a re-prepare. + void syncPopulation() { + const uint16_t want = wanted(); + uint16_t alive = 0; + for (uint16_t i = 0; i < pool_.count; i++) if (pool_.ttl[i]) alive++; + for (uint16_t i = 0; i < pool_.count && alive < want; i++) + if (!pool_.ttl[i]) { launch(i, /*anywhere=*/true); alive++; } + for (uint16_t i = pool_.count; i-- > 0 && alive > want;) + if (pool_.ttl[i]) { pool_.ttl[i] = 0; alive--; } + + // Trading one species for another leaves the TOTAL unchanged, so nothing above respawns + // and the slots keep the species they launched with: the controls would say four slim + // fish while the tank still swam four broad ones. Restock only the slots whose species no + // longer matches the moved boundary; the rest keep their positions and momentum. + for (uint16_t i = 0; i < pool_.count; i++) + if (pool_.ttl[i] && pool_.hue[i] != speciesFor(i)) launch(i, /*anywhere=*/true); + } + + /// The species slot `i` should hold: the first `fish` slots are broad, the next `slim` + /// slender, the rest the school. One home for the rule launch() applies. + uint8_t speciesFor(uint16_t i) const { + if (i < fish) return kBroad; + if (i < static_cast(fish) + slim) return kSlim; + return kTiny; + } + + /// Put fish `i` into the tank: a species by slot order, a palette entry of its own, a speed + /// that follows its size (a small fish drifts slower, which reads as depth). + void launch(uint16_t i, bool anywhere) { + const lengthType w = width(), h = height(); + const uint8_t sc = spriteScale(); + + // Species by slot: the first `fish` slots are broad, the next `slim` slender, the rest + // the school. Keeping it positional means a count change moves one boundary, and the + // fish already in the tank keep their identity. + const uint8_t species = speciesFor(i); + pool_.hue[i] = species; + entry_[i] = rng_.next8(); // its own place on the palette, full 8-bit spread + + const lengthType sw = species == kBroad ? fishart::W + : species == kSlim ? fishart::SW : fishart::TW; + + // Right-to-left, the direction the art faces. Speed scales with the sprite so the motion + // READS the same on any grid (same body-lengths per second), and with the species so the + // small ones trail behind. + const int32_t base = static_cast(speed) * sc * + (species == kBroad ? 3 : species == kSlim ? 2 : 1) / 2; + const int32_t vary = base / 4; + const uint32_t span = static_cast(vary) * 2; + const int32_t v = base - vary + (span > 0 ? static_cast(rng_.next16() % span) : 0); + + // Half swim each way. Direction is picked per fish, and the spawn edge follows it, so a + // fish always enters from the side it is heading away from. + const bool leftward = (rng_.next8() & 1) != 0; + pool_.vx[i] = static_cast(leftward ? -v : v); + // A slight vertical drift, so the tank does not read as horizontal lanes. + pool_.vy[i] = static_cast(static_cast(rng_.next8()) - 128) / 16; + + // On the initial fill, STRIDE across the width rather than scattering: uniform random + // x clumps, and two fish a few pixels apart read as one shape rather than two. Later + // respawns enter from the edge the fish faces. + const uint16_t slots = wanted() ? wanted() : 1; + // A stride by SLOT sorts the tank by species, and since species differ in speed the fast + // ones bunch at one edge within seconds. spreadLane interleaves them, with a step chosen + // coprime to the count so the lanes stay distinct however many fish there are. + const lengthType lane = particles::spreadLane(i, slots, w); + pool_.x[i] = draw::toSub(anywhere + ? static_cast(lane + static_cast(rng_.next8() % 16) - 8) + : (leftward ? static_cast(w + sw * sc) + : static_cast(-sw * sc))); + // Vertical lanes too, so the tank fills top to bottom instead of banding. + const lengthType vlane = particles::spreadLane(static_cast(i * 2), slots, h); + pool_.y[i] = draw::toSub(static_cast( + anywhere ? vlane : static_cast(rng_.next16() % (h > 0 ? h : 1)))); + pool_.ttl[i] = 0xFFFF; // fish leave by swimming out, not by expiring + } + + particles::Pool pool_; + ScratchBuffer x_{*this}, y_{*this}, vx_{*this}, vy_{*this}; + ScratchBuffer ttl_{*this}; + ScratchBuffer kind_{*this}; // species + ScratchBuffer entry_{*this}; // palette entry, one per fish + particles::FrameTime time_; + BeatPhase beat_; + Random8 rng_; +}; + +} // namespace mm diff --git a/src/light/effects/FlyingToastersEffect.h b/src/light/effects/FlyingToastersEffect.h index c3e22ec0..c83eb62a 100644 --- a/src/light/effects/FlyingToastersEffect.h +++ b/src/light/effects/FlyingToastersEffect.h @@ -104,7 +104,7 @@ class FlyingToastersEffect : public EffectBase { public: static constexpr uint8_t kPool = 20; // 12 toasters + 8 toast, the control maxima - const char* tags() const override { return "🔬"; } + const char* tags() const override { return "🔬📊"; } // audio-reactive when soundReactive is set Dim dimensions() const override { return Dim::D2; } /// How many of each fly, and how fast the flock drifts. @@ -112,12 +112,14 @@ class FlyingToastersEffect : public EffectBase { uint8_t toast = 3; uint8_t speed = 96; uint8_t spriteSize = 0; // 0 = auto: scale with the grid; both toasters and toast use it + bool soundReactive = false; // move to the music: each sprite on its own band, still in silence void defineControls() override { controls_.addControl("toasters", toasters, 1, 12); controls_.addControl("toast", toast, 0, 8); controls_.addControl("speed", speed, 1, 255); controls_.addControl("spriteSize", spriteSize, 0, 12); + controls_.addControl("soundReactive", soundReactive); } void prepare() override { @@ -158,7 +160,7 @@ class FlyingToastersEffect : public EffectBase { draw::fill(cv, RGB{0, 0, 0}); const uint32_t scale = time_.advance(elapsed()); - if (scale > 0) pool_.step(scale); + if (scale > 0) pool_.stepDriven(scale, soundReactive, wanted()); // The wing flap: one shared BeatPhase, offset per toaster so the flock never syncs. flap_.advance(elapsed(), 180); // ~3 flaps per second across the 4-frame cycle @@ -200,7 +202,11 @@ class FlyingToastersEffect : public EffectBase { // per second, more pixels per second on a big wall). const int32_t base = static_cast(speed) * 2 * sc; const int32_t vary = base / 4; - const int32_t v = base - vary + rng_.below(static_cast(vary * 2 > 255 ? 255 : vary * 2)); + // next16, not below(uint8_t): the span is base/2, which passes 255 at any real sprite + // scale, and an 8-bit draw would silently clamp it: every large toaster flying at + // almost exactly the same speed instead of the documented +-25%. + const uint32_t span = static_cast(vary) * 2; + const int32_t v = base - vary + (span > 0 ? static_cast(rng_.next16() % span) : 0); draw::pos_t px, py; if (anywhere) { px = draw::toSub(static_cast(rng_.next16() % (w > 0 ? w : 1))); diff --git a/src/light/effects/PacmanEffect.h b/src/light/effects/PacmanEffect.h new file mode 100644 index 00000000..3e3d1c5a --- /dev/null +++ b/src/light/effects/PacmanEffect.h @@ -0,0 +1,311 @@ +#pragma once +// Pacman: the arcade cast crossing the wall. Pacman chomps along with the four ghosts, each in +// its own color, wrapping around the edges forever. +// +// ITERATION 1 (this one): the characters move independently and do not notice each other. The +// maze, the pellets and the chase are iteration 2; the shapes, the animation and the movement +// grid below are what that will be built on, so the step is a foundation rather than a mock-up. +// +// Inspired by Namco's Pac-Man (1980). Inspiration only: the pixel art is drawn fresh for this +// effect, at a size and palette of its own. +// +// The construction follows FlyingToastersEffect and FishTankEffect: movement is a particles::Pool +// entry per character (constant velocity, wrap at the edges), appearance is the stateless +// draw::sprite power function. Pacman's chomp and the ghosts' foot-shuffle are one shared +// BeatPhase, offset per character so the cast does not pulse in unison. +// Author: projectMM original + +#include "core/math16.h" // BeatPhase: the chomp / shuffle clock +#include "core/math8.h" // Random8: fixed-seed spawn variation, golden-reproducible +#include "light/effects/EffectBase.h" +#include "light/particles.h" // Pool: the movable-things kernel the cast rides + +namespace mm { + +namespace pacart { + +// Palette LAYOUT: the sprites index these and the effect fills them per character, so a ghost is +// one drawing in four colors rather than four drawings. Slot 0 is draw::sprite's transparent key. +enum : uint8_t { kClear = 0, kBody = 1, kEye = 2, kPupil = 3, kDark = 4 }; +inline constexpr uint8_t kPaletteCount = 5; + +// Pacman: 11x11, 4 frames of chomp (open, half, closed, half). Drawn facing RIGHT; draw::sprite's +// flipX serves leftward travel, so one drawing walks both ways. +inline constexpr uint8_t W = 11, H = 11, F = 4; +inline constexpr uint8_t kPac[] = { + // frame 0: mouth wide + 0,0,0,1,1,1,1,1,0,0,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,1,1,1,1,1,1,1,0,0,0, + 1,1,1,1,1,1,1,0,0,0,0, + 1,1,1,1,1,1,0,0,0,0,0, + 1,1,1,1,1,0,0,0,0,0,0, + 1,1,1,1,1,1,0,0,0,0,0, + 1,1,1,1,1,1,1,0,0,0,0, + 0,1,1,1,1,1,1,1,0,0,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,0,0,1,1,1,1,1,0,0,0, + // frame 1: mouth half + 0,0,0,1,1,1,1,1,0,0,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,1,1,1,1,1,1,1,1,1,0, + 1,1,1,1,1,1,1,1,1,0,0, + 1,1,1,1,1,1,1,1,0,0,0, + 1,1,1,1,1,1,1,0,0,0,0, + 1,1,1,1,1,1,1,1,0,0,0, + 1,1,1,1,1,1,1,1,1,0,0, + 0,1,1,1,1,1,1,1,1,1,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,0,0,1,1,1,1,1,0,0,0, + // frame 2: mouth closed (a full disc) + 0,0,0,1,1,1,1,1,0,0,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,1,1,1,1,1,1,1,1,1,0, + 1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 0,1,1,1,1,1,1,1,1,1,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,0,0,1,1,1,1,1,0,0,0, + // frame 3: mouth half again (the return stroke) + 0,0,0,1,1,1,1,1,0,0,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,1,1,1,1,1,1,1,1,1,0, + 1,1,1,1,1,1,1,1,1,0,0, + 1,1,1,1,1,1,1,1,0,0,0, + 1,1,1,1,1,1,1,0,0,0,0, + 1,1,1,1,1,1,1,1,0,0,0, + 1,1,1,1,1,1,1,1,1,0,0, + 0,1,1,1,1,1,1,1,1,1,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,0,0,1,1,1,1,1,0,0,0, +}; +static_assert(sizeof(kPac) == static_cast(W) * H * F, "pacman: 4 frames of 11x11"); + +// A ghost: 11x11, 2 frames whose skirt alternates, which is the arcade original's whole walk +// animation. The eyes look RIGHT; flipX mirrors them with the body. +inline constexpr uint8_t GW = 11, GH = 11, GF = 2; +inline constexpr uint8_t kGhost[] = { + // frame 0: skirt down-up-down + 0,0,0,1,1,1,1,1,0,0,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,1,1,1,1,1,1,1,1,1,0, + 0,1,2,2,1,1,2,2,1,1,0, + 1,1,2,2,2,1,2,2,2,1,1, + 1,1,2,3,3,1,2,3,3,1,1, + 1,1,2,3,3,1,2,3,3,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 1,0,1,1,0,1,1,0,1,1,0, + // frame 1: skirt up-down-up + 0,0,0,1,1,1,1,1,0,0,0, + 0,0,1,1,1,1,1,1,1,0,0, + 0,1,1,1,1,1,1,1,1,1,0, + 0,1,2,2,1,1,2,2,1,1,0, + 1,1,2,2,2,1,2,2,2,1,1, + 1,1,2,3,3,1,2,3,3,1,1, + 1,1,2,3,3,1,2,3,3,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 1,1,1,1,1,1,1,1,1,1,1, + 0,1,1,0,1,1,0,1,1,0,1, +}; +static_assert(sizeof(kGhost) == static_cast(GW) * GH * GF, "ghost: 2 frames of 11x11"); + +} // namespace pacart + +/// Effect: Pacman and the ghosts cross the wall, chomping. +/// @card PacmanEffect.gif +class PacmanEffect : public EffectBase { +public: + static constexpr uint8_t kPool = 12; + + const char* tags() const override { return "🔬📊"; } // audio-reactive when soundReactive is set + Dim dimensions() const override { return Dim::D2; } + + /// How many of each, and how fast they travel. + uint8_t pacmen = 1; + uint8_t ghosts = 4; // the arcade cast: Blinky, Pinky, Inky, Clyde + uint8_t speed = 96; + uint8_t spriteSize = 0; // 0 = auto: scale with the grid + bool soundReactive = false; // move to the music: each sprite on its own band, still in silence + + void defineControls() override { + controls_.addControl("pacmen", pacmen, 0, 4); + controls_.addControl("ghosts", ghosts, 0, 8); + controls_.addControl("speed", speed, 1, 255); + controls_.addControl("spriteSize", spriteSize, 0, 12); + controls_.addControl("soundReactive", soundReactive); + } + + void prepare() override { + const bool ok = x_.resize(kPool) && y_.resize(kPool) && vx_.resize(kPool) && + vy_.resize(kPool) && ttl_.resize(kPool) && kind_.resize(kPool); + if (!ok) { pool_ = particles::Pool{}; return; } + pool_ = particles::Pool{}; + pool_.x = &x_[0]; pool_.y = &y_[0]; + pool_.vx = &vx_[0]; pool_.vy = &vy_[0]; + pool_.ttl = &ttl_[0]; + pool_.hue = &kind_[0]; // 0 = pacman, 1..N = ghost color index + pool_.count = kPool; + pool_.clear(); + rng_.seed(kSeed); + for (uint16_t i = 0; i < wanted(); i++) launch(i, /*anywhere=*/true); + time_.reset(); + chomp_ = BeatPhase{}; + } + + /// Sprite magnification: the control, or grid-proportional when 0, so the cast reads on a + /// 16x16 matrix and on a 768-wide desktop grid alike. Same rule as the other sprite effects. + uint8_t spriteScale() const { + if (spriteSize > 0) return spriteSize; + const lengthType m = width() < height() ? width() : height(); + const lengthType autoScale = m / 40; + return static_cast(autoScale < 1 ? 1 : (autoScale > 12 ? 12 : autoScale)); + } + + void tick() MM_NONBLOCKING override { + const draw::Canvas cv = canvas(); + const lengthType w = width(); + if (!pool_.valid()) return; + const uint8_t sc = spriteScale(); + + draw::fill(cv, RGB{0, 0, 0}); + + const uint32_t scale = time_.advance(elapsed()); + if (scale > 0) pool_.stepDriven(scale, soundReactive, wanted()); + + // One clock for the chomp AND the ghosts' shuffle: in the arcade they run at the same + // rate, and a single phase keeps them in step without a second accumulator. + chomp_.advance(elapsed(), 420); + + syncPopulation(); + + for (uint16_t i = 0; i < pool_.count; i++) { + if (!pool_.ttl[i]) continue; + const lengthType px = draw::toPixel(pool_.x[i]); + const lengthType py = draw::toPixel(pool_.y[i]); + const uint8_t role = pool_.hue[i]; + + // Off an edge: re-enter from the far side. The arcade maze wraps through its tunnel, + // and a cast that vanished for good would empty the wall within a minute. + if (px < -static_cast(pacart::W) * sc || px > w + pacart::W * sc) { + launch(i, /*anywhere=*/false); + continue; + } + + RGB pal[pacart::kPaletteCount]; + const bool flip = pool_.vx[i] < 0; + + if (role == 0) { + pacmanPalette(pal); + const draw::sprites::Sprite s{pacart::kPac, pal, pacart::W, pacart::H, + pacart::F, pacart::kPaletteCount}; + draw::sprite(cv, s, static_cast(chomp_.phase(4) & 0x03), px, py, sc, flip); + } else { + ghostPalette(static_cast(role - 1), pal); + const draw::sprites::Sprite s{pacart::kGhost, pal, pacart::GW, pacart::GH, + pacart::GF, pacart::kPaletteCount}; + const uint8_t f = static_cast((chomp_.phase(2) + i) & 0x01); + draw::sprite(cv, s, f, px, py, sc, flip); + } + } + } + +private: + static constexpr uint32_t kSeed = 0x9AC3A17Du; + + /// Pacman is yellow, the one color in this cast that is not negotiable: a differently colored + /// Pacman is not Pacman. The palette drives the ghosts instead (see ghostPalette). + void pacmanPalette(RGB (&pal)[pacart::kPaletteCount]) const { + pal[pacart::kClear] = RGB{0, 0, 0}; + pal[pacart::kBody] = RGB{255, 214, 0}; + pal[pacart::kEye] = RGB{0, 0, 0}; + pal[pacart::kPupil] = RGB{0, 0, 0}; + pal[pacart::kDark] = RGB{140, 118, 0}; + } + + /// A ghost takes its body color from the active palette, spread so four ghosts land far apart + /// on it rather than in one narrow arc. The eyes stay white with dark pupils, which is what + /// makes a colored blob read as a ghost at all. + void ghostPalette(uint8_t which, RGB (&pal)[pacart::kPaletteCount]) const { + pal[pacart::kClear] = RGB{0, 0, 0}; + pal[pacart::kBody] = colorFromPalette(*Palettes::active(), + static_cast(which * 64 + 16)); + pal[pacart::kEye] = RGB{255, 255, 255}; + pal[pacart::kPupil] = RGB{30, 30, 160}; + pal[pacart::kDark] = blend(pal[pacart::kBody], RGB{0, 0, 0}, 150); + } + + uint16_t wanted() const { + const uint16_t n = static_cast(pacmen) + ghosts; + return n > kPool ? kPool : n; + } + + /// Top up or retire slots when a count control changes, live, without a re-prepare. + void syncPopulation() { + const uint16_t want = wanted(); + uint16_t alive = 0; + for (uint16_t i = 0; i < pool_.count; i++) if (pool_.ttl[i]) alive++; + for (uint16_t i = 0; i < pool_.count && alive < want; i++) + if (!pool_.ttl[i]) { launch(i, /*anywhere=*/true); alive++; } + for (uint16_t i = pool_.count; i-- > 0 && alive > want;) + if (pool_.ttl[i]) { pool_.ttl[i] = 0; alive--; } + + // Trading ghosts for Pacmen leaves the TOTAL unchanged, so nothing above respawns and the + // slots keep the roles they launched with: the controls would say three Pacmen while the + // wall still showed one. Re-role the live slots that sit on the wrong side of the moved + // boundary, and only those, so the rest keep their positions and momentum. + for (uint16_t i = 0; i < pool_.count; i++) + if (pool_.ttl[i] && pool_.hue[i] != roleFor(i)) launch(i, /*anywhere=*/true); + } + + /// The role slot `i` should hold: slots are positional, so the first `pacmen` are Pacman and + /// the rest cycle through the four ghost colors. One home for the rule launch() applies. + uint8_t roleFor(uint16_t i) const { + return (i < pacmen) ? 0 : static_cast(1 + ((i - pacmen) & 0x03)); + } + + /// Put character `i` on the wall. Slots are positional: the first `pacmen` are Pacman, the + /// rest ghosts, so changing a count moves one boundary and leaves the others as they were. + void launch(uint16_t i, bool anywhere) { + const lengthType w = width(), h = height(); + const uint8_t sc = spriteScale(); + + const uint8_t role = roleFor(i); + pool_.hue[i] = role; + + // Speed scales with the sprite so travel READS the same on any grid, and Pacman is a + // touch quicker than the ghosts, as in the arcade. + const int32_t base = static_cast(speed) * sc * (role == 0 ? 5 : 4) / 4; + const bool leftward = (rng_.next8() & 1) != 0; + pool_.vx[i] = static_cast(leftward ? -base : base); + pool_.vy[i] = 0; // ITERATION 1: straight lines. The maze in iteration 2 turns them. + + const uint16_t slots = wanted() ? wanted() : 1; + // Rows, not a scatter: the cast reads as characters travelling lanes, and two sprites on + // the same pixels read as one shape. particles::spreadLane keeps the lanes distinct at + // any count (a naive `(i * 5) % slots` collapses to row 0 when slots is 5). + const lengthType row = particles::spreadLane(i, slots, + static_cast(h - pacart::H * sc)); + pool_.x[i] = draw::toSub(anywhere + ? particles::spreadLane(static_cast(i * 2), slots, w) + : (leftward ? static_cast(w + pacart::W * sc) + : static_cast(-pacart::W * sc))); + pool_.y[i] = draw::toSub(row); + pool_.ttl[i] = 0xFFFF; // they leave by walking off, not by expiring + } + + particles::Pool pool_; + ScratchBuffer x_{*this}, y_{*this}, vx_{*this}, vy_{*this}; + ScratchBuffer ttl_{*this}; + ScratchBuffer kind_{*this}; + particles::FrameTime time_; + BeatPhase chomp_; + Random8 rng_; +}; + +} // namespace mm diff --git a/src/light/particles.h b/src/light/particles.h index bac91d05..efc1a069 100644 --- a/src/light/particles.h +++ b/src/light/particles.h @@ -1,6 +1,8 @@ #pragma once #include "core/math16.h" // sin16/cos16 for angleEmit, hashInt for spray, isqrt for attract +#include "core/AudioFrame.h" // AudioFrame: the spectrum audioDrive() reads +#include "core/AudioService.h" // latestFrame(): the live spectrum stepDriven() consumes #include "light/draw.h" // pos_t, splat, Canvas — particles render through the sub-pixel writer #include "light/Palette.h" // colorFromPalette @@ -12,14 +14,14 @@ namespace mm::particles { // matter — sparks, rain, snow, smoke, confetti, a fountain, a swarm, debris from an impact, an // audio band throwing off embers — is the same handful of forces over the same state, and the part // that differs between them is which forces are applied and how particles are emitted, not the -// physics. Handing an effect writer a working integrator, wall behaviour and emitter means a new +// physics. Handing an effect writer a working integrator, wall behavior and emitter means a new // look is a few lines of composition rather than a re-derivation of Euler integration. // // Retrofitting the effects that already hand-roll this is a real second benefit — several carry // their own representation (structs with floats, parallel arrays, a private 12.4 pair) and each // re-derived the same integration and the same wall bounce — but it is a consequence of the kernel // being right, not the reason to build it. The kernel is designed for what comes next; existing -// effects move onto it when their behaviour is judged on the bench, one at a time. +// effects move onto it when their behavior is judged on the bench, one at a time. // // **Structure of arrays, not array of structs.** Each field is its own contiguous run, so a pass // that touches only velocity walks only velocity. On a chip with a small cache and no prefetcher @@ -130,6 +132,72 @@ enum class RenderStyle : uint8_t { /// /// `ttl` is the unit of life: a particle with ttl 0 is dead and is skipped by every pass. Effects /// that want immortal particles simply never decrement it. +/// Map slot `i` of `slots` onto a distinct lane in [0, extent), interleaved so that consecutive +/// slots are NOT adjacent lanes. +/// +/// Effects assign slots by species or role, and species differ in speed, so a straight stride +/// sorts the scene: the fast ones bunch at one edge within seconds. Interleaving mixes them. +/// The step must be COPRIME with `slots` or the mapping collapses: `(i * 5) % 5` is zero for +/// every i, which stacked an entire cast on one row (bench: five Pacman characters in a single +/// line). Stepping by a value chosen coprime to the count keeps the lanes distinct at any count. +inline lengthType spreadLane(uint16_t i, uint16_t slots, lengthType extent) { + if (slots == 0) return 0; + // A step coprime with `slots`, chosen as near HALF of it as possible. Coprimality alone only + // guarantees the lanes are distinct: `slots - 1` is always coprime, but it is congruent to + // -1, so consecutive slots land on ADJACENT lanes (descending) and the species this is meant + // to interleave still bunch together. A step near half the count puts the widest gap between + // consecutive slots, which is the actual goal. Searching outward from the midpoint always + // terminates: 1 is coprime with everything and ends the walk. + uint16_t step = 1; + if (slots > 2) { + for (uint16_t d = 0; d < slots / 2; d++) { + const uint16_t lo = static_cast(slots / 2 - d); + const uint16_t hi = static_cast(slots / 2 + d); + uint16_t pick = 0; + for (uint16_t c : {hi, lo}) { + if (c <= 1 || c >= slots) continue; + uint16_t a = c, b = slots; + while (b) { const uint16_t t = a % b; a = b; b = t; } + if (a == 1) { pick = c; break; } // gcd(c, slots) == 1 + } + if (pick) { step = pick; break; } + } + } + const uint16_t lane = static_cast((static_cast(i) * step) % slots); + return static_cast((static_cast(extent) * lane) / slots); +} + +/// Per-sprite audio drive: how fast sprite `i` of `slots` should move for the sound playing now, +/// as a multiplier of FrameTime::kOne (so 0 = frozen, kOne = its normal speed). +/// +/// Shared by every sprite effect with a `soundReactive` checkbox (FlyingToasters, FishTank, +/// Pacman): the behavior a viewer expects is identical in all three, so it is written once. +/// +/// Each sprite gets its OWN band, spread across the 16 the FFT produces, so a scene breathes with +/// the music instead of surging as one block: the bass sprites lurch on the kick while the treble +/// ones flutter on the hats. The overall level gates it, so SILENCE STANDS THE SCENE STILL - the +/// requirement that makes the mode read as sound-reactive rather than merely speed-varying, since +/// a per-band value alone still drifts on noise between tracks. +/// +/// Returns kOne unchanged when there is no audio at all (no microphone, service not running), so +/// an effect never freezes on a device that simply cannot hear. +inline uint32_t audioDrive(const AudioFrame* frame, uint16_t i, uint16_t slots) { + if (!frame) return FrameTime::kOne; // no audio source: move normally, never freeze + + // Below this the input is room noise or a gap between tracks, not music. The scene stands + // still rather than creeping, which is what "if no music they should stand still" asks for. + constexpr uint16_t kSilence = 8; + if (frame->levelSmoothed < kSilence) return 0; + + const uint8_t band = slots ? static_cast((static_cast(i) * 16u) / slots) : 0; + const uint32_t mag = frame->bands[band > 15 ? 15 : band]; + + // A floor under the band keeps a sprite whose own band is quiet drifting slowly rather than + // frozen mid-air while the music plays; the rest scales with that band, and a loud band runs + // the sprite at about twice its normal speed. + return FrameTime::kOne / 4 + (mag * FrameTime::kOne * 7) / (255 * 4); +} + struct Pool { draw::pos_t* x = nullptr; draw::pos_t* y = nullptr; @@ -309,6 +377,34 @@ struct Pool { } } + /// step() with a PER-PARTICLE time scale: `drive(i)` returns particle i's own multiplier of + /// FrameTime::kOne. Sound-reactive sprite effects use it to move each sprite on its own audio + /// band (see audioDrive) - the whole point being that the sprites do NOT move as one block. + /// The frame scale still multiplies in, so speed stays frame-rate independent either way. + /// step(), optionally driven by the music: with `soundReactive` set, each of the `live` + /// sprites moves on its own frequency band and the scene stands still in silence; otherwise + /// the whole pool steps together. The one place the sound-reactive rule lives, so the sprite + /// effects share it rather than each carrying a copy of the branch. + /// + /// `live` is the number of sprites actually in play, NOT the pool capacity: the bands are + /// spread across the sprites that exist, so passing the capacity would crowd every sprite + /// into the low bands and leave the treble driving nothing. + void stepDriven(uint32_t scale, bool soundReactive, uint16_t live) { + if (!soundReactive) { step(scale); return; } + const AudioFrame* f = AudioService::latestFrame(); + stepEach(scale, [f, live](uint16_t i) { return audioDrive(f, i, live); }); + } + + template + void stepEach(uint32_t scale, Drive drive) { + for (uint16_t i = 0; i < count; i++) + if (ttl[i]) { + const uint32_t s = (scale * drive(i)) / FrameTime::kOne; + x[i] = static_cast(x[i] + scaleSigned(vx[i], static_cast(s), FrameTime::kOne)); + y[i] = static_cast(y[i] + scaleSigned(vy[i], static_cast(s), FrameTime::kOne)); + } + } + /// Count down every particle's life; a particle reaching zero is dead and its slot is reusable. /// `rate` of 0 makes the pool immortal. void age(uint16_t rate = 1, uint32_t scale = FrameTime::kOne) { @@ -367,7 +463,7 @@ struct Pool { /// Wrap particles around the grid edges: a particle leaving one side re-enters the other. /// - /// The third wall behaviour, alongside `bounce` and `killOutside`, and the one snow, rain and + /// The third wall behavior, alongside `bounce` and `killOutside`, and the one snow, rain and /// marquee effects need — those want an endless field, not a box to rattle inside or a cliff to /// fall off. Per axis, so a snowfall can wrap horizontally while still dying at the floor. void wrap(draw::pos_t w, draw::pos_t h, bool wrapX = true, bool wrapY = true) { diff --git a/src/main.cpp b/src/main.cpp index c7b6e5a0..2d9cbdd1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -20,6 +20,7 @@ #include "light/effects/RainbowEffect.h" #include "light/effects/WaveEffect.h" #include "light/effects/NoiseEffect.h" +#include "light/effects/PacmanEffect.h" #include "light/effects/PlasmaEffect.h" #include "light/effects/MetaballsEffect.h" #include "light/effects/FireEffect.h" @@ -50,6 +51,7 @@ #include "light/effects/SpectrumEffect.h" #include "light/effects/FireworksEffect.h" #include "light/effects/BallpitEffect.h" +#include "light/effects/FishTankEffect.h" #include "light/effects/FlyingToastersEffect.h" #include "light/effects/TruchetEffect.h" #include "light/effects/VectorBallsEffect.h" @@ -234,6 +236,8 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType("DissolveEffect", "light/effects.md#dissolve"); mm::ModuleFactory::registerType("SpectrumEffect", "light/effects.md#spectrum"); mm::ModuleFactory::registerType("FireworksEffect", "light/effects.md#fireworks"); + mm::ModuleFactory::registerType("FishTankEffect", "light/effects.md#fishtank"); + mm::ModuleFactory::registerType("PacmanEffect", "light/effects.md#pacman"); mm::ModuleFactory::registerType("FlyingToastersEffect", "light/effects.md#flyingtoasters"); mm::ModuleFactory::registerType("BallpitEffect", "light/effects.md#ballpit"); mm::ModuleFactory::registerType("TruchetEffect", "light/effects.md#truchet"); diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index e9e098a3..41de6f40 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -112,6 +112,13 @@ constexpr bool hasNdi = true; // ffmpeg found on PATH (a runtime dependency of the user's, like the NDI runtime and Npcap). // True on desktop and the Pi; the encoder process seam lives in the platform layer. constexpr bool hasHls = true; +// hasEncoderChoice: ffmpeg offers several H.264 encoders (software and per-vendor hardware), so +// the pick is the user's. False where the platform has exactly one encoder, which hides the +// control rather than offering a choice of one. +constexpr bool hasEncoderChoice = true; +// hasFsSegments: ffmpeg writes the playlist and segments to disk, so the driver manages that +// directory and the HTTP server serves it as files. False where segments live in RAM (the P4). +constexpr bool hasFsSegments = true; // 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). diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 8552192b..279d6b0b 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -49,6 +49,7 @@ extern char** environ; // posix_spawnp wants the environment explicitly #include // ETH_P_ALL #endif #ifdef __APPLE__ +#include // SIOCGIFMEDIA: the negotiated link rate, for the interface labels #include // pthread_jit_write_protect_np — macOS arm64 W^X JIT toggle #include // BIOCSETIF — binding a BPF device to an interface (ethSendRaw) #include @@ -58,6 +59,32 @@ extern char** environ; // posix_spawnp wants the environment explicitly namespace mm::platform { namespace { +/// Append ", " to an interface label, in the one format every OS's list uses. +/// +/// The speed LOOKUP is necessarily per-OS (MIB_IF_TABLE2 on Windows, sysfs on Linux, SIOCGIFMEDIA +/// on macOS: three APIs, three units), which is what the platform layer is for. The RENDERING is +/// not, so it lives here once: the label shape is a contract the apply path and the driver's remap +/// both parse (they split on ", " to recover the adapter's stable identity), and two copies of it +/// would be two chances to drift out of that agreement. +/// +/// `mbps` of 0 means the OS would not state a speed (a virtual adapter, a link that is down, or +/// macOS Wi-Fi reporting only "autoselect"). That appends nothing, rather than a fabricated +/// "0 Mb". Appends only if the whole suffix fits: a truncated speed reads worse than none, and +/// the label is what the Select persists by. +void appendLinkSpeed(char* out, size_t cap, unsigned mbps) { + if (!out || mbps == 0) return; + const size_t n = std::strlen(out); + char speed[24]; + if (mbps >= 1000 && mbps % 1000 == 0) + std::snprintf(speed, sizeof(speed), ", %u Gb", mbps / 1000); + else if (mbps >= 1000) + std::snprintf(speed, sizeof(speed), ", %u.%u Gb", mbps / 1000, (mbps % 1000) / 100); + else + std::snprintf(speed, sizeof(speed), ", %u Mb", mbps); + if (n + std::strlen(speed) + 1 <= cap) std::snprintf(out + n, cap - n, "%s", speed); +} + + // Tiny portability shims so each call site reads as plain code, not `#ifdef` noise. // POSIX uses int FDs + errno + read/write/close; Winsock uses SOCKET handles + // WSAGetLastError + recv/send/closesocket. Map to a small common surface. @@ -942,9 +969,16 @@ void guidToString(const GUID& g, char* out, size_t cap) { g.Data4[4], g.Data4[5], g.Data4[6], g.Data4[7]); } -/// The description WINDOWS shows for a pcap device, found through the interface table by GUID. +/// The description WINDOWS shows for a pcap device, found through the interface table by GUID, +/// with the adapter's LINK SPEED appended when Windows states one ("Realtek PCIe GbE, 1 Gb"). /// This exists because pcap's own description can be absent: without it such an adapter is /// unnameable, since the only text left to match is a 49-character device path. +/// +/// The speed rides in the label because the name alone does not say what a picker needs to know: +/// a panel wall wants the 1 Gb NIC, and a list of plausible-looking names hides which entries are +/// a 2.5 Gb USB dongle, a Wi-Fi radio, or a Hyper-V virtual switch. Windows reports 0 or ~0 for +/// an adapter whose speed it will not state (typically one that is down), and those get no suffix +/// rather than a fabricated "0 Mb". bool winDescForPcapName(const MIB_IF_TABLE2* table, const char* pcapName, char* out, size_t cap) { if (!table || !out || cap == 0) return false; char want[40]; @@ -959,7 +993,14 @@ bool winDescForPcapName(const MIB_IF_TABLE2* table, const char* pcapName, char* out[n] = static_cast(table->Table[i].Description[n]); } out[n] = '\0'; - return n > 0; + if (n == 0) return false; + + // Same source and the same unknown-speed guard as ethLinkSpeedMbps (winAdapterLink); + // converted to Mbit here so the shared formatter takes one unit from every OS. + const unsigned long long bps = table->Table[i].TransmitLinkSpeed; + if (bps == 0 || bps == ~0ULL) return true; + appendLinkSpeed(out, cap, static_cast(bps / 1000000ULL)); + return true; } return false; } @@ -2363,7 +2404,9 @@ static void stopEncoderProcess() { #endif } -bool encoderStart(const char* const argv[]) { +// Spawn `argv` (argv[0] resolved via PATH) with its stdin piped from us. The ffmpeg command line +// is assembled by encoderStart below; this half is pure process plumbing. +static bool spawnEncoderProcess(const char* const argv[]) { stopEncoderProcess(); if (encTestMode_ != EncoderTestMode::Off) { encCapturedArgs_.clear(); @@ -2480,6 +2523,60 @@ bool encoderStart(const char* const argv[]) { return true; } +// The ffmpeg invocation IS the desktop encode contract: raw RGB in at the grid size and rate, +// zerolatency x264 out, 1 s segments on a short rolling playlist (the live tuning that puts +// glass-to-glass at 2-5 s), segments deleted as they fall off it. +bool encoderStart(const EncoderConfig& cfg) { + char geo[16], rate[8], gop[8], bv[12], out[192]; + std::snprintf(geo, sizeof(geo), "%ux%u", static_cast(cfg.width), + static_cast(cfg.height)); + std::snprintf(rate, sizeof(rate), "%u", static_cast(cfg.fps)); + std::snprintf(gop, sizeof(gop), "%u", static_cast(cfg.fps)); + std::snprintf(bv, sizeof(bv), "%uk", static_cast(cfg.bitrateKbit)); + std::snprintf(out, sizeof(out), "%s/stream.m3u8", cfg.outDir); + + // Assembled by index so the x264-only tuning flags stay off other encoders + // (h264_videotoolbox rejects -tune) without duplicated slots. + // Size the frame slots HERE, off the render tick: encoderWrite's assign() would otherwise + // allocate on its first lap, and the tick path must not allocate at all. A frame is + // width*height*3 (tight RGB, the driver's packing); a failure here fails the start, where + // the driver already reports it, rather than throwing from a later write. + const size_t frameBytes = static_cast(cfg.width) * cfg.height * 3; + // Stop FIRST, then resize. The previous writer thread reads a slot's data pointer in its + // blocking write loop WITHOUT encMutex_ held, so reserving under it is both a data race and, + // once a geometry or scale change grows frameBytes, a reallocation that frees the buffer the + // writer is still reading. spawnEncoderProcess stops again below; that call is then a no-op. + stopEncoderProcess(); + try { + for (auto& slot : encSlots_) slot.reserve(frameBytes); + } catch (const std::bad_alloc&) { + return false; + } + + const char* encoder = cfg.encoderName ? cfg.encoderName : "libx264"; + const bool x264 = std::strcmp(encoder, "libx264") == 0; + const char* argv[40]; + size_t i = 0; + auto add = [&](const char* a) { if (i + 1 < sizeof(argv) / sizeof(argv[0])) argv[i++] = a; }; + for (const char* a : std::initializer_list{ + "ffmpeg", "-hide_banner", "-loglevel", "error", + "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", geo, + "-r", rate, "-i", "-", + "-c:v", encoder }) add(a); + if (x264) { add("-preset"); add("veryfast"); add("-tune"); add("zerolatency"); } + for (const char* a : std::initializer_list{ + "-g", gop, "-b:v", bv, + "-f", "hls", "-hls_time", "1", "-hls_list_size", "6", + "-hls_flags", "delete_segments+temp_file", out }) add(a); // temp_file: the playlist lands by RENAME, never served half-written + argv[i] = nullptr; + return spawnEncoderProcess(argv); +} + +// ffmpeg writes the playlist and segments to disk itself, so there is nothing in RAM to serve and +// the HTTP server uses its normal file path. +bool hlsSegment(const char*, const uint8_t**, size_t*) { return false; } +void hlsSegmentRelease() {} + int encoderWrite(const uint8_t* data, size_t len) { std::lock_guard lock(encMutex_); if (encTestMode_ == EncoderTestMode::Record) { @@ -2489,8 +2586,8 @@ int encoderWrite(const uint8_t* data, size_t len) { } if (encWriterDead_) return -1; if (encCount_ >= kEncQueueMax) return 0; // encoder behind: drop-newest, stay live - // assign() into the reused slot: after the first lap each slot holds its capacity, so the - // steady-state hot path copies without allocating. + // assign() into the reused slot. The capacity was reserved by encoderStart, so this copies + // without allocating -- including the first lap, which is why the reserve is there. encSlots_[(encHead_ + encCount_) % kEncQueueMax].assign(data, data + len); encCount_++; encCv_.notify_one(); @@ -2569,6 +2666,9 @@ void rawIfPush(const char* label, const char* name) { } // namespace void setTestRawInterfaces(const char* const* names, size_t count) { + // The documented reset is (nullptr, 0), and `names + count` on a null pointer is undefined + // even when count is zero, so the reset is its own path rather than a degenerate range. + if (!names || count == 0) { rawIfTest_.clear(); return; } rawIfTest_.assign(names, names + count); } @@ -2616,6 +2716,50 @@ size_t rawInterfaces(const char* const** optionsOut) { "lo", "utun", "awdl", "llw", "anpi", "bridge", "gif", "stf", "ap", "pktap", "veth", "docker", "br-", "virbr", }; + // The adapter's negotiated link speed in Mbit, or 0 when the OS will not state one + // (a virtual interface, a link that is down, Wi-Fi on macOS which reports only + // "autoselect"). Rides in the label for the same reason as the Windows branch: the + // name alone does not say which entry is the 1 Gb NIC and which is a tunnel. + auto linkMbps = [](const char* ifname) -> unsigned { +#ifdef __linux__ + // sysfs states it directly, in Mbit. Absent or -1 for a virtual or down link. + char path[128]; + std::snprintf(path, sizeof(path), "/sys/class/net/%s/speed", ifname); + FILE* f = std::fopen(path, "r"); + if (!f) return 0; + long v = 0; + const bool ok = std::fscanf(f, "%ld", &v) == 1; + std::fclose(f); + return (ok && v > 0) ? static_cast(v) : 0; +#elif defined(__APPLE__) + // macOS has no speed field: the negotiated rate is encoded as the media + // SUBTYPE, so map the ones that name a rate. Wi-Fi and "autoselect" report no + // subtype we can turn into a number, which is exactly when 0 is the honest + // answer rather than a guess. + const int fd = ::socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) return 0; + ifmediareq req{}; + std::snprintf(req.ifm_name, sizeof(req.ifm_name), "%s", ifname); + unsigned mbps = 0; + if (::ioctl(fd, SIOCGIFMEDIA, &req) == 0 && (req.ifm_status & IFM_ACTIVE)) { + switch (IFM_SUBTYPE(req.ifm_active)) { + case IFM_10_T: mbps = 10; break; + case IFM_100_TX: mbps = 100; break; + case IFM_1000_T: mbps = 1000; break; + case IFM_2500_T: mbps = 2500; break; + case IFM_5000_T: mbps = 5000; break; + case IFM_10G_T: mbps = 10000; break; + default: break; + } + } + ::close(fd); + return mbps; +#else + (void)ifname; + return 0; +#endif + }; + auto isVirtual = [](const char* n) { for (const char* p : kVirtualPrefixes) { const size_t l = std::strlen(p); @@ -2633,7 +2777,13 @@ size_t rawInterfaces(const char* const** optionsOut) { for (size_t i = 1; i < rawIfCount_; i++) if (std::strcmp(rawIfNames_[i], a->ifa_name) == 0) { seen = true; break; } if (seen) continue; // getifaddrs lists one row per address family - rawIfPush(a->ifa_name, a->ifa_name); + // Label carries the speed, bind name does not: the name is the adapter's + // identity and the speed changes when a link renegotiates (platform.h § raw + // interfaces). Same "NAME, N Gb" shape as the Windows branch. + char label[64]; + std::snprintf(label, sizeof(label), "%s", a->ifa_name); + appendLinkSpeed(label, sizeof(label), linkMbps(a->ifa_name)); + rawIfPush(label, a->ifa_name); } ::freeifaddrs(addrs); } diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index fe77d89e..d3687b2c 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -1,6 +1,6 @@ #pragma once -// ESP32 platform configuration — PSRAM detected via sdkconfig +// ESP32 platform configuration: PSRAM detected via sdkconfig #include "sdkconfig.h" @@ -18,7 +18,7 @@ #include "hal/rmt_ll.h" #endif -// MM_RAMFUNC — "this function executes from RAM, not flash" (the __ramfunc concept from STM32/Zephyr, +// MM_RAMFUNC: "this function executes from RAM, not flash" (the __ramfunc concept from STM32/Zephyr, // spelled IRAM_ATTR in ESP-IDF). For code an ISR runs on a tight deadline: flash-resident code shares // one instruction cache between both cores, so a hot render loop evicts an ISR's code path between // invocations and every firing pays flash-refetch latency. A macro (not if constexpr) because it is a @@ -39,7 +39,7 @@ constexpr bool hasPsram = false; // family, so most new chips work untouched without a per-chip flag. Only two chips // earn an `is` flag, for seams that aren't SOC-derived: isEsp32P4 (its // Ethernet pin defaults in `ethConfigDefault` + co-processor WiFi via -// hasWifiCoprocessor) and isEsp32S3 (its W5500-SPI Ethernet default — see below). +// hasWifiCoprocessor) and isEsp32S3 (its W5500-SPI Ethernet default: see below). // Keyed off the IDF target macro; false on desktop. #ifdef CONFIG_IDF_TARGET_ESP32P4 constexpr bool isEsp32P4 = true; @@ -48,7 +48,7 @@ constexpr bool isEsp32P4 = false; #endif // isEsp32S3 earns its place the same way isEsp32P4 does: a chip-specific seam not -// derivable from a SOC flag — the S3 has no internal EMAC, so its Ethernet default +// derivable from a SOC flag: the S3 has no internal EMAC, so its Ethernet default // is W5500-over-SPI (where classic/P4 default to RMII). Used only for ethConfigDefault. #ifdef CONFIG_IDF_TARGET_ESP32S3 constexpr bool isEsp32S3 = true; @@ -57,7 +57,7 @@ constexpr bool isEsp32S3 = false; #endif // isEsp32S31: the S31 is the only target whose EMAC is RGMII / 1 Gb (SOC_EMAC_SUPPORT_1000M), -// where classic/P4 are RMII — so its Ethernet default is a distinct RGMII PHY (YT8531) with a +// where classic/P4 are RMII: so its Ethernet default is a distinct RGMII PHY (YT8531) with a // different pin set. Not derivable from a SOC flag (the RGMII data pins are board wiring, not a // chip property). Used by ethConfigDefault and ethInitEmac's RGMII branch/log. #ifdef CONFIG_IDF_TARGET_ESP32S31 @@ -123,7 +123,7 @@ constexpr uint8_t ethFixedPadCount = 0; #endif // RMT TX channels this chip offers (8 on classic ESP32, 4 on the S3 / P4 / S31, -// straight from the RMT HAL — `RMT_LL_TX_CANDIDATES_PER_INST`, included above). +// straight from the RMT HAL: `RMT_LL_TX_CANDIDATES_PER_INST`, included above). // Doubles as the RMT capability flag: the RMT LED driver and its main.cpp // registration guard on `rmtTxChannels > 0` instead of a chip-family flag, so a // new RMT-bearing target works untouched. @@ -135,7 +135,7 @@ constexpr uint8_t rmtTxChannels = 0; // Parallel WS2812 lanes over the LCD_CAM i80 bus (ESP32-S3 / P4). The peripheral // does 16 data lines and the driver uses all of them: it derives the actual bus -// width (8 or 16 — power-of-two only) from the configured pin count, so this is +// width (8 or 16: power-of-two only) from the configured pin count, so this is // the ceiling, not a self-imposed cap. SOC-derived like rmtTxChannels so a future // LCD_CAM-bearing chip works untouched. // @@ -146,7 +146,7 @@ constexpr uint8_t rmtTxChannels = 0; // doesn't have. SOC_LCDCAM_I80_LCD_SUPPORTED is defined only on chips with the // real LCD_CAM (S3/P4/S31), which is what esp_lcd's i80 driver actually needs. // The LCD_CAM i80 bus does 16 data lines. The driver derives the actual bus width -// (8 or 16 — power-of-two only) from the configured pin count; this is the MAX it +// (8 or 16: power-of-two only) from the configured pin count; this is the MAX it // may reach. LCD requires exactly 8 or 16 real pins (i80 rejects an NC data line). #ifdef CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED constexpr uint8_t lcdLanes = 16; @@ -154,12 +154,12 @@ constexpr uint8_t lcdLanes = 16; constexpr uint8_t lcdLanes = 0; #endif -// hasLcdCam — is this LCD_CAM silicon (S3/P4/S31)? Separate from the lane COUNT because the host +// hasLcdCam: is this LCD_CAM silicon (S3/P4/S31)? Separate from the lane COUNT because the host // sets a non-zero count so the parallel driver RUNS there against a memory bus, while having no // LCD_CAM at all. On a real chip the two coincide; the pin expander keys off the capability. constexpr bool hasLcdCam = (lcdLanes > 0); -// Parallel WS2812 lanes over the Parlio (Parallel IO) TX peripheral — the +// Parallel WS2812 lanes over the Parlio (Parallel IO) TX peripheral: the // ESP32-P4's scale path. The unit does 16 data lines; the driver derives the bus // width (8 or 16) from the pin count. SOC-derived like the others, so a future // Parlio-bearing chip works untouched. Unlike i80, Parlio takes the data GPIOs @@ -171,14 +171,14 @@ constexpr uint8_t parlioLanes = 16; constexpr uint8_t parlioLanes = 0; #endif -// Parallel WS2812 lanes over the classic ESP32's I2S peripheral in LCD/i80 mode — the +// Parallel WS2812 lanes over the classic ESP32's I2S peripheral in LCD/i80 mode: the // classic chip's ONLY >8-lane route (it has neither LCD_CAM nor Parlio). IDF's esp_lcd // component backs the SAME esp_lcd i80 API (esp_lcd_new_i80_bus / tx_color, 8-or-16 bus // width, WR/DC) with the I2S peripheral on the classic ESP32 (esp_lcd_panel_io_i2s.c), -// using WHOLE-FRAME chained DMA — so MultiPinLedDriver reuses the MultiPinLedDriver code path and +// using WHOLE-FRAME chained DMA: so MultiPinLedDriver reuses the MultiPinLedDriver code path and // the i80Ws2812* seam, not a bespoke ISR ring. Gate CLASSIC-ONLY: SOC_LCD_I80_SUPPORTED // is set on the classic chip (I2S backend) AND the LCD_CAM chips (S3/P4/S31, LCD_CAM backend), so -// exclude the LCD_CAM chips — otherwise both this and lcdLanes would be non-zero on those chips and +// exclude the LCD_CAM chips: otherwise both this and lcdLanes would be non-zero on those chips and // the chip would register both drivers. The `defined(A) && !defined(B)` shape mirrors // hasEthW5500 below. The i80 bus does 16 data lines; the driver derives 8 or 16 from the // pin count and requires exactly that many real pins (i80 rejects an NC data line). @@ -207,7 +207,7 @@ constexpr bool hasAudioCapture = false; // Some boards put the mic behind an I2S audio codec configured over I2C (vs a // direct I2S MEMS mic). The codec type + its control pins are a fixed board // property, so they live here per-target (like ethConfigDefault), not as -// AudioService controls — the I2S data pins (ws/sd/sck) stay user controls. +// AudioService controls: the I2S data pins (ws/sd/sck) stay user controls. // `audioCodecInit` (platform.h) consumes these; CodecType is neutral so a second // codec is just another enum value + a backend branch. enum class CodecType : uint8_t { None = 0, Es8311 = 1 }; @@ -219,7 +219,7 @@ struct AudioCodecPins { }; // Default None; the ESP32-S31 Function-CoreBoard has an ES8311 (addr 0x18, I2C -// SDA on GPIO51 / SCL on GPIO50, MCLK on GPIO52 — bench-confirmed by I2C scan; the +// SDA on GPIO51 / SCL on GPIO50, MCLK on GPIO52: bench-confirmed by I2C scan; the // schematic net labels read SDA/SCL the other way round. See // docs/reference/esp32-s31-coreboard.md.). #ifdef CONFIG_IDF_TARGET_ESP32S31 @@ -241,16 +241,16 @@ constexpr bool hasWiFi = true; // The P4 has no native radio; when it has WiFi at all (the esp32p4-eth-wifi build), // that WiFi runs on the on-board ESP32-C6 over SDIO via esp_wifi_remote / esp_hosted. -// The esp_wifi_* API is identical to native and esp_hosted self-initialises at boot, +// The esp_wifi_* API is identical to native and esp_hosted self-initializes at boot, // so the WiFi *path* needs no branch. This flag exists only so the co-processor // firmware read-out (SystemModule's `wifiCoproc` control + platform::coprocessorWifi) -// compiles in ONLY on a build that actually has a co-processor — on every other +// compiles in ONLY on a build that actually has a co-processor: on every other // target the buffer, the calls, and the control vanish (if constexpr), keeping the // flash/RAM cost off boards that can't use it. constexpr bool hasWifiCoprocessor = isEsp32P4 && hasWiFi; // Ethernet is only available on firmware variants whose sdkconfig fragment -// enables the ESP32 EMAC (sdkconfig.defaults.eth — the default LAN8720 RMII pin map). Other +// enables the ESP32 EMAC (sdkconfig.defaults.eth: the default LAN8720 RMII pin map). Other // firmwares (plain ESP32 WiFi-only, ESP32-S3 with no EMAC) define MM_NO_ETH // and get stubbed-out platform::eth* functions, mirroring the desktop layer. #ifdef MM_NO_ETH @@ -259,10 +259,10 @@ constexpr bool hasEthernet = false; constexpr bool hasEthernet = true; #endif -// True when the firmware carries an IP stack at all — WiFi OR Ethernet. UdpSocket +// 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. +// hasWiFi: an Ethernet-only board (the MHC-WLED P4 shield) still has UDP. constexpr bool hasNetwork = hasWiFi || hasEthernet; // ethPhyIsFixed, true where the interface is a property of the PLATFORM rather than of the board, @@ -278,7 +278,7 @@ constexpr bool ethPhyIsFixed = true; constexpr bool ethPhyIsFixed = false; #endif -// Enough compute headroom for a per-pixel FLOAT algorithm — a raymarcher, a fractal, a feedback +// Enough compute headroom for a per-pixel FLOAT algorithm: a raymarcher, a fractal, a feedback // loop that iterates per light. This is the ONE exception to the integer-only render-path rule in // coding-standards, and it is gated rather than assumed: an effect behind this constant is not // compiled at all where it is false, so no ESP32 firmware carries the float code and the rule is @@ -289,9 +289,9 @@ constexpr bool ethPhyIsFixed = false; // a large one, and the effect's own controls are what trade quality for cost. The classic ESP32 has // no FPU at all, so it stays out; the S3 and P4 have single-precision hardware. // Derived from the SoC capability, not a hand-kept chip list: every ESP32 variant IDF supports -// declares SOC_CPU_HAS_FPU (checked S3, P4, S31 and the classic ESP32 — all 1), so a new target +// declares SOC_CPU_HAS_FPU (checked S3, P4, S31 and the classic ESP32: all 1), so a new target // inherits the right answer without an edit here. What separates them is SPEED and clock, which -// the effect's own `steps` control and the fixture size decide — not whether the code exists. +// the effect's own `steps` control and the fixture size decide: not whether the code exists. constexpr bool hasHeavyCompute = SOC_CPU_HAS_FPU; // Preprocessor mirror of the flag above: a whole effect can be compiled out only by @@ -300,25 +300,38 @@ constexpr bool hasHeavyCompute = SOC_CPU_HAS_FPU; // 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 +// fragment (the S3: CONFIG_ETH_USE_SPI_ETHERNET set, CONFIG_ETH_USE_ESP32_EMAC // not). NetworkModule gates the *live* W5500 reconfigure on this: on a classic / // P4 board (RMII only) ethInit() can't bring up W5500, so the live path must not // tear down the working RMII interface for a type it can't init. Mirrors the // MM_ETH_W5500 marker in platform_esp32.cpp; false on desktop (no SPI-eth there). -// hasNamedNetInterfaces — false on every ESP32: one MAC per chip, so a raw sender has nothing to +// hasNamedNetInterfaces: false on every ESP32: one MAC per chip, so a raw sender has nothing to // choose between and a NIC-name control would do nothing. See the desktop config for the true case. constexpr bool hasNamedNetInterfaces = false; -// hasNdi — false on every ESP32, and not by choice: the NDI runtime is a closed binary that +// hasNdi: false on every ESP32, and not by choice: the NDI runtime is a closed binary that // Vizrt builds only for Intel and ARM, with a documented floor of SSSE3 / NEON SIMD. Neither // Xtensa nor ESP32 RISC-V has either, and there is no source to port. NDI's own embedded answer // is an FPGA reference design, not an MCU. An ESP32 reaches the same tools over Art-Net, sACN and -// DDP instead — open protocols, our own implementations, send and receive. Desktop config: true. +// DDP instead: open protocols, our own implementations, send and receive. Desktop config: true. constexpr bool hasNdi = false; -// hasHls: false on every ESP32: H.264 encoding needs a hardware encoder or a desktop-class -// CPU, and there is no process to spawn. The HLS driver registers only where this is true. +// hasHls: true only where the chip has a hardware H.264 encoder, which on the ESP32 line means +// the P4 alone (soc_caps SOC_H264_SUPPORTED). Everywhere else the encode has no hardware and +// there is no process to spawn. Mirrors the MM_HLS Kconfig symbol, which is what actually pulls +// in the esp_h264 component, so the flag and the dependency can never disagree. +#if defined(CONFIG_MM_HLS) +constexpr bool hasHls = true; +#else constexpr bool hasHls = false; +#endif +// hasEncoderChoice: false on the P4: one hardware encoder, so there is nothing to choose and +// the control is hidden. Desktop config: true (ffmpeg offers several). +constexpr bool hasEncoderChoice = false; +// hasFsSegments: false: segments live in a PSRAM ring, not on LittleFS. At one segment per +// second the flash wear buys nothing, since a live segment is stale within seconds; the HTTP +// server serves them through the hlsSegment seam instead. Desktop config: true. +constexpr bool hasFsSegments = false; #if defined(CONFIG_ETH_USE_SPI_ETHERNET) && !defined(CONFIG_ETH_USE_ESP32_EMAC) constexpr bool hasEthW5500 = true; @@ -327,22 +340,22 @@ constexpr bool hasEthW5500 = false; #endif // Ethernet PHY type. The DRIVER for each type is compiled into the firmware per -// chip (RMII EMAC on classic/P4, W5500 SPI on the S3 — see the sdkconfig +// chip (RMII EMAC on classic/P4, W5500 SPI on the S3: see the sdkconfig // fragments); WHICH type a given board uses, and its pins, are runtime config // (deviceModels.json → NetworkModule → platform::setEthConfig). Plain int values keep // this header free of esp_eth includes; ethInit() maps them to the IDF ctors. enum EthPhyType { - ethNone = 0, // no Ethernet on this board (the default — WiFi only) + ethNone = 0, // no Ethernet on this board (the default: WiFi only) ethLan8720 = 1, // RMII, generic PHY (Olimex Gateway, QuinLED Dig-Octa) ethIp101 = 2, // RMII, IP101 PHY (Waveshare P4-NANO; managed component, P4-only) - ethW5500 = 3, // SPI, external W5500 module (ESP32-S3 boards — SE16, LightCrafter) + ethW5500 = 3, // SPI, external W5500 module (ESP32-S3 boards: SE16, LightCrafter) ethYt8531 = 4, // RGMII, YT8531 PHY (ESP32-S31 CoreBoard; on-chip 1 Gb EMAC, S31-only) ethOpeneth = 5, // QEMU's emulated OpenCores MAC, no silicon, only ever selected under emulation. // It is what gives an emulated device a real IP stack, and therefore the REST API // and the web UI: without it a QEMU run can only be watched on the serial console. }; -// Per-board Ethernet pin/PHY map — runtime-configurable (no longer a fixed +// Per-board Ethernet pin/PHY map: runtime-configurable (no longer a fixed // compile-time constant). RMII fields apply to LAN8720/IP101; the spi* fields to // W5500. ethInit() reads the runtime `ethConfig` (set from deviceModels.json via // platform::setEthConfig); the per-chip `ethConfigDefault` below seeds it so an @@ -375,10 +388,10 @@ struct EthPinConfig { // RMII clock OUT on GPIO17, MDC/MDIO at IDF defaults (e.g. Olimex ESP32-Gateway). // - S3 → no built-in EMAC, so the default is W5500 SPI but with no pins set // (phyType ethW5500, pins -1): a W5500 S3 board MUST provide its SPI pins via -// deviceModels.json — there's no universal S3 default to guess. +// deviceModels.json: there's no universal S3 default to guess. // - S31 → Function-CoreBoard-1: RGMII YT8531, MDC/MDIO 5/6, reset 7. The RGMII // *data* pins (TX_CTL/TXD0-3, RX_CTL/RXD0-3, clocks) are board-fixed and live in -// ethInitEmac()'s S31 branch, not this struct (same reason RMII data pins don't — +// ethInitEmac()'s S31 branch, not this struct (same reason RMII data pins don't - // see above); rmiiClock* are unused for RGMII (clocks are set there too). See // docs/reference/esp32-s31-coreboard.md for the schematic pin map. constexpr EthPinConfig ethConfigDefault = @@ -409,7 +422,7 @@ constexpr EthPinConfig ethConfigDefault = /*miso*/ -1, /*mosi*/ -1, /*sck*/ -1, /*cs*/ -1, /*irq*/ -1 }; #endif // CONFIG_ETH_USE_OPENETH -// OTA (esp_https_ota) is available on every ESP32 build — the OTA partition +// OTA (esp_https_ota) is available on every ESP32 build: the OTA partition // layout in partitions/*.csv reserves app0/app1 unconditionally, and esp_https_ota // is in baseline ESP-IDF. FirmwareUpdateModule + the /api/firmware/url route // `if constexpr` on this so desktop builds get a 501-returning stub instead. @@ -417,8 +430,8 @@ constexpr bool hasOta = true; // Improv-serial is the device's serial RPC channel (UART0 + native USB-Serial-JTAG): // the WiFi-provisioning RPCs (WIFI_SETTINGS, GET_WIFI_NETWORKS) AND the vendor RPCs -// (SET_DEVICE_MODEL, SET_TX_POWER, APPLY_OP — "Improv = REST over serial"). The -// transport is always available on ESP32, so the listener runs everywhere — including +// (SET_DEVICE_MODEL, SET_TX_POWER, APPLY_OP: "Improv = REST over serial"). The +// transport is always available on ESP32, so the listener runs everywhere: including // Ethernet-only builds (`--firmware esp32-eth*`), where the WiFi-only RPCs are compiled // out (the `esp_wifi_*` calls aren't linked) but the vendor RPCs still work, so the web // installer can push a device-model's config over serial to an eth device just as it @@ -428,13 +441,13 @@ constexpr bool hasImprov = true; } // namespace mm::platform -// MM_MOONLIVE_HAS_HOST_JIT — always 0 on ESP32. The unit tests that consume this macro run on +// MM_MOONLIVE_HAS_HOST_JIT: always 0 on ESP32. The unit tests that consume this macro run on // the *desktop* host binary; on-device MoonLive uses its own per-ISA backends (Xtensa / RISC-V // in src/platform/esp32/moonlive_emit.cpp), validated by the live hardware run, not host tests. // Kept in platform_config.h so the core header stays free of architecture #ifs. #define MM_MOONLIVE_HAS_HOST_JIT 0 -// MM_LINKS_ALL_LED_DRIVERS — 0 on ESP32: a board links only the drivers its silicon can run, so +// MM_LINKS_ALL_LED_DRIVERS: 0 on ESP32: a board links only the drivers its silicon can run, so // the type picker stays honest and the binary lean. See the desktop config for why the host is // the other way round. #define MM_LINKS_ALL_LED_DRIVERS 0 diff --git a/src/platform/esp32/platform_esp32_h264.cpp b/src/platform/esp32/platform_esp32_h264.cpp new file mode 100644 index 00000000..ebc184c2 --- /dev/null +++ b/src/platform/esp32/platform_esp32_h264.cpp @@ -0,0 +1,458 @@ +// HLS on the ESP32-P4: the platform half of the encoder seam (platform.h § HLS). +// +// Where desktop spawns an ffmpeg and hands it the whole job, the P4 does all three parts itself: +// the chip's hardware H.264 encoder (Espressif's esp_h264 component), our own MPEG-TS muxer +// (MpegTs.h), and a segment ring in PSRAM that the HTTP server serves straight out of RAM. There +// is no filesystem in the path: at one segment per second, writing them to flash would wear it +// out for no gain, since a live HLS segment is stale within seconds. +// +// **Why a worker task.** encoderWrite() is called from the render tick and must never block on an +// encode. It copies the frame into a slot ring (the desktop writer-thread shape) and returns; the +// mmH264 task does the color conversion, the encode and the muxing. A full ring drops the newest +// frame, exactly as the desktop pipe does when ffmpeg falls behind. + +#include "platform/platform.h" +#include "sdkconfig.h" + +#if defined(CONFIG_MM_HLS) + +#include "light/MpegTs.h" + +#include "esp_h264_enc_single_hw.h" +#include "esp_heap_caps.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" + +#include +#include +#include + +namespace mm::platform { +namespace { + +// Frame slots between the render tick and the encode task. Three is the desktop's number and the +// same reasoning: enough to absorb a burst, few enough that a backlog is dropped rather than +// queued into latency. +constexpr size_t kSlots = 3; + +// Segments kept in the ring, and so also the playlist's depth: a segment survives this many +// seconds after it closes, which is the whole budget a player has to parse the playlist, fetch +// and buffer before what it asked for is recycled. Browsers want several seconds of that, so the +// ring is the lifetime, not a cache. +constexpr size_t kSegments = 12; +// Segments held back from the playlist: the slots rotation is about to reuse. Without this margin +// a player is handed a segment that is overwritten while it fetches it. +constexpr uint32_t kReserved = 3; + +// One second at a generous bitrate, with headroom for the keyframe that opens every segment. +constexpr size_t kSegmentBytes = 512 * 1024; + +struct Slot { + uint8_t* data = nullptr; + size_t len = 0; +}; + +struct Segment { + uint8_t* data = nullptr; + size_t len = 0; + uint32_t seq = 0; // its number in the playlist; 0 = never filled + uint16_t frames = 0; // frames muxed into it, so the playlist can state its REAL duration +}; + +// Everything the worker and the producers share. Guarded by the FreeRTOS mutex below, except the +// atomics, which are read without it. +Slot slots_[kSlots]; +size_t head_ = 0, count_ = 0; +Segment segments_[kSegments]; +size_t segWrite_ = 0; // segment currently being filled +uint32_t nextSeq_ = 1; +// The segment a socket is currently reading, if any. hlsSegment hands out a pointer that the +// caller reads AFTER the lock drops, so the encoder must not recycle that slot underneath it; +// serving is far shorter than the eight seconds the ring takes to lap, but "usually in time" is +// not a lifetime guarantee. kNoSeg = nothing being served. +constexpr uint32_t kNoSeg = 0; +std::atomic serving_{kNoSeg}; + +WorkerTask task_; +std::atomic running_{false}; +std::atomic dead_{false}; // the encoder failed: writes are refused until a restart +// Set by the worker as its LAST act. stopPinnedTask detaches rather than joins if the worker +// overruns its deadline (platform_esp32_worker.cpp), so its return does not prove the worker is +// gone; freeing the buffers on that path would pull them out from under a live encode. +std::atomic workerExited_{false}; + +// Which worker generation is the live one. stopPinnedTask DETACHES a worker that overruns its +// join deadline rather than freeing it (platform_esp32_worker.cpp), so an orphan can still be +// parked in waitNotify when the next encoderStart runs. `running_` alone cannot gate it: that +// start sets running_ back to true, and the orphan would resume as a SECOND producer on the one +// encoder handle and scratch buffer. Each worker captures the generation it was spawned for and +// exits as soon as it is no longer current. +std::atomic generation_{0}; + +// Set when a segment was closed early (a frame that did not fit), so the fresh one is still +// waiting for its first keyframe. Without it the next P-frame would open the segment and a +// player seeking there would have no reference frame to decode against. +bool needKeyframe_ = false; + +esp_h264_enc_handle_t enc_ = nullptr; +uint8_t* yuv_ = nullptr; // one converted frame, the encoder's input +uint8_t* nal_ = nullptr; // one encoded frame, the encoder's output +uint16_t width_ = 0, height_ = 0; +uint8_t fps_ = 30; +uint32_t frameNo_ = 0; +// One per stream, never per frame or per segment: see mm::ts::Continuity. +mm::ts::Continuity cc_; + +SemaphoreHandle_t mutex_ = nullptr; + +struct Lock { + Lock() { if (mutex_) xSemaphoreTake(mutex_, portMAX_DELAY); } + ~Lock() { if (mutex_) xSemaphoreGive(mutex_); } +}; + +/// RGB888 -> the encoder's O_UYY_E_VYY layout: YUV420 packed as alternating chroma-prefixed +/// lines (odd lines carry U, even lines V, each followed by two luma samples). BT.601 integer +/// coefficients, which is what the H.264 default color matrix expects. +void rgbToEncoderFormat(const uint8_t* rgb, uint8_t* out, uint16_t w, uint16_t h) { + const size_t lineBytes = static_cast(w) * 3 / 2; + for (uint16_t y = 0; y < h; y++) { + uint8_t* dst = out + static_cast(y) * lineBytes; + const uint8_t* src = rgb + static_cast(y) * w * 3; + const bool evenRow = (y & 1) == 0; // rows 0, 2, 4...: these carry U, the others V + for (uint16_t x = 0; x < w; x += 2) { + const uint8_t* p0 = src + static_cast(x) * 3; + const uint8_t* p1 = (x + 1 < w) ? p0 + 3 : p0; + const int r0 = p0[0], g0 = p0[1], b0 = p0[2]; + const int r1 = p1[0], g1 = p1[1], b1 = p1[2]; + + const int y0 = (77 * r0 + 150 * g0 + 29 * b0) >> 8; + const int y1 = (77 * r1 + 150 * g1 + 29 * b1) >> 8; + // Chroma is subsampled 2x2; averaging the pair costs nothing and avoids the crawl a + // nearest-sample pick gives on hard edges. + const int rA = (r0 + r1) >> 1, gA = (g0 + g1) >> 1, bA = (b0 + b1) >> 1; + const int c = evenRow ? (((-43 * rA - 84 * gA + 128 * bA) >> 8) + 128) // U + : (((128 * rA - 107 * gA - 21 * bA) >> 8) + 128); // V + + *dst++ = static_cast(c < 0 ? 0 : (c > 255 ? 255 : c)); + *dst++ = static_cast(y0 < 0 ? 0 : (y0 > 255 ? 255 : y0)); + *dst++ = static_cast(y1 < 0 ? 0 : (y1 > 255 ? 255 : y1)); + } + } +} + +/// Close the current segment and open the next, overwriting the oldest. Called with the lock held. +/// Skips a slot still being served: dropping one segment is invisible to a player (it re-fetches +/// the playlist every second), where overwriting one mid-send corrupts what that viewer sees. +void rotateSegment() { + segments_[segWrite_].seq = nextSeq_++; + const uint32_t busy = serving_.load(); + for (size_t tried = 0; tried < kSegments; tried++) { + segWrite_ = (segWrite_ + 1) % kSegments; + // Only a slot actually being served is off limits. An empty slot carries seq 0, which is + // also kNoSeg, so comparing without the busy check skips every free slot and the ring + // never advances (bench: 12 rotations, all eight slots still seq 0). + if (busy == kNoSeg || segments_[segWrite_].seq != busy) break; + } + segments_[segWrite_].len = 0; + segments_[segWrite_].seq = 0; + segments_[segWrite_].frames = 0; +} + +void encodeOne(const uint8_t* rgb, size_t rgbLen) { + if (!enc_ || !yuv_ || !nal_) return; + const size_t need = static_cast(width_) * height_ * 3; + if (rgbLen < need) return; + + rgbToEncoderFormat(rgb, yuv_, width_, height_); + + esp_h264_enc_in_frame_t in{}; + in.raw_data.buffer = yuv_; + in.raw_data.len = static_cast(need / 2); // 1.5 bytes per pixel + in.pts = frameNo_ * (1000u / (fps_ ? fps_ : 30)); + + esp_h264_enc_out_frame_t out{}; + out.raw_data.buffer = nal_; + out.raw_data.len = static_cast(kSegmentBytes / 4); + + const esp_h264_err_t perr = esp_h264_enc_process(enc_, &in, &out); + if (perr != ESP_H264_ERR_OK || out.length == 0) { + dead_ = true; + return; + } + + const bool keyframe = out.frame_type == ESP_H264_FRAME_TYPE_IDR || + out.frame_type == ESP_H264_FRAME_TYPE_I; + const uint32_t pts90 = static_cast( + static_cast(frameNo_) * mm::ts::kClockHz / (fps_ ? fps_ : 30)); + frameNo_++; + + Lock lk; + Segment& seg = segments_[segWrite_]; + // A segment must START on a keyframe (a player seeking to it has nothing to reference + // otherwise), so a keyframe closes the previous one. GOP == fps, so this lands once a second. + if (keyframe && seg.len > 0) { + rotateSegment(); + } + // A segment opened by an overflow rotate holds nothing until a keyframe arrives: dropping + // these few P-frames costs a fraction of a second, where admitting them costs the segment. + if (needKeyframe_ && !keyframe) return; + needKeyframe_ = false; + Segment& dst = segments_[segWrite_]; + if (!dst.data) return; + + // The counters advance per packet as the writer emits. A discarded frame must not keep that + // advance: the packets were never sent, so a player would read the gap as lost packets, the + // exact corruption Continuity exists to prevent. + const mm::ts::Continuity ccBefore = cc_; + mm::ts::Writer w(dst.data + dst.len, kSegmentBytes - dst.len, cc_); + if (dst.len == 0) w.writeTables(); + w.writeAccessUnit(nal_, out.length, pts90, keyframe); + if (w.overflowed()) { + cc_ = ccBefore; + // The frame did not fit: close the segment here rather than emit a torn one, and hold + // the fresh one empty until a keyframe can open it (needKeyframe_). + if (dst.len > 0) rotateSegment(); + needKeyframe_ = true; + return; + } + dst.len += w.size(); + dst.frames++; +} + +void workerFn(void* arg) { + const uint32_t myGen = static_cast(reinterpret_cast(arg)); + taskWdtSubscribe(); + while (running_ && generation_.load() == myGen) { + taskWdtReset(); + const uint8_t* frame = nullptr; + size_t len = 0; + { + Lock lk; + if (count_ > 0) { frame = slots_[head_].data; len = slots_[head_].len; } + } + if (!frame) { waitNotify(task_, 100); continue; } + encodeOne(frame, len); + { + Lock lk; + head_ = (head_ + 1) % kSlots; + count_--; + } + } + taskWdtUnsubscribe(); + workerExited_ = true; // the buffers are now nobody's: encoderStop may free them +} + +void freeAll() { + if (enc_) { esp_h264_enc_close(enc_); esp_h264_enc_del(enc_); enc_ = nullptr; } + for (auto& s : slots_) { heap_caps_free(s.data); s.data = nullptr; s.len = 0; } + for (auto& s : segments_) { heap_caps_free(s.data); s.data = nullptr; s.len = 0; s.seq = 0; s.frames = 0; } + heap_caps_free(yuv_); yuv_ = nullptr; + heap_caps_free(nal_); nal_ = nullptr; +} + +void* psram(size_t bytes) { + return heap_caps_aligned_alloc(64, bytes, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +} + +} // namespace + +bool encoderStart(const EncoderConfig& cfg) { + // If the previous stop had to detach a wedged worker, encoderStop left its buffers alive on + // purpose (see there) and the pointers below are overwritten rather than freed: a bounded + // one-time leak, deliberately preferred to freeing memory a live task is still writing. + encoderStop(); + if (!mutex_) mutex_ = xSemaphoreCreateMutex(); + if (!mutex_) return false; + + // The hardware encoder's own limits (esp_h264_types.h): below 80 or above 1920x2032 it will + // refuse, so decline here with a status rather than fail obscurely mid-stream. + if (cfg.width < 80 || cfg.height < 80 || cfg.width > 1920 || cfg.height > 2032) return false; + // 4:2:0 chroma needs even dimensions. + if ((cfg.width & 1) || (cfg.height & 1)) return false; + + width_ = cfg.width; + height_ = cfg.height; + fps_ = cfg.fps ? cfg.fps : 30; + frameNo_ = 0; + + esp_h264_enc_cfg_hw_t hw{}; + hw.pic_type = ESP_H264_RAW_FMT_O_UYY_E_VYY; + hw.gop = fps_; // one keyframe per second: the segment boundary + hw.fps = fps_; + hw.res.width = width_; + hw.res.height = height_; + hw.rc.bitrate = static_cast(cfg.bitrateKbit) * 1000u; + // The QP window the rate controller may use. A near-fixed window (the 25/26 this started + // with) overrides the bitrate entirely: quality is pinned, so the encoder spends whatever + // that costs and ignores rc.bitrate. Opening the window lets the configured bitrate actually + // govern, which is what the driver's control promises. + hw.rc.qp_min = 10; + hw.rc.qp_max = 40; + + if (esp_h264_enc_hw_new(&hw, &enc_) != ESP_H264_ERR_OK || !enc_) { enc_ = nullptr; return false; } + if (esp_h264_enc_open(enc_) != ESP_H264_ERR_OK) { + esp_h264_enc_del(enc_); + enc_ = nullptr; + return false; + } + + const size_t rgbBytes = static_cast(width_) * height_ * 3; + yuv_ = static_cast(psram(rgbBytes / 2)); + nal_ = static_cast(psram(kSegmentBytes / 4)); + for (auto& s : slots_) s.data = static_cast(psram(rgbBytes)); + for (auto& s : segments_) { s.data = static_cast(psram(kSegmentBytes)); s.len = 0; s.seq = 0; s.frames = 0; } + if (!yuv_ || !nal_) { freeAll(); return false; } + for (const auto& s : slots_) if (!s.data) { freeAll(); return false; } + for (const auto& s : segments_) if (!s.data) { freeAll(); return false; } + + head_ = count_ = 0; + segWrite_ = 0; + nextSeq_ = 1; + cc_ = mm::ts::Continuity{}; + needKeyframe_ = false; + dead_ = false; + running_ = true; + // A new generation retires any orphan the previous stop had to detach. + const uint32_t myGen = generation_.fetch_add(1) + 1; + // Clear HERE, not in the worker: the worker's first instruction runs only once the scheduler + // reaches it, and a stop landing in that window would read the previous stop's `true` and + // free the buffers the worker is about to encode from. + workerExited_ = false; + // Core 1: core 0 runs the network stack, and starving it stalls the HTTP server that serves + // these very segments (the LC16 lesson). + // 16 KB, not the 8 KB this started with: the hardware-encoder call chain plus our muxer + // overflowed that and jumped into libm with a corrupted pointer (an "Illegal instruction" + // panic loop on the bench). Espressif's own esp_h264 example runs its encode from a 10 KB + // task, and the muxer's frame loop sits on top of that. + if (!spawnPinnedTask(task_, "mmH264", workerFn, + reinterpret_cast(static_cast(myGen)), + 16 * 1024, 5, 1)) { + running_ = false; + freeAll(); + return false; + } + return true; +} + +int encoderWrite(const uint8_t* data, size_t len) { + if (!data || len == 0) return -1; // invalid input, distinct from the queue-full drop (0) + if (!running_ || dead_) return -1; + Lock lk; + if (count_ >= kSlots) return 0; // encoder behind: drop-newest, stay live + Slot& s = slots_[(head_ + count_) % kSlots]; + if (!s.data) return -1; + const size_t cap = static_cast(width_) * height_ * 3; + const size_t n = len < cap ? len : cap; + std::memcpy(s.data, data, n); + s.len = n; + count_++; + notifyTask(task_); + return static_cast(n); +} + +bool encoderRunning() { return running_ && !dead_; } + +void encoderStop() { + if (running_) { + running_ = false; + stopPinnedTask(task_); + } + Lock lk; + // Free ONLY once the worker has actually returned. stopPinnedTask detaches on a timeout and + // returns while the worker runs on, and that worker is mid-encode holding raw pointers to + // these buffers and to the encoder handle: freeing here would be a use-after-free in PSRAM + // plus a call into a deleted esp_h264 session. Leaking a few MB of PSRAM until the next + // start is the better trade, and the same one the worker layer makes for its own state. + if (workerExited_) { + freeAll(); + head_ = count_ = 0; + segWrite_ = 0; + } +} + +bool hlsSegment(const char* name, const uint8_t** data, size_t* len) { + if (!name || !data || !len) return false; + Lock lk; + + // The playlist is generated on demand from the ring: whatever segments are currently complete, + // newest last. Static buffer because the caller writes it straight to the socket. + if (std::strcmp(name, "stream.m3u8") == 0) { + static char playlist[640]; // header + two lines per segment, kSegments of them + uint32_t oldest = 0; + for (const auto& s : segments_) + if (s.seq && (oldest == 0 || s.seq < oldest)) oldest = s.seq; + if (!oldest) return false; // nothing complete yet + + // Advertise from the oldest segment PLUS A MARGIN, never the oldest itself: that slot is + // the next one rotation overwrites, so a player fetching it races the encoder and gets a + // 404. The margin is what a player has left to fetch what it was promised; the rest of + // the ring is its buffering budget. (Bench: listing the true oldest 404'd immediately, + // and listing only the newest few 404'd within about five seconds. Both spin forever.) + uint32_t first = oldest + kReserved; + if (first >= nextSeq_) first = oldest; // ring not yet full: nothing to reserve + int n = std::snprintf(playlist, sizeof(playlist), + "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:1\n" + "#EXT-X-MEDIA-SEQUENCE:%u\n", static_cast(first)); + for (uint32_t q = first; q < nextSeq_ && n > 0 && n < static_cast(sizeof(playlist)); q++) { + const Segment* seg = nullptr; + for (const auto& s : segments_) if (s.seq == q) seg = &s; + if (!seg) continue; + // The segment's REAL duration, from the frames actually in it. Claiming a flat 1.0 s + // while delivering fewer makes the player run ahead of the stream until it stalls to + // re-buffer -- the periodic hiccup, visible as segments arriving every ~0.7 s. + const uint32_t milli = fps_ ? (static_cast(seg->frames) * 1000u) / fps_ : 1000u; + // snprintf returns the length it WOULD have written, so an unchecked accumulate can + // push n past the buffer and report more bytes than exist. Not reachable at this + // sizing, but the clamp costs nothing and the failure would be served garbage. + if (n < 0 || n >= static_cast(sizeof(playlist))) break; + n += std::snprintf(playlist + n, sizeof(playlist) - n, + "#EXTINF:%u.%03u,\nseg%u.ts\n", + static_cast(milli / 1000u), + static_cast(milli % 1000u), static_cast(q)); + } + // Clamp: snprintf reports the length it WOULD have written, so an accumulated n can + // exceed the buffer and hand the caller bytes past its end. + if (n < 0) return false; + if (n > static_cast(sizeof(playlist)) - 1) n = static_cast(sizeof(playlist)) - 1; + *data = reinterpret_cast(playlist); + *len = static_cast(n); + return *len > 0; + } + + unsigned q = 0; + if (std::sscanf(name, "seg%u.ts", &q) != 1) return false; + for (const auto& s : segments_) { + if (s.seq == q && s.len > 0) { + // Reserved until hlsSegmentRelease: the caller reads this pointer after the lock + // drops, and the encoder must not recycle the slot underneath it. + serving_ = q; + *data = s.data; + *len = s.len; + return true; + } + } + return false; +} + +void hlsSegmentRelease() { serving_ = kNoSeg; } + +} // namespace mm::platform + +#else // !CONFIG_MM_HLS + +// The HTTP server calls the RAM-segment seam on every /hls/ request whatever the platform, so a +// build without the encoder still has to answer it: no segments in RAM, fall through to the +// filesystem path (where there is nothing either, and the request 404s as it should). +namespace mm::platform { +bool hlsSegment(const char*, const uint8_t**, size_t*) { return false; } +void hlsSegmentRelease() {} +// The whole encoder seam, not just the segment half: platform.h declares these for every target, +// so a build that reaches them without CONFIG_MM_HLS must link rather than fail. Starting fails, +// which is what the driver reports; the rest are inert. +bool encoderStart(const EncoderConfig&) { return false; } +int encoderWrite(const uint8_t*, size_t) { return -1; } +bool encoderRunning() { return false; } +void encoderStop() {} +} // namespace mm::platform + +#endif // CONFIG_MM_HLS diff --git a/src/platform/platform.h b/src/platform/platform.h index c0dea6b3..7aa0ec1d 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -3,22 +3,22 @@ #include #include #include -#include "platform_config.h" // hasOta / hasPsram / … — flags this header's contract refers to +#include "platform_config.h" // hasOta / hasPsram / …: flags this header's contract refers to // The render path must not allocate or block (architecture.md § Hot path discipline). Clang 20+ // checks that TRANSITIVELY under -Wfunction-effects: the attribute is inherited by overrides, so // marking the three tick methods here covers every module's tick and everything it calls, which a -// regex over source text can never do — it sees a tick body, not what its callees reach. +// regex over source text can never do: it sees a tick body, not what its callees reach. // -// On GCC it expands to `noexcept` alone — that toolchain has neither the attribute nor the warning +// On GCC it expands to `noexcept` alone: that toolchain has neither the attribute nor the warning // (so the effect check is desktop-only), but the exception contract still holds. It builds with // -Werror, so a bare [[clang::nonblocking]] there is a build break (-Wattributes). Same shape as // MM_PRINTF_FORMAT in JsonSink.h. That makes this a DESKTOP-side check, which loses nothing: every -// tick method — modules, effects, and the LED drivers — compiles on desktop. src/platform/esp32/ +// tick method: modules, effects, and the LED drivers: compiles on desktop. src/platform/esp32/ // has no tick methods at all; it is free functions the tick path calls INTO, and those are checked // through their call sites. // Feature-tested, not version-inferred: Apple Clang carries its own version line, so a -// `__clang_major__ >= 20` check reports true on toolchains that predate the attribute — the CI +// `__clang_major__ >= 20` check reports true on toolchains that predate the attribute: the CI // macos-14 runner is exactly that case. __has_cpp_attribute asks the compiler directly. #if defined(__clang__) && defined(__has_cpp_attribute) && __has_cpp_attribute(clang::nonblocking) // noexcept is part of the contract, not decoration: clang warns @@ -37,8 +37,8 @@ uint32_t millis() MM_NONBLOCKING; /// concurrent ones. Zero is never returned, so a caller can use it as "no thread recorded". /// /// Exists because C++ `thread_local` is NOT usable on the ESP32: the compiler reaches TLS through -/// the THREADPTR special register, and a FreeRTOS task that was not created with TLS initialised -/// has THREADPTR = 0 — so the access dereferences a small offset from null (0xfffffff0 was the +/// the THREADPTR special register, and a FreeRTOS task that was not created with TLS initialized +/// has THREADPTR = 0: so the access dereferences a small offset from null (0xfffffff0 was the /// measured faulting address) and dies inside the exception handler as a Double exception. This is /// the portable seam for "which thread am I", used where per-thread state is genuinely needed. uintptr_t currentThreadId() MM_NONBLOCKING; @@ -47,17 +47,17 @@ uint32_t micros() MM_NONBLOCKING; // Test-only override: when set to non-zero, millis() returns this value instead // of reading the platform clock. Production code never calls this; tests use it // to drive virtual time deterministically (replaces the wall-clock delayMs in -// animation tests). Pass 0 to restore real-clock behaviour — tests must reset +// animation tests). Pass 0 to restore real-clock behavior: tests must reset // in release so cases stay independent. ESP32 honours the override too so a // scenario-tests run on real hardware can still freeze time if needed. void setTestNowMs(uint32_t ms); // Force the next UdpSocket::bind() calls to fail, so a test can exercise a bind-failure path without -// depending on the OS to refuse a port. Production code never calls this. The alternative — hog the -// port with a second socket — is NOT portable: on Linux, SO_REUSEADDR on a UDP socket bound to +// depending on the OS to refuse a port. Production code never calls this. The alternative: hog the +// port with a second socket: is NOT portable: on Linux, SO_REUSEADDR on a UDP socket bound to // INADDR_ANY *permits* the overlapping bind, so the hog succeeds and the failure never happens (this // silently broke unit_AudioService_sync on Linux for as long as it existed; nothing caught it because -// CI did not compile the C++ tests until the sanitizer job). Nor is a privileged port reliable — +// CI did not compile the C++ tests until the sanitizer job). Nor is a privileged port reliable - // modern macOS lets a non-root process bind port 80. Pass false to restore; tests must reset in // release so cases stay independent, same contract as setTestNowMs. void setTestBindFails(bool fail); @@ -65,23 +65,23 @@ void setTestBindFails(bool fail); void* alloc(size_t bytes); void free(void* ptr); -// Internal-RAM-only allocation — the mirror of alloc()'s PSRAM-first policy, for buffers a hot ISR READS. +// Internal-RAM-only allocation: the mirror of alloc()'s PSRAM-first policy, for buffers a hot ISR READS. // alloc() prefers PSRAM because most large buffers are touched from tasks where PSRAM latency amortizes; // a buffer read per-byte inside an interrupt (the streaming ring's encode source) pays that latency // hundreds of times per invocation and blows its deadline (measured: ~595 µs per slice refill with a -// PSRAM-resident source, against a 151 µs drain budget). Returns nullptr when internal RAM can't supply it — +// PSRAM-resident source, against a 151 µs drain budget). Returns nullptr when internal RAM can't supply it - // the CALLER decides the fallback (typically plain alloc(), accepting the slower PSRAM read over failing). // Free with the ordinary free(). void* allocInternal(size_t bytes); -// True when the pointer resolves to external (PSRAM) memory — the standard residency probe (IDF's +// True when the pointer resolves to external (PSRAM) memory: the standard residency probe (IDF's // esp_ptr_external_ram). Diagnostic companion to allocInternal's internal-first-PSRAM-fallback pattern: // the caller of that pattern cannot otherwise tell which way an allocation landed, and for buffers an // ISR reads the difference is a measured 4-8x per-byte cost plus cache-contention exposure. Desktop has // no PSRAM; always false. bool ptrIsPsram(const void* p); -// CPU cycle counter (Xtensa CCOUNT / RISC-V mcycle; 0-based, wraps at 2^32 — callers difference two +// CPU cycle counter (Xtensa CCOUNT / RISC-V mcycle; 0-based, wraps at 2^32: callers difference two // reads). The standard fine-grained profiling primitive (ARM's DWT_CYCCNT, x86's rdtsc): a 1-instruction // read, safe in ISRs, used by bench diagnostics to attribute hot-path cycles. Desktop returns a // nanosecond-scaled clock so differences are still meaningful. @@ -90,7 +90,7 @@ uint32_t cycleCount(); // Executable memory for JIT-emitted native code (MoonLive). Distinct from alloc() // because code must live in memory the CPU can FETCH from, not just read/write: // IRAM on ESP32 (MALLOC_CAP_EXEC), an mmap'd PROT_EXEC page on desktop. Returns -// nullptr when exec memory is exhausted — the caller degrades (status, runs dark), +// nullptr when exec memory is exhausted: the caller degrades (status, runs dark), // never crashes. freeExec takes the same size so a backend that needs it (munmap) // has it; ESP32 ignores the size. void* allocExec(size_t bytes); @@ -102,16 +102,16 @@ void freeExec(void* ptr, size_t bytes); // the instruction cache must be synced so the core fetches the fresh bytes, not stale // cache. Both quirks live here, behind the platform line; the engine just hands over // (dst-from-allocExec, src-bytes, len). On desktop this is a plain memcpy. `len` need -// not be a multiple of 4 — the ESP32 path pads the final partial word. +// not be a multiple of 4: the ESP32 path pads the final partial word. void writeExec(void* dst, const void* src, size_t len); void yield(); // Which CPU core the caller runs on (0 or 1 on the S3; always 0 on single-core parts and desktop). The -// render loop is core 0; the multicore render/encode split runs a driver's tick on core 1 — so a driver +// render loop is core 0; the multicore render/encode split runs a driver's tick on core 1: so a driver // seeing core 1 here KNOWS the split is engaged and core 0 is the idle helper (xPortGetCoreID's role). uint8_t currentCore(); -// Upper bound on cores that run driver code concurrently — sizes per-CPU scratch (the textbook +// Upper bound on cores that run driver code concurrently: sizes per-CPU scratch (the textbook // per-CPU-data pattern: one slice per core, no hot-path locking). A cap, not the exact count: // single-core parts and desktop simply leave slice 1 unused. inline constexpr uint8_t kMaxCores = 2; @@ -121,7 +121,7 @@ void delayUs(uint32_t us); // blocking busy-wait for sub-ms protocol gaps (e.g. // hundred µs, not a general-purpose sleep size_t freeHeap(); // total free (internal + PSRAM if present) size_t freeInternalHeap(); // internal RAM only (for stack/HTTP/WiFi reserve check) -size_t maxAllocBlock(); // largest contiguous block (any memory type — incl PSRAM) +size_t maxAllocBlock(); // largest contiguous block (any memory type: incl PSRAM) size_t maxInternalAllocBlock(); // largest contiguous block in INTERNAL RAM only // --- RTOS task introspection (TasksModule) -------------------------------------------------- @@ -129,9 +129,9 @@ size_t maxInternalAllocBlock(); // largest contiguous block in INTERNAL RAM only // FreeRTOS type escapes src/platform/ (the platform-boundary rule). ESP32 fills it from // uxTaskGetSystemState (the textbook RTOS-introspection call, needs CONFIG_FREERTOS_USE_TRACE_ // FACILITY); it uses a fixed static scratch (no heap) but briefly suspends the scheduler while it -// walks the task list — call it off the per-frame path (tick1s, once a second), not from tick(). +// walks the task list: call it off the per-frame path (tick1s, once a second), not from tick(). // Desktop returns 0 (no RTOS). cpuPermille is 0..1000, or kTaskCpuUnmeasured when run-time-stats are -// compiled out (the cheap default) — the caller shows a CPU% column only when it's a real number. +// compiled out (the cheap default): the caller shows a CPU% column only when it's a real number. enum class TaskState : uint8_t { Running, Ready, Blocked, Suspended, Deleted, Invalid, Unknown }; constexpr uint32_t kTaskCpuUnmeasured = 0xFFFFFFFFu; struct TaskInfo { @@ -147,7 +147,7 @@ struct TaskInfo { size_t taskSnapshot(TaskInfo* out, size_t maxTasks); // Name of the task currently running on `core` (0 or 1); empty string if unavailable/single-core. void currentTaskOnCore(int core, char* out, size_t cap); -// Name of the RTOS task that runs the render loop (Scheduler::tick) — the task every MoonModule +// Name of the RTOS task that runs the render loop (Scheduler::tick): the task every MoonModule // executes inside today. TasksModule nests the module rows under the matching `tasks` entry rather // than hardcoding a task name. Empty on a target with no distinct render task (desktop). const char* renderTaskName(); @@ -157,7 +157,7 @@ void setTestTaskSnapshot(const TaskInfo* tasks, size_t count, const char* render // --- Pinned worker task + wake notification (render/encode multicore split) ------------------ // A minimal own-a-thread seam: spawn one function on a named task pinned to `core`, plus a -// single-slot wake notification. This is FreeRTOS's textbook lock-free pairing — +// single-slot wake notification. This is FreeRTOS's textbook lock-free pairing - // xTaskCreatePinnedToCore + a direct-to-task notification (xTaskNotifyGive / ulTaskNotifyTake), // which the RTOS documents as the lightweight replacement for a binary semaphore in a // single-producer/single-consumer wake. The multicore pipeline (Drivers render↔encode split) @@ -168,7 +168,7 @@ void setTestTaskSnapshot(const TaskInfo* tasks, size_t count, const char* render struct WorkerTask { void* impl = nullptr; }; using WorkerFn = void(*)(void* user); // Spawn `fn(user)` on a task named `name` with `stackBytes` stack, at `priority`, pinned to -// `core` (0 or 1; -1 = no affinity). Returns false if the task couldn't be created — the caller +// `core` (0 or 1; -1 = no affinity). Returns false if the task couldn't be created: the caller // then runs the work inline (the allocate-and-degrade fallback). The spawned fn owns its loop and // returns only after stopPinnedTask signals it. bool spawnPinnedTask(WorkerTask& t, const char* name, WorkerFn fn, void* user, @@ -191,25 +191,25 @@ void taskWdtSubscribe(); // encode worker when the render split disengages) leaves no dangling WDT entry. No-op on desktop, and // no-op on ESP32 if the task never subscribed. void taskWdtUnsubscribe(); -// Reset THIS task's watchdog (esp_task_wdt_reset) — called each tick to feed the subscription above. No-op +// Reset THIS task's watchdog (esp_task_wdt_reset): called each tick to feed the subscription above. No-op // on desktop, and no-op on ESP32 if the task never subscribed. void taskWdtReset(); // --- GPIO capability introspection (PinsModule) --------------------------------------------- // Static per-pin capability for one GPIO, so the pin ownership map can flag a claim that lands on -// an unsafe pin — an output role driven onto an input-only pin or a boot strap, or any role on a +// an unsafe pin: an output role driven onto an input-only pin or a boot strap, or any role on a // reserved (flash/PSRAM/USB) pin. Domain-neutral; no chip API escapes src/platform/. ESP32 fills // `validGpio`/`outputCapable`/`rtc` from the IDF's own GPIO_IS_VALID_GPIO / GPIO_IS_VALID_OUTPUT_ // GPIO / rtc_gpio_is_valid_gpio (the textbook, always-correct SDK queries), and overlays `strap` / // `reserved` from a small per-chip table sourced from docs/reference/gpio-usage.md (the SDK has no -// "is this a strap / flash pin" query — that's board/datasheet knowledge). Desktop returns +// "is this a strap / flash pin" query: that's board/datasheet knowledge). Desktop returns // "all valid, nothing reserved" (a host build has no real GPIOs to protect). Pure lookup, no state. struct GpioCapability { bool validGpio = true; // a real, usable GPIO on this chip (false = out of range / not bonded) bool outputCapable = true; // has an output driver (false = input-only, e.g. classic ESP32 34-39) bool rtc = false; // an RTC/low-power-domain pin (usable for deep-sleep wake / RTC I/O) - bool strap = false; // a boot-strapping pin — driving it at reset can change boot mode - bool reserved = false; // wired to flash / PSRAM / native USB — routing I/O here corrupts the device + bool strap = false; // a boot-strapping pin: driving it at reset can change boot mode + bool reserved = false; // wired to flash / PSRAM / native USB: routing I/O here corrupts the device }; GpioCapability gpioCapability(uint8_t gpio); // Test-only (desktop): make gpioCapability(gpio) return `cap` for one specific gpio, so PinsModule's @@ -218,7 +218,7 @@ GpioCapability gpioCapability(uint8_t gpio); void setTestGpioCapability(uint8_t gpio, GpioCapability cap); void clearTestGpioCapability(); -// Live electrical state of one GPIO — the pin map's second axis (what a pin is *doing now*, vs. +// Live electrical state of one GPIO: the pin map's second axis (what a pin is *doing now*, vs. // gpioCapability's static "what it *is*"). The see-the-wire HAL check: gpio_get_level reads the pad on // ANY pin, even one a peripheral drives, so a driver's output must toggle when it renders and a mic // clock must toggle when the mic runs. Sampled on tick1s (off the hot path), not per frame. Desktop @@ -227,7 +227,7 @@ struct GpioLiveState { bool valid = false; // pin is readable (false = out of range, or desktop → columns omitted) bool level = false; // current pad level: true = HIGH, false = LOW (gpio_get_level) bool output = false; // the pad's output driver is enabled RIGHT NOW (gpio_get_io_config .oe) - bool input = false; // the pad's input buffer is enabled RIGHT NOW (.ie) — a pin can be both + bool input = false; // the pad's input buffer is enabled RIGHT NOW (.ie): a pin can be both uint8_t driveCap = 0; // output drive strength 0..3 = WEAK / MEDIUM / STRONG / STRONGEST }; GpioLiveState gpioLiveState(uint8_t gpio); @@ -242,18 +242,18 @@ void clearTestGpioLiveState(); void setTestMaxAllocBlock(size_t bytes); // (scarce; use this as the memory-pressure KPI). // PSRAM blocks dominate on S3/S2 boards and make - // maxAllocBlock useless as a stress signal — + // maxAllocBlock useless as a stress signal - // it'll report ~8 MB even when DRAM is exhausted. size_t totalHeap(); // total heap capacity (internal + PSRAM) size_t totalInternalHeap(); // total internal heap capacity -// Heap to keep free for stack, HTTP, WiFi, and overhead when sizing buffers — +// Heap to keep free for stack, HTTP, WiFi, and overhead when sizing buffers - // a platform memory constraint, not a domain one (it guards core subsystems). // Any allocator checks free heap against this reserve before committing. constexpr size_t HEAP_RESERVE = 32768; void getMacAddress(uint8_t mac[6]); -// The MAC as canonical "XX:XX:XX:XX:XX:XX", formatted once into a static buffer — a stable per-chip +// The MAC as canonical "XX:XX:XX:XX:XX:XX", formatted once into a static buffer: a stable per-chip // identity string a caller can point at without keeping its own copy. (chipModel/sdkVersion likewise // return static strings; a ReadOnly control binds straight to these, storing nothing per-module.) const char* macString(); @@ -261,7 +261,7 @@ const char* chipModel(); const char* sdkVersion(); // CPU frequency + core count as one short static string ("240 MHz, 2 cores"), read from the RUNNING -// hardware, not a config macro — so a stale sdkconfig or a PM downclock is visible in the UI (finding +// hardware, not a config macro: so a stale sdkconfig or a PM downclock is visible in the UI (finding // the chip silently at 160 MHz is exactly what this control exists to catch). Desktop reports cores // only (host clock speed has no portable query). Static-buffer contract as macString above. const char* cpuInfo(); @@ -282,12 +282,12 @@ const char* psramType(); // // "no version reply" rather than "not detected": on the bench the C6 associates and // serves traffic while this particular RPC times out, so declaring the slave absent -// would be a false statement about working hardware. The field says what is known — -// the query did not answer — and leaves the conclusion to whoever reads it. +// would be a false statement about working hardware. The field says what is known - +// the query did not answer: and leaves the conclusion to whoever reads it. const char* coprocessorWifi(); // This host's LAN IPv4 address as a dotted string, or "" if unavailable. -// Desktop: the outbound interface address. ESP32: empty — the device IP is +// Desktop: the outbound interface address. ESP32: empty: the device IP is // owned by NetworkModule (WiFi/Ethernet), not the platform layer. const char* hostIp(); @@ -299,7 +299,7 @@ const char* resetReason(); // Serial log verbosity, low to high. Mirrors the standard syslog/ESP-IDF ordering so the // numeric value maps straight onto esp_log_level_set (None=0 … Verbose=5). The periodic KPI // tick line (a plain stdout printf, not an ESP_LOG) is emitted only at Info or above, so a -// resting device at Warn stays quiet on the wire — no once-a-second serial write — while real +// resting device at Warn stays quiet on the wire: no once-a-second serial write: while real // ESP_LOGW/ESP_LOGE warnings and errors still print. setLogLevel applies it to the ESP-IDF // logger; the KPI-line gate is read from the same value in the main loop. Desktop is a no-op. enum class LogLevel : uint8_t { None = 0, Error, Warn, Info, Debug, Verbose }; @@ -311,7 +311,7 @@ size_t flashChipSize(); // total flash chip capacity size_t filesystemUsed(); // filesystem used bytes size_t filesystemTotal(); // filesystem total bytes -// Filesystem — LittleFS on ESP32, std::filesystem on desktop (rooted at ./.config/'s parent). +// Filesystem: LittleFS on ESP32, std::filesystem on desktop (rooted at ./.config/'s parent). // Paths are absolute-looking (start with '/'); desktop strips the leading '/' so // "/.config/System.json" maps to "/.config/System.json". // @@ -339,7 +339,7 @@ bool fsWriteAtomic(const char* path, const char* data, size_t len); // with `*abort == false` is a clean EOF (commit), a 0 (or any return) with `*abort == true` is an // error (a short/timed-out upload) → the temp file is DISCARDED, not renamed. Returns false on abort, // a write failure, or a rename failure. Lets the HTTP layer stream an upload of any size with a fixed -// small buffer — the device never holds the whole file in RAM. Caller ensures the parent dir exists. +// small buffer: the device never holds the whole file in RAM. Caller ensures the parent dir exists. using FsWriteSrc = size_t(*)(char* buf, size_t cap, void* user, bool* abort); bool fsWriteStream(const char* path, FsWriteSrc src, void* user); // Per-entry callback for fsList: name, whether it's a directory, and its size in bytes @@ -349,17 +349,17 @@ void fsList(const char* dir, FsListCb cb, void* user); // single-level lis // Network (ESP32 only, stubs on desktop) // setEthConfig overrides the per-chip default eth pin/PHY map (ethConfigDefault) -// with a board's runtime config before ethInit — NetworkModule pushes the values +// with a board's runtime config before ethInit: NetworkModule pushes the values // it got from deviceModels.json. Call before ethInit(); takes effect on the next init. void setEthConfig(const EthPinConfig& cfg); bool ethInit(); -// Tear down a running Ethernet driver so ethInit() can re-init with new config — +// Tear down a running Ethernet driver so ethInit() can re-init with new config - // the live reconfigure path (used for W5500/SPI, which tears down cleanly; RMII // keeps apply-on-next-init). Safe to call when nothing is running. Desktop: no-op. void ethStop(); bool ethLinkUp() MM_NONBLOCKING; // PHY link detected (cable plugged, fast check) bool ethConnected() MM_NONBLOCKING; // IP assigned (DHCP complete) -// Current IP as raw octets — out[0..3]. All-zero (0.0.0.0) means "no IP yet". +// Current IP as raw octets: out[0..3]. All-zero (0.0.0.0) means "no IP yet". // Octets, not a string: the IP's canonical form is uint8_t[4] (matching the // static-IP controls and formatDottedQuad); callers that need text format at // their own boundary, callers that need bytes (ArtNet) use them directly. @@ -371,12 +371,12 @@ void ethGetIPv4(uint8_t out[4]) MM_NONBLOCKING; // never to an IP. // // Needs a link, not an IP: ethLinkUp() is the precondition, ethConnected() is not. That split is -// the point of the seam — a board whose DHCP never completes can still drive panels, and the +// the point of the seam: a board whose DHCP never completes can still drive panels, and the // driver's status says which of the two is missing. // // `len` is the payload as handed to the MAC: below 60 bytes the hardware pads to the Ethernet // minimum, so callers need not. Returns false when no driver is running, the link is down, or the -// MAC rejects the frame (a full TX ring) — a dropped frame, like a dropped UDP packet, is the +// MAC rejects the frame (a full TX ring): a dropped frame, like a dropped UDP packet, is the // caller's to tolerate. Desktop records the frame instead of sending it, which is what lets the // driver and its tests run on the host. bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING; @@ -386,7 +386,7 @@ bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING; // Exists because a panel wall is a BURST, not a stream: a 128-row wall is ~131 frames that must all // land inside the inter-frame window, since the cards have no buffering and latch on the sync frame. // Where the platform can hand the whole burst to the kernel at once it should, and only the caller -// knows where a burst ends — hence a seam rather than a heuristic on frame contents, which would put +// knows where a burst ends: hence a seam rather than a heuristic on frame contents, which would put // wire-format knowledge in the platform layer. // // Idempotent and safe to call with nothing pending. A platform that already sends each frame as it @@ -395,11 +395,11 @@ void ethFlushRaw() MM_NONBLOCKING; // Claim the Ethernet interface for direct L2 use, or release it. A driver that addresses the wire // below IP calls this in prepare/release to STATE its intent, rather than leaving -// NetworkModule to infer it from traffic — the driver knows, and a claim made before the first frame +// NetworkModule to infer it from traffic: the driver knows, and a claim made before the first frame // cannot race the cascade's DHCP timeout. // // What it changes: nothing about the hardware. Ethernet keeps running and the cascade still moves on -// to WiFi for IP service (which is what a panel rig wants — panels on the wire, UI over WiFi). It +// to WiFi for IP service (which is what a panel rig wants: panels on the wire, UI over WiFi). It // only tells NetworkModule that a leaseless link is intended rather than broken. // // Reference-counted, so two drivers sharing the link both have to release before the claim drops. @@ -449,7 +449,7 @@ bool ethRestartTx(); // "100 Mbit, expect tearing" instead of either failing silently or refusing to run. uint16_t ethLinkSpeedMbps() MM_NONBLOCKING; -// Bind raw sending to a host network interface by name ("eth0", "en0"). ESP32 ignores this — it has +// Bind raw sending to a host network interface by name ("eth0", "en0"). ESP32 ignores this: it has // one MAC and ethSendRaw always uses it. On desktop it opens the raw socket (Linux AF_PACKET, macOS // BPF) that makes a host a real panel controller: the same driver on a Pi or a mini-PC drives the // same cards, which is worth having both as a product and as the way to test the wire format @@ -466,6 +466,13 @@ bool ethBindRawInterface(const char* ifName); // entry 0 always "none (capture only)". Rebuilt on every call so a hot-plugged NIC appears on // the next schema rebuild. rawInterfaceName(i) is the BIND name behind row i (the pcap device // name on Windows differs from its label; on POSIX they are the same); nullptr for row 0. +// A label may carry a live DETAIL after ", " -- Windows appends the adapter's link speed +// ("Realtek PCIe GbE Family Controller, 1 Gb") -- because the name alone does not tell a picker +// which entry is the 1 Gb NIC and which is a Wi-Fi radio or a Hyper-V virtual switch. Only the +// part BEFORE that separator is the adapter's identity: the speed changes when a link +// renegotiates, and both the apply path (Control.cpp) and the driver's own remap compare on the +// stable head so a changed speed does not read as a different NIC. +// // The Select persists by LABEL (see Control::persistLabel): a NIC keeps its identity across // reboots and Npcap reinstalls, the index-mismatch trap this exists to close. size_t rawInterfaces(const char* const** optionsOut); @@ -483,11 +490,11 @@ void setTestRawInterfaces(const char* const* names, size_t count); // // **The runtime is the USER'S, never ours.** projectMM is GPL-3.0 and the NDI runtime is // proprietary with redistribution terms GPL cannot carry downstream, so it is resolved on demand -// (dlopen / LoadLibrary) and never linked, never bundled, and its headers are never included — the +// (dlopen / LoadLibrary) and never linked, never bundled, and its headers are never included: the // same arrangement, and for the same reason, as Npcap for raw Ethernet. A machine without it builds // and runs identically; ndiAvailable() simply reads false and the driver says so. -// Is the NDI runtime present and loaded? False when it is not installed, which is not an error — +// Is the NDI runtime present and loaded? False when it is not installed, which is not an error - // the driver reports it as a status. Loads on first call. bool ndiAvailable(); @@ -505,7 +512,7 @@ bool ndiSendFrame(const uint8_t* rgb, uint16_t w, uint16_t h, uint8_t fps); // Desktop-only test seam, mirroring ethTestFrame* above: with no NDI runtime installed there is // nothing to send into, and CI never has one, so the frames the driver produced are RECORDED -// instead. That is what lets the conversion be pinned — the geometry, the packing, the pacing — +// instead. That is what lets the conversion be pinned: the geometry, the packing, the pacing - // without the proprietary runtime, leaving the bench to confirm only that a receiver sees it. #ifndef ESP_PLATFORM // Force the runtime's apparent presence, overriding whatever is really installed. A developer @@ -528,20 +535,41 @@ const char* ndiTestSenderName(); void ndiTestClearFrames(); #endif -// --- HLS video output (ffmpeg pipe) ----------------------------------------------------------- +// --- HLS video output ------------------------------------------------------------------------- +// +// projectMM as an HLS source: the rendered grid, output correction applied and any integer +// upscaling done (see HlsDriver's `scale`), reaches a TV, VLC or a browser as H.264 over HLS. +// Gated by `hasHls`. +// +// **The seam carries NUMBERS, not an encoder command line.** The driver states the frame geometry, +// the rate and the bitrate; how those become H.264 is entirely the platform's business, because +// the two implementations share no mechanism: // -// projectMM as an HLS source: the rendered frame, pixel-exact, reaches a TV, VLC or a browser as -// H.264 over HLS. Gated by `hasHls` (desktop true, ESP32 false: no hardware encoder there). +// - **Desktop** spawns the `ffmpeg` found on PATH and pipes raw frames to its stdin, ffmpeg doing +// both the encode and the HLS segmenting. **ffmpeg is the USER'S, never ours**: nothing is +// vendored or linked, the same runtime-dependency arrangement as Npcap and NDI. A machine +// without ffmpeg builds and runs identically; encoderStart() fails and the driver says so. +// - **ESP32-P4** drives the chip's hardware H.264 encoder and muxes the segments itself, in RAM. // -// **ffmpeg is the USER'S, never ours.** One general encode path for every desktop OS and the Pi: -// the platform spawns the `ffmpeg` found on PATH and pipes raw frames to its stdin; nothing is -// vendored or linked, the same runtime-dependency arrangement as Npcap and NDI. A machine -// without ffmpeg builds and runs identically; encoderStart() fails and the driver says so. +// An argv-shaped seam would have forced the P4 to string-parse `-s`/`-r`/`-b:v` back out of a +// command line assembled for a program it never runs. + +/// What to encode. Geometry and rate are the frame contract; `encoderName` names a desktop ffmpeg +/// encoder and is ignored where the platform has only one (see `hasEncoderChoice`). +struct EncoderConfig { + uint16_t width; + uint16_t height; + uint8_t fps; // also the GOP: hls_time can only cut on a keyframe, so a longer + // GOP silently lengthens every segment past the 1 s design + uint16_t bitrateKbit; + const char* encoderName; // e.g. "libx264"; nullptr or ignored where there is no choice + const char* outDir; // absolute directory for the playlist and segments (fs platforms) +}; -// Spawn the encoder process with a NUL-terminated argv (argv[0] = "ffmpeg", resolved via PATH), -// its stdin piped from us in NON-BLOCKING mode. Replaces any encoder already running. Returns -// false when ffmpeg is absent or the spawn fails (not an error: the driver reports a status). -bool encoderStart(const char* const argv[]); +// Start encoding to `cfg`. Replaces any encoder already running. Returns false when the platform +// cannot start one (desktop: ffmpeg absent or the spawn failed): not an error: the driver reports +// a status. +bool encoderStart(const EncoderConfig& cfg); // Hand one whole frame to the encoder. The frame is COPIED into a bounded queue and a platform // writer thread does the blocking pipe writes, so this never blocks the caller and a frame is @@ -557,6 +585,17 @@ bool encoderRunning(); // Safe with none running, so a driver's release() need not track state. void encoderStop(); +// Serve an HLS file the platform holds in RAM rather than on the filesystem. Returns false when +// this platform writes segments to disk (desktop: ffmpeg does), and the caller falls through to +// its normal file path. The P4 keeps segments in a PSRAM ring because at one per second the +// flash wear buys nothing: a live segment is stale within seconds. `name` is a bare filename +// ("stream.m3u8", "seg7.ts"); the returned pointer stays valid until the next encoderWrite. +bool hlsSegment(const char* name, const uint8_t** data, size_t* len); +// Release the segment a preceding hlsSegment() handed out, once the caller has finished reading +// it. Required after every hlsSegment() that returned true: until it is called the platform will +// not recycle that segment's memory, so a missed release stalls the ring by one slot. +void hlsSegmentRelease(); + #ifndef ESP_PLATFORM // Test seam, mirroring NdiTestMode: CI has no ffmpeg and a test must not need one. Record mode // makes encoderStart() a no-op recorder of its argv and encoderWrite() a frame recorder; @@ -600,11 +639,11 @@ uint32_t ethRestartCountForTest(); bool wifiStaInit(const char* ssid, const char* password); bool wifiStaConnected() MM_NONBLOCKING; -void wifiStaGetIPv4(uint8_t out[4]); // see ethGetIPv4 — same octet contract +void wifiStaGetIPv4(uint8_t out[4]); // see ethGetIPv4: same octet contract void wifiStaStop(); // STA-side RSSI in dBm (negative, e.g. -58). Returns 0 when the STA isn't -// associated or the call fails — NetworkModule only surfaces this control +// associated or the call fails: NetworkModule only surfaces this control // while state_ == ConnectedSta so a 0 is effectively unreachable. int wifiStaRssi(); @@ -616,11 +655,11 @@ int wifiStaChannel(); // A client interface (STA or Ethernet) whose addressing NetworkModule sets. One enum so a // single netSetStaticIPv4 serves both, rather than a per-interface duplicate. (The AP is not -// here: it is always the DHCP *server* at a fixed IP, a different role — wifiApInit sets that.) +// here: it is always the DHCP *server* at a fixed IP, a different role: wifiApInit sets that.) enum class NetIface : uint8_t { Sta, Eth }; // Switch a client interface to a STATIC IPv4 config: stop its DHCP client and pin ip/gateway/mask // (+ DNS if non-zero) onto the netif. Octets, matching ethGetIPv4/wifiStaGetIPv4. Passing an -// all-zero `ip` is a no-op guard (treated as "not static"). Idempotent — safe to re-apply. To go +// all-zero `ip` is a no-op guard (treated as "not static"). Idempotent: safe to re-apply. To go // back to DHCP, call netSetDhcp(iface). Desktop: no-op (host uses OS networking). The netif must // exist (interface init has run); NetworkModule calls this after bring-up / on a live toggle. void netSetStaticIPv4(NetIface iface, const uint8_t ip[4], const uint8_t gw[4], @@ -628,7 +667,7 @@ void netSetStaticIPv4(NetIface iface, const uint8_t ip[4], const uint8_t gw[4], // Return a client interface to DHCP: restart its DHCP client so it re-leases live (no reboot). // The counterpart to netSetStaticIPv4 for a Static→DHCP toggle. Desktop: no-op. void netSetDhcp(NetIface iface); -// Test seams (desktop-only impls, same contract as setTestBindFails — reset in release so cases +// Test seams (desktop-only impls, same contract as setTestBindFails: reset in release so cases // stay independent): make wifiStaInit() succeed so a host test can drive the STA cascade // (WaitingSta) that the radio-less desktop otherwise never enters, and count netSetStaticIPv4() // applies per interface so the test can pin that the static-addressing path reached the platform. @@ -643,8 +682,8 @@ void wifiApStop(); // the portal: bringing STA up switches the radio to STA mode, which drops the AP under them. uint32_t wifiApClientCount(); -// 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 — +// True when it is safe to open/use a socket: the TCP/IP stack is initialized 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 @@ -652,42 +691,42 @@ uint32_t wifiApClientCount(); 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. +// and we round to whole). Returns 0 when WiFi isn't initialized or the call +// fails. Same value for STA and AP: WiFi has one radio at one TX power. int wifiTxPower(); // Cap the WiFi transmit power. `quarterDbm` is in ESP-IDF's quarter-dBm units // (valid range 8..84 → 2..21 dBm); pass 0 to skip the override and let the // stack use its default. Used by NetworkModule for the weak-power / brown-out // WiFi cap: some boards / WiFi modules (a thin on-module LDO, a marginal USB -// supply — e.g. various S2/S3 mini-class boards) brown out at full TX power, +// supply: e.g. various S2/S3 mini-class boards) brown out at full TX power, // dropping WiFi during association. Capping to 8 dBm (32 quarter-dBm, the value // `deviceModels.json` injects for brown-out-prone entries) keeps them stable. Returns // true on success or when called with 0 (no-op). -// Call after esp_wifi_start() — earlier calls are silently ignored by ESP-IDF. +// Call after esp_wifi_start(): earlier calls are silently ignored by ESP-IDF. bool wifiSetTxPower(int8_t quarterDbm); // mDNS is advertise-only: `mdnsInit` brings the stack up and advertises this device as // `_http._tcp` (with an `mm=1` TXT) and `_wled._tcp` (with a `mac=` TXT), so the native // WLED app + Home Assistant discover it. Peer discovery is UDP presence (DevicesModule + -// WledPacket) — the platform exposes advertise here, discovery lives in the module. +// WledPacket): the platform exposes advertise here, discovery lives in the module. bool mdnsInit(const char* deviceName); // Stop advertising: remove both services and clear the hostname, keeping the stack up so // a later mdnsInit re-advertises without a full re-init (the mDNS toggle uses this). void mdnsStop(); -// Full mdns_free — call at release. +// Full mdns_free: call at release. void mdnsShutdown(); // Store the DHCP hostname (DHCP option 12) the next eth/wifi bring-up advertises. // Routers populate their client list from the DHCP request, not mDNS, so without // this a provisioned device shows as "Unknown" there. Call before ethInit() / -// wifiStaInit() — the netif applies it before the DHCP client starts, so it lands +// wifiStaInit(): the netif applies it before the DHCP client starts, so it lands // in the first DISCOVER. NetworkModule pushes deviceName (default MM-XXXX), keeping // the router name, the mDNS .local name and the SoftAP name one identity. A later // rename takes effect on the next DHCP renewal/reconnect. Desktop: no-op. void setHostname(const char* name); -// OTA — fetch a firmware image from `url` and flash it to the next OTA partition. +// OTA: fetch a firmware image from `url` and flash it to the next OTA partition. // ESP32: spawns a one-shot FreeRTOS task (the call returns immediately; the task // runs to completion or error). The task uses `esp_https_ota`, which rolls the // download + partition write + boot-pointer flip into one API; on success it @@ -697,7 +736,7 @@ void setHostname(const char* name); // // `statusBuf` is updated in place by the task with a short progress string // (e.g. "downloading", "flashing", "error: HTTP 404"). `bytesReadOut` / -// `bytesTotalOut` advance as the download proceeds — the UI renders them as +// `bytesTotalOut` advance as the download proceeds: the UI renders them as // "X KB / Y KB". `bytesTotalOut` is 0 until esp_https_ota reports the image // size (just after the HTTPS handshake), then holds the real value for the // rest of the task's lifetime. FirmwareUpdateModule polls all three at 1 Hz @@ -707,14 +746,14 @@ bool http_fetch_to_ota(const char* url, char* statusBuf, size_t statusBufLen, uint32_t* bytesReadOut, uint32_t* bytesTotalOut); -// OTA — flash a firmware image STREAMED from `src` (no URL fetch; the caller pulls the bytes, +// OTA: flash a firmware image STREAMED from `src` (no URL fetch; the caller pulls the bytes, // e.g. straight off an HTTP upload body). Same producer callback shape as fsWriteStream: `src` // fills up to `cap` bytes, returns the count (0 = clean EOF), and sets `*abort` to fail the OTA // (an incomplete/timed-out upload). Runs esp_ota_begin → esp_ota_write per chunk → esp_ota_end + -// set_boot_partition, then RETURNS true (it does NOT reboot — the caller sends its HTTP 200 first, +// set_boot_partition, then RETURNS true (it does NOT reboot: the caller sends its HTTP 200 first, // then reboots into the flashed image, the same order /api/reboot uses). SYNCHRONOUS (unlike // http_fetch_to_ota, which runs on its own task): the caller is the HTTP request handler, which runs -// on the tick20ms tick INSIDE Scheduler::tick — so this blocks rendering for the flash duration. That +// on the tick20ms tick INSIDE Scheduler::tick: so this blocks rendering for the flash duration. That // is the accepted trade-off (a firmware upload is user-initiated and reboots the device on success), // bounded by the same upload idle/hard limits; the caller needs the result to reply. // `statusBuf` / `bytesReadOut` are updated in place (bytesTotal is the caller-supplied @@ -742,9 +781,9 @@ bool moonbaseStageInstallUrl(const char* url); // switch leaves it armed, and the next unrelated MoonBase visit would auto-install it. void moonbaseClearStagedUrl(); -// Synchronous outbound HTTP request to a LAN host — plain HTTP, no TLS (the Philips Hue v1 +// Synchronous outbound HTTP request to a LAN host: plain HTTP, no TLS (the Philips Hue v1 // API, which HueDriver drives, allows it). Connects to `host:port`, sends `method path` -// with `reqBody` (NUL-terminated; "" for none — a Content-Length + JSON content-type are +// with `reqBody` (NUL-terminated; "" for none: a Content-Length + JSON content-type are // added when non-empty), and copies the RESPONSE BODY into `body` (NUL-terminated, truncated // to bodyLen-1). Returns the HTTP status code, or 0 on connect/timeout/error. Blocks up to // `timeoutMs`. Caller runs this OFF the render hot path (HueDriver on tick1s, like the OTA @@ -777,15 +816,15 @@ struct ImprovDeviceInfo { const char* chipFamily; // "ESP32" / "ESP32-S3" / ... const char* firmwareVersion; // e.g. "1.0.0-rc2" }; -// SET_TX_POWER RPC (command 0xFD) — when set, the Improv task validates the +// SET_TX_POWER RPC (command 0xFD): when set, the Improv task validates the // 1-byte dBm payload (0..21), writes it to txPowerOut, and publishes via // txPowerReady's release-store. This is the pre-association escape hatch for // boards whose LDO browns out at full TX power (weak-powered boards): their // catalog cap normally arrives over HTTP *after* the device is online, -// which such a board can never reach — proven on the bench 2026-06-10. It stays +// which such a board can never reach: proven on the bench 2026-06-10. It stays // a dedicated RPC (not an APPLY_OP) precisely because it must land BEFORE the // radio associates, whereas APPLY_OP ops apply once the device is up. -// opOut/opOutLen/opReady carry the APPLY_OP vendor RPC (0xFC) — one REST operation +// opOut/opOutLen/opReady carry the APPLY_OP vendor RPC (0xFC): one REST operation // as JSON, pushed over serial during provisioning ("Improv = REST over serial"). // Chunks reassemble into opOut; on the last chunk opReady's release-store publishes // it and ImprovProvisioningModule applies the op on the main loop. This is how the @@ -816,7 +855,7 @@ class UdpSocket { bool connect(const char* ip, uint16_t port); bool sendTo(const uint8_t* data, size_t len); // uses the connect()ed destination // Receiver side (ArtNet in): listen on `port` on any interface - // (SO_REUSEADDR) and flip the socket non-blocking — note that flips the + // (SO_REUSEADDR) and flip the socket non-blocking: note that flips the // whole socket, sendTo() included. Returns false when the port is taken. bool bind(uint16_t port); // Non-blocking receive of one datagram: >0 = bytes copied into buf, -1 = @@ -825,7 +864,7 @@ class UdpSocket { // than maxLen is truncated. Pass `srcIp` to also get the sender's IPv4 // octets (ArtNet discovery replies go back to the poller's address). int recvFrom(uint8_t* buf, size_t maxLen, uint8_t srcIp[4] = nullptr); - // One-shot send to an explicit address — for replying on a bound, + // One-shot send to an explicit address: for replying on a bound, // unconnected receive socket (e.g. ArtPollReply to the poller). connect()ed // send sockets keep using sendTo(). bool sendToAddr(const uint8_t ip[4], uint16_t port, const uint8_t* data, size_t len); @@ -852,7 +891,7 @@ class TcpConnection { // Non-blocking outbound connect to host:port, for a client that must NOT stall the render loop // (MQTT runs on tick1s inside Scheduler::tick). `connectStart` resolves `host` (a hostname via - // getaddrinfo — one bounded DNS lookup — or a dotted-quad IP) and kicks off a non-blocking + // getaddrinfo: one bounded DNS lookup: or a dotted-quad IP) and kicks off a non-blocking // connect, returning immediately; `connectPoll` checks the in-flight connect WITHOUT blocking and // returns Pending / Connected / Failed. The caller polls across ticks and enforces its own overall // timeout, then reads/writes via the non-blocking read()/writeSome(). Caller gates on @@ -863,11 +902,11 @@ 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 — sends all bytes (HTTP responses must complete) + 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 - // and re-calls — used by the preview drain to stream a frame across ticks without ever + // and re-calls: used by the preview drain to stream a frame across ticks without ever // blocking the render task. Never spins, never yields. (int mirrors read()'s contract.) int writeSome(const uint8_t* data, size_t len); @@ -916,18 +955,18 @@ struct RmtWs2812Handle { void* impl = nullptr; }; bool rmtWs2812Init(RmtWs2812Handle& h, uint8_t gpio, uint32_t resolutionHz, bool invert); // The tick resolution the platform actually granted (may differ from requested). -// The driver converts its ns timings to ticks with this. 0 if not initialised. +// The driver converts its ns timings to ticks with this. 0 if not initialized. uint32_t rmtWs2812Resolution(const RmtWs2812Handle& h) MM_NONBLOCKING; // Start transmitting `symbolCount` pre-encoded WS2812 RMT symbols and return -// immediately — channels started back-to-back clock out concurrently. Pair with +// immediately: channels started back-to-back clock out concurrently. Pair with // rmtWs2812Wait; the caller owns the inter-frame latch (delayUs) after the last // wait. The symbol buffer must stay valid until the wait returns. Returns false -// when the channel isn't initialised (and on targets without RMT). +// when the channel isn't initialized (and on targets without RMT). bool rmtWs2812Transmit(RmtWs2812Handle& h, const uint32_t* symbols, size_t symbolCount); // Block until the channel's in-flight transmission finishes, bounded by -// `timeoutMs` so a wedged peripheral can't hang the render tick forever — a +// `timeoutMs` so a wedged peripheral can't hang the render tick forever: a // timed-out frame is simply dropped and re-encoded next tick (self-heals). With // N channels waited sequentially the worst case is N×timeoutMs; acceptable for // the same self-healing reason. @@ -957,12 +996,12 @@ struct RmtLoopbackResult { uint32_t firstBadBit = 0; // index of the first wrong bit, or bitsChecked when all pass // Capture diagnostics (frame mode). The verdict must say WHY it failed, not only that it did: // an empty capture (dead wiring, idle line, stalled transfer) is a different fault class from a - // full capture that decodes wrong (waveform/threshold) — without these numbers both collapse + // full capture that decodes wrong (waveform/threshold): without these numbers both collapse // into the same "bad bit 0/0" and the instrument can't isolate anything. uint32_t capturedSymbols = 0; // RMT RX symbols actually captured (target: >= bitsChecked) int8_t rxIdleLevel = -1; // RX GPIO level sampled after the capture window (-1 = unknown) uint32_t txWallUs = 0; // wall time of the first (timed) transmit - uint32_t txExpectUs = 0; // expected wire time (byte count / configured clock) — a wall time + uint32_t txExpectUs = 0; // expected wire time (byte count / configured clock): a wall time // far above this is a stalled/underrun transfer, measured directly }; RmtLoopbackResult rmtWs2812Loopback(uint8_t txGpio, uint8_t rxGpio); @@ -972,17 +1011,17 @@ RmtLoopbackResult rmtWs2812Loopback(uint8_t txGpio, uint8_t rxGpio); // repeated, `channels` per light) back to back like the render loop, capture // the WHOLE frame on rxGpio and bit-verify every WS2812 bit. This is what // catches frame-rate / sustained-transfer corruption and RF interference on -// the data line that a 24-bit burst can't — a single flipped bit anywhere in +// the data line that a 24-bit burst can't: a single flipped bit anywhere in // the frame fails the test and reports its position. No-op off ESP32. RmtLoopbackResult rmtWs2812LoopbackFrame(uint8_t txGpio, uint8_t rxGpio, uint16_t lights, uint8_t channels); // --------------------------------------------------------------------------- -// i80-bus parallel WS2812 output — the LCD_CAM peripheral on the ESP32-S3/P4, the +// i80-bus parallel WS2812 output: the LCD_CAM peripheral on the ESP32-S3/P4, the // I2S peripheral on the classic ESP32 (IDF's esp_lcd i80 API picks the backend per // chip). The driver (src/light/drivers/MultiPinLedDriver.h) pre-encodes the WHOLE frame into one // DMA buffer (3-slot encode in ParallelSlots.h, domain code); the platform owns -// only the i80 bus/peripheral AND the DMA buffer itself — the buffer must be +// only the i80 bus/peripheral AND the DMA buffer itself: the buffer must be // DMA-capable internal RAM (platform::alloc prefers PSRAM, which the // peripheral can't stream from at full rate), so the platform allocates it at // init and exposes the pointer for the driver's zero-copy encode. All inert @@ -999,15 +1038,15 @@ struct I80Ws2812Handle { void* impl = nullptr; }; // `bufferBytes`). When `wantSecondBuffer` is true (the async double-buffer is // on), it also TRIES a second identical buffer and, if it fits, arms // double-buffer mode; if it won't fit (memory-tight board), buffer 1 stays null -// and the driver runs single-buffer (allocate-and-degrade — the double-buffer +// and the driver runs single-buffer (allocate-and-degrade: the double-buffer // is never *required*). When `wantSecondBuffer` is false (default), NO second -// buffer is allocated at all — the off path costs exactly one buffer. Returns +// buffer is allocated at all: the off path costs exactly one buffer. Returns // false only when buffer 0 (or the bus) can't be created (bad pins, DMA pressure). // `clockMultiplier` (1 = direct, 8 = a 74HCT595 shift-register expander on every data pin) scales // the pixel clock: a '595 is serial-in, so each WS2812 slot is shifted out over that many bus // words, and the bus must clock proportionally faster to keep the slot's duration on the wire. // The backend picks the exact rate its clock tree can divide to EXACTLY (an inexact rate is not an -// error in esp_lcd — it silently rounds the prescale, which would emit a wrong waveform). A +// error in esp_lcd: it silently rounds the prescale, which would emit a wrong waveform). A // multiplier > 1 is rejected on a backend that cannot DMA the resulting frame from PSRAM (the // classic-ESP32 I2S i80 path), rather than driving a frame the hardware can't sustain. bool i80Ws2812Init(I80Ws2812Handle& h, const uint16_t* dataPins, uint8_t laneCount, @@ -1016,26 +1055,26 @@ bool i80Ws2812Init(I80Ws2812Handle& h, const uint16_t* dataPins, uint8_t laneCou // DMA frame buffer `buffer` (0 or 1) the driver encodes into (zero-copy). // Buffer 0 always exists once init succeeded; buffer 1 is null when the second -// allocation didn't fit — the driver reads that null as "run single-buffer". +// allocation didn't fit: the driver reads that null as "run single-buffer". // `i80Ws2812BufferCapacity` is the shared per-buffer capacity (both buffers are -// the same size) — the driver's grow-only check. nullptr / 0 when not initialised. +// the same size): the driver's grow-only check. nullptr / 0 when not initialized. uint8_t* i80Ws2812Buffer(const I80Ws2812Handle& h, uint8_t buffer); size_t i80Ws2812BufferCapacity(const I80Ws2812Handle& h); // Start the autonomous DMA transfer of buffer `buffer`'s first `bytes` and // return; pair with i80Ws2812Wait on the SAME buffer. Once started no CPU work -// remains — there is no refill deadline for WiFi to miss (the design difference +// remains: there is no refill deadline for WiFi to miss (the design difference // vs the ISR-refilled rings in the hpwit/FastLED lineage). The deferred-wait // tick encodes into the other buffer while this one clocks out. bool i80Ws2812Transmit(I80Ws2812Handle& h, uint8_t buffer, size_t bytes); // Block until buffer `buffer`'s in-flight transfer finishes, bounded by `timeoutMs`. -// Returns TRUE only when the transfer actually completed. FALSE on timeout — the DMA may still be +// Returns TRUE only when the transfer actually completed. FALSE on timeout: the DMA may still be // reading that buffer, so the caller must NOT re-encode into it (see ParallelLedDriver::busWaitIfBusy, // which keeps it marked in-flight and re-waits next tick rather than corrupting a live transfer). bool i80Ws2812Wait(I80Ws2812Handle& h, uint8_t buffer, uint32_t timeoutMs); -// Duration in microseconds of the most recent completed DMA transfer — measured start-of-transmit +// Duration in microseconds of the most recent completed DMA transfer: measured start-of-transmit // to done-callback, so it is the PURE wire/DMA time (independent of CPU / render load), i.e. the // hard WS2812 output floor (256 lights × 30 µs ≈ 7680 µs → the 130 fps ceiling). The driver surfaces // it as a read-only KPI so the actual output rate is visible as the pipeline improves (and if a @@ -1045,11 +1084,11 @@ uint32_t i80Ws2812LastTransmitUs(const I80Ws2812Handle& h); void i80Ws2812Deinit(I80Ws2812Handle& h); // LCD loopback self-test: build a private FULL-WIDTH bus on the driver's -// real pins (the i80 peripheral configures all 8 data lines — a partial bus +// real pins (the i80 peripheral configures all 8 data lines: a partial bus // is rejected by the hardware layer) and transmit the caller's REAL encoded // frame (`frame`/`frameBytes`, lane 0 = dataPins[0]) back to back, exactly // like the render loop, while an RMT RX channel captures the whole frame off -// `rxGpio` and verifies every bit (RMT receive is transmitter-agnostic — the +// `rxGpio` and verifies every bit (RMT receive is transmitter-agnostic: the // increment-1 rig reused). `dataBytes` is the slot-carrying prefix of the // frame (before the latch pad); `rowBits` the bits per light row, so the // expected pattern repeats per row. Testing the genuine frame matters: a @@ -1058,7 +1097,7 @@ void i80Ws2812Deinit(I80Ws2812Handle& h); // RMT test; got[] holds the first mismatching row. No-op off the S3. // `clockMultiplier` > 1 = a 74HCT595 expander is fitted: the private bus is built at the // shift-mode pclk, and the GPIO CONTINUITY pre-check is SKIPPED. That pre-check drives the TX pin -// and expects the RX pin to follow directly — true for a bare jumper, false through a shift +// and expects the RX pin to follow directly: true for a bare jumper, false through a shift // register (driving the serial input high does not raise an output; that takes 8 clocks + a latch), // so it would report "jumper not detected" on perfectly good wiring. The captured signal is the // real post-'595 WS2812 waveform, so the bit-verify itself is unchanged. @@ -1069,85 +1108,85 @@ RmtLoopbackResult i80Ws2812Loopback(const uint16_t* dataPins, uint8_t laneCount, uint8_t clockMultiplier = 1); // --------------------------------------------------------------------------- -// MoonI80 — the same i80 output, on OUR OWN DMA driver instead of IDF's esp_lcd. +// MoonI80: the same i80 output, on OUR OWN DMA driver instead of IDF's esp_lcd. // // **Why a second implementation exists.** esp_lcd re-arms the peripheral on every -// transaction — `lcd_start_transaction()` does `lcd_ll_reset()` + `lcd_ll_fifo_reset()` + +// transaction: `lcd_start_transaction()` does `lcd_ll_reset()` + `lcd_ll_fifo_reset()` + // a hard-coded 4 µs busy-wait before each one. An LCD panel does not care; WS2812 is one // unbroken self-clocked bit stream, so a mid-frame reset garbles everything after it. // That makes a frame split across several esp_lcd transactions impossible to send gaplessly, -// at any chunk size — which in turn forces the whole frame into ONE transaction, and THAT is +// at any chunk size: which in turn forces the whole frame into ONE transaction, and THAT is // what caps the driver: the DMA must stream the entire frame from one contiguous, DMA- // reachable block (hence ~96 lights/strand through the '595 expander on an S3, and no PSRAM // at all on the classic ESP32). // -// The hardware never demanded this. The LCD peripheral has no data-length register — +// The hardware never demanded this. The LCD peripheral has no data-length register - // `lcd_ll_set_phase_cycles()` only sets `lcd_dout` as a boolean enable, and IDF's own comment // reads "Number of data phase cycles are controlled by DMA buffer length". So the peripheral // clocks out exactly what the DMA feeds it and stops when the chain ends: ONE gdma_start() over // an arbitrarily long descriptor chain + ONE lcd_ll_start() is a single gapless stream across as // many buffers as we like. This backend takes that, built on IDF's HAL + GDMA link-list APIs -// (one level below esp_lcd — not raw registers; IDF's own drivers use the same APIs). +// (one level below esp_lcd: not raw registers; IDF's own drivers use the same APIs). // // **Both implementations ship.** The esp_lcd one above is the REFERENCE: correct, capped, and // what this is measured against. Selecting between them is a module swap in the UI (two // registered driver types), so the A/B needs no reflash. See docs/adr/0014. // -// Identical contract to the i80Ws2812* family above, function for function — the domain driver +// Identical contract to the i80Ws2812* family above, function for function: the domain driver // (src/light/drivers/MoonLedDriver.h) is the same CRTP sibling with its forwards re-pointed. // Inert on chips without LCD_CAM. // --------------------------------------------------------------------------- struct MoonI80Ws2812Handle { void* impl = nullptr; }; -// **The streaming ring — how MoonI80 drives a frame too big to hold.** +// **The streaming ring: how MoonI80 drives a frame too big to hold.** // // The whole-frame path above needs the entire encoded frame in one DMA-reachable block, and that is // what caps the 74HCT595 expander: the encoder emits ~1,152 bytes per light in shift mode, so 96 -// lights per strand is already 108 KB — the internal-DMA-RAM edge. Above that the frame can only live +// lights per strand is already 108 KB: the internal-DMA-RAM edge. Above that the frame can only live // in PSRAM, and the S3's GDMA cannot sustain a PSRAM read at the expander's 10× pixel clock (measured: // a PSRAM frame drives fine at 2.67 MHz and never completes at 26.67 MHz, at any size). Moving that -// read to the CPU does not help — same memory, same bus, and the CPU is not faster at bulk reads. +// read to the CPU does not help: same memory, same bus, and the CPU is not faster at bulk reads. // // So the frame is never materialised at all. The DMA loops a small ring of INTERNAL buffers, and as -// each one drains, the CPU encodes the next slice straight into it — reading the Layer buffer, which +// each one drains, the CPU encodes the next slice straight into it: reading the Layer buffer, which // is internal and ~24× smaller than the encoded output (3 bytes/light vs 1,152). PSRAM leaves the path // entirely. Espressif's RGB-LCD driver calls the same trick "bounce buffers"; hpwit's LED driver // arrived at it independently. // // The deadline is comfortable, and the expander is *why*: the DMA takes ~345 µs to drain one 16-row -// buffer while the CPU encodes those rows in ~96 µs (measured on an S3) — the 8× output inflation buys +// buffer while the CPU encodes those rows in ~96 µs (measured on an S3): the 8× output inflation buys // far more DMA time than it costs CPU, a ~3.6× margin. // -// **The refill runs INLINE IN THE GDMA EOF ISR** — IDF's own continuous-gapless pattern (the RGB-LCD +// **The refill runs INLINE IN THE GDMA EOF ISR**: IDF's own continuous-gapless pattern (the RGB-LCD // bounce buffers, esp_lcd_panel_rgb.c: the refill runs synchronously in the EOF handler). As each buffer // drains, the ISR encodes the next slice into it at interrupt priority, so the refill always finishes // before the DMA laps back into that buffer `kRingBufs` slices later. That is the reuse-race guarantee: a // lower-priority task (the original design) could lose the buffer-reuse race to task-wake latency at >8 // slices (≥192 lights/strand), stalling the frame; an ISR cannot. The ring channel sets -// `isr_cache_safe = true` and the whole encode chain is IRAM-resident (MM_RAMFUNC) — the shipped +// `isr_cache_safe = true` and the whole encode chain is IRAM-resident (MM_RAMFUNC): the shipped // hardening, because a flash-cache miss inside a wire-rate ISR would blow the refill deadline. Being // cache-safe, the ISR can fire while the flash cache is disabled (a SPI-flash write: OTA/NVS), so the -// refill DEFERS when `spi_flash_cache_enabled()` is false and the batch catches up afterward — never +// refill DEFERS when `spi_flash_cache_enabled()` is false and the batch catches up afterward: never // touching flash from the ISR. Prior art: esp_lcd_panel_rgb.c's ISR-refill under CONFIG_LCD_RGB_ISR_IRAM_SAFE. // // `MoonI80EncodeFn` is the seam: the platform owns the ring, the descriptors and the completion; the // domain owns the encode. The callback runs from the EOF ISR (and once from the priming call). // // `needsPrefill` is the platform's buffer-lifecycle fact the encode's biggest saving hangs on: a ring -// buffer's CONSTANT words (the shift waveform frame prefillShiftRows lays) survive recycling — a data-only -// refill of a recycled buffer is byte-identical to a full one — so the encoder may skip the prefill except +// buffer's CONSTANT words (the shift waveform frame prefillShiftRows lays) survive recycling: a data-only +// refill of a recycled buffer is byte-identical to a full one: so the encoder may skip the prefill except // when the platform says the buffer's constants are gone: its FIRST use since the pool was built, or after // any platform-side memset (the short-last-slice tail zero, the past-frame zero-fill). Only the platform // knows those events, so it computes the flag; the domain decides what "prefill" means (and may still -// prefill unconditionally when its lane masks vary per row — ragged strands). Measured: the per-refill +// prefill unconditionally when its lane masks vary per row: ragged strands). Measured: the per-refill // prefill was ~1/3 of the ISR encode cost. // The FRAME-CLOSE call: `rowCount == 0 && closeFrame` asks the domain to write ONLY its frame-closing -// word (the shift expander's latch-only word) at `dst` — the platform makes this call for the first +// word (the shift expander's latch-only word) at `dst`: the platform makes this call for the first // zero-lap slice past the frame, whose head then presents the register's final slot on the strand (a // '595 output only changes when LATCHED, so a frame that ends without one more latch pulse leaves the // strand frozen on its second-to-last slot through the reset). Encoders with no close word (direct -// mode) do nothing — the zeroed buffer is already a clean LOW. +// mode) do nothing: the zeroed buffer is already a clean LOW. using MoonI80EncodeFn = void (*)(void* user, uint8_t* dst, uint32_t firstRow, uint32_t rowCount, bool closeFrame, bool needsPrefill); @@ -1168,7 +1207,7 @@ constexpr uint8_t kRingRowsDefault = 7; // lights per DMA buffer constexpr uint8_t kRingBufsDefault = 16; // buffers the DMA circulates // Per-slice zero-pad ceiling, µs. A LOW gap under ~150 µs inside a WS2812 stream reads as a PAUSE, not a // latch (the strand latches at ~300 µs measured), so an inter-buffer pad up to this bound stretches the -// refill deadline without ending the frame — hpwit's _DMA_EXTENSTION mechanism, sized to stay well under +// refill deadline without ending the frame: hpwit's _DMA_EXTENSTION mechanism, sized to stay well under // the latch threshold. The pad's fps cost is linear (frame += nSlices × padUs), which is why the value is // a driver CONTROL bounded by this constant, not a platform constant applied unconditionally. constexpr uint8_t kRingPadMaxUs = 120; @@ -1176,16 +1215,16 @@ constexpr uint8_t kRingPadMaxUs = 120; // BOTH sides derive geometry from it: the platform clamps a ring buffer to one node, and the driver's // auto geometry computes the same rows-per-node ceiling to SHOW the user real values (see ringAuto). constexpr size_t kRingNodeMaxBytes = 4095; -// The ring pool's depth bounds — shared for the same reason as kRingNodeMaxBytes: the platform enforces +// The ring pool's depth bounds: shared for the same reason as kRingNodeMaxBytes: the platform enforces // them (array bound / bounce floor) and the driver's auto geometry + control range must agree or drift. -// 64 keeps the prime-only regime (ringBufs ≥ nSlices — every slice encoded before arm, no ISR deadline) +// 64 keeps the prime-only regime (ringBufs ≥ nSlices: every slice encoded before arm, no ISR deadline) // reachable at 48×256 (nSlices = 37 at 7 rows/slice); the pool arrays it bounds cost bytes, not KB. constexpr uint8_t kRingBufsMax = 64; constexpr uint8_t kRingBufsMin = 2; // `rowsPerBuf` (lights per DMA buffer) and `ringBufs` (pool depth) are the ring's GEOMETRY, and they are // the caller's choice because the optimum is a measurement, not a derivation. RAM is the only axis that -// wants a small rowsPerBuf — it alone stops scaling with strand length at 1 (the only way a 48x256 frame +// wants a small rowsPerBuf: it alone stops scaling with strand length at 1 (the only way a 48x256 frame // is reachable at all); per-call encode overhead, interrupt rate and lap-time runway all want it big. // `padUs` (0..kRingPadMaxUs) inserts a shared zero-pad node after every buffer, stretching the per-slice // refill deadline by that many µs at a linear frame-time cost; 0 = no pad nodes at all. @@ -1197,12 +1236,12 @@ bool moonI80Ws2812InitRing(MoonI80Ws2812Handle& h, const uint16_t* dataPins, uin uint8_t clockMultiplier, MoonI80EncodeFn encode, void* user); // Start one frame on the ring: prime the buffers, fire the DMA, and let the refill task (woken by the -// EOF ISR) refill behind it. Pair with moonI80Ws2812Wait(h, 0, …) — the ring reports completion on slot 0. +// EOF ISR) refill behind it. Pair with moonI80Ws2812Wait(h, 0, …): the ring reports completion on slot 0. bool moonI80Ws2812TransmitRing(MoonI80Ws2812Handle& h); // The dual-core split of TransmitRing: prime a SUB-RANGE of the pool's buffers (each independent, so two -// cores prime disjoint ranges concurrently), then arm once EVERYTHING is primed — the caller's join is +// cores prime disjoint ranges concurrently), then arm once EVERYTHING is primed: the caller's join is // the fence. TransmitRing remains the serial combo (prime all + arm) for the single-core path. -// Set the '595 shift-clock prescale off the 80 MHz bus resolution (4 = 20 MHz default — the reliability +// Set the '595 shift-clock prescale off the 80 MHz bus resolution (4 = 20 MHz default: the reliability // point; 3 = 26.67 MHz overclock; 5 = 16 MHz is past the WS2812 0-vs-1 threshold, all-white). A slower // clock gives the shift register more setup margin on marginal strand wiring, at a longer WS2812 T0H. // Takes effect on the next bus (re)build. See kShiftClockDivDefault in the i80 driver. @@ -1229,7 +1268,7 @@ uint32_t moonI80Ws2812LastTransmitUs(const MoonI80Ws2812Handle& h); // frame's slice count (light-count / rowsPerBuf); `eofTotal`/`doneGiven` are lifetime counts the EOF ISR // bumps; `lastDrain` is the drainCount the last EOF saw (should reach nSlices each frame); `numItems`/ // `consumedItems` are the descriptor pool capacity vs what the mount loop used (a mismatch is the ≥256 -// chain-sizing bug). Read-only, best-effort (volatile reads, no lock) — a diagnostic, not a contract. +// chain-sizing bug). Read-only, best-effort (volatile reads, no lock): a diagnostic, not a contract. struct MoonI80RingStats { bool isRing = false; uint32_t nSlices = 0; @@ -1241,38 +1280,38 @@ struct MoonI80RingStats { uint32_t consumedItems = 0; // items the mount loop actually used (== numItems when sized right) uint32_t descErr = 0; // GDMA descriptor-error count (>0 == the in-ISR encode corrupted the chain: B1) uint32_t maxEncodeUs = 0; // worst ISR refill-encode time (the producer's JITTER number) - uint32_t avgEncodeUs = 0; // average refill-encode time (the producer's PACE number — the one that + uint32_t avgEncodeUs = 0; // average refill-encode time (the producer's PACE number: the one that // decides whether the ring keeps up; the max only sizes the pool's margin) uint32_t maxIsrGapUs = 0; // worst gap between EOFs = DMA buffer-drain time (the deadline) - uint32_t late = 0; // slices refilled AFTER the clock oracle said their drain began — each + uint32_t late = 0; // slices refilled AFTER the clock oracle said their drain began: each // one was stale on the wire. The machine's scatter meter: a clean soak // is late == 0; any increment is a deadline miss the eye may not catch. - // Ring-diagnosis fields — the instruments that isolated the three prime-only bugs (mount re-link, + // Ring-diagnosis fields: the instruments that isolated the three prime-only bugs (mount re-link, // multi-node buffers, EOF coalescing); the lapping work reads them the same way. Their scope lives in // backlog-light § MoonI80 streaming ring. uint32_t itemsPerBuf = 0; // descriptor nodes per ring buffer (1 by construction since the clamp) int32_t termNodeDiag = -1; // the mount-time NULL terminator node (-1 = looping/lapping chain) uint32_t cacheOffDefers = 0; // lifetime EOF firings that refilled NOTHING because the flash cache was - // off (a flash/WiFi write). Expected background noise — the WiFi driver + // off (a flash/WiFi write). Expected background noise: the WiFi driver // toggles the cache ~10/s even at idle; benign now (see stallAbandons). uint32_t cacheOffMaxRun = 0; // worst run of CONSECUTIVE cache-off defers ≈ how many buffers the DMA // drained un-refilled during one write. When it exceeds the pool's lead the - // DMA reaches the frontier terminator and HALTS (no stale replay) — counted + // DMA reaches the frontier terminator and HALTS (no stale replay): counted // as a stallAbandon, not corruption. High here + low stallAbandons = the // pool absorbed every window; high both = the walls will show a held frame. uint32_t stallAbandons = 0; // lifetime frames finalized by the wait backstop because a cache-off window // outlasted the pool's lead and the DMA self-terminated at the frontier // (moonI80Ws2812Wait). Each = one partially-updated frame held for one - // frame period — the DESIGNED benign outcome (vs. the old stale-slice burst). + // frame period: the DESIGNED benign outcome (vs. the old stale-slice burst). }; MoonI80RingStats moonI80Ws2812RingStats(const MoonI80Ws2812Handle& h); void moonI80Ws2812Deinit(MoonI80Ws2812Handle& h); // `useRing` makes the self-test ride the ring exactly when the render path does, so it verifies the SAME -// transport the driver is tuned to (not "only when the frame won't fit internal") — the instrument the +// transport the driver is tuned to (not "only when the frame won't fit internal"): the instrument the // ring's margin bug needs. `ringRows`/`ringBufs` are that ring's geometry (0 → the platform default). The // bit-verify then measures the ACTUAL ring: a margin the eyes see scattered on the wall shows here as a -// bit fault at the same slice boundary — the machine reproduction of the wall (the margin rule, +// bit fault at the same slice boundary: the machine reproduction of the wall (the margin rule, // `ring-reuse-is-the-blocker`). `useRing=false` keeps the legacy auto-gate (ring iff the frame overflows // internal RAM), which is what direct-mode continuity callers want. RmtLoopbackResult moonI80Ws2812Loopback(const uint16_t* dataPins, uint8_t laneCount, @@ -1283,11 +1322,11 @@ RmtLoopbackResult moonI80Ws2812Loopback(const uint16_t* dataPins, uint8_t laneCo uint32_t ringRows = 0, uint32_t ringBufs = 0, bool useRing = false); -// INTRUSIVE loopback — DRIVER-AGNOSTIC, so it lives here (not per-family): bit-verify what the LIVE +// INTRUSIVE loopback: DRIVER-AGNOSTIC, so it lives here (not per-family): bit-verify what the LIVE // pipeline is ALREADY clocking on `rxGpio`, building no bus and leaving the running peripheral untouched -// (unlike the per-driver `*Loopback`, which tears the output down and rebuilds a private copy — a large +// (unlike the per-driver `*Loopback`, which tears the output down and rebuilds a private copy: a large // contiguous alloc that fragments the heap and tests a replica). Because it only arms the RMT-RX (the -// render loop is the transmitter), it needs nothing driver-specific — every driver family (i80, esp_lcd, +// render loop is the transmitter), it needs nothing driver-specific: every driver family (i80, esp_lcd, // Parlio, RMT) shares this one entry, the same way they share `detail::captureAndVerifyFrame`. The caller // pins a known per-light pattern (`sent`, `sentLen` channels) into the driver's source so the tapped // strand's expected wire is deterministic. `dataBytes` = the tapped strand's WS2812 byte count (lights × @@ -1297,12 +1336,12 @@ RmtLoopbackResult ws2812LoopbackRide(uint16_t rxGpio, const uint8_t* sent, uint8 size_t dataBytes, uint8_t rowBits, uint8_t clockMultiplier); // --------------------------------------------------------------------------- -// Parlio (Parallel IO) WS2812 output — the ESP32-P4's parallel LED path, a +// Parlio (Parallel IO) WS2812 output: the ESP32-P4's parallel LED path, a // sibling of the LCD_CAM i80 functions above. Same autonomous-whole-frame DMA // shape, but Parlio is simpler: it takes the data GPIOs directly (no -// sacrificial WR/DC lines — Parlio generates the pixel clock itself from +// sacrificial WR/DC lines: Parlio generates the pixel clock itself from // `pclkHz`) and allows ANY lane count (1..8 here), so there is no all-8-pins -// rule. The same encoder feeds it (ParallelSlots.h — one bus word per slot, bit L = +// rule. The same encoder feeds it (ParallelSlots.h: one bus word per slot, bit L = // data line L). All inert on targets without Parlio, guarded by // `if constexpr (platform::parlioLanes == 0)` in the driver. // --------------------------------------------------------------------------- @@ -1314,7 +1353,7 @@ struct ParlioWs2812Handle { void* impl = nullptr; }; // WS2812 slot rate), with a zeroed DMA-capable buffer 0 of `bufferBytes`. When // `wantSecondBuffer` is true, also TRY a second buffer for the async double-buffer // (same allocate-and-degrade contract as i80Ws2812Init); when false (default) no -// second buffer is allocated. No WR/DC pins — Parlio drives the clock internally. +// second buffer is allocated. No WR/DC pins: Parlio drives the clock internally. // Returns false when buffer 0 (or the unit) fails. bool parlioWs2812Init(ParlioWs2812Handle& h, const uint16_t* dataPins, uint8_t laneCount, uint32_t pclkHz, size_t bufferBytes, @@ -1325,11 +1364,11 @@ bool parlioWs2812Init(ParlioWs2812Handle& h, const uint16_t* dataPins, uint8_t* parlioWs2812Buffer(const ParlioWs2812Handle& h, uint8_t buffer); size_t parlioWs2812BufferCapacity(const ParlioWs2812Handle& h); -// The most bytes Parlio can send in ONE transfer — a HARDWARE ceiling, not a heap budget, so it +// The most bytes Parlio can send in ONE transfer: a HARDWARE ceiling, not a heap budget, so it // needs no handle and holds before anything is allocated. A caller sizes a frame against it to // refuse an impossible configuration up front instead of failing the bus init. // -// 0 means NO BOUND (the dmaBudgetBytes contract), not "zero bytes usable" — it is what a host +// 0 means NO BOUND (the dmaBudgetBytes contract), not "zero bytes usable": it is what a host // without Parlio returns, and what a caller reads as "nothing to check against". size_t parlioMaxTransferBytes(); @@ -1339,17 +1378,17 @@ size_t parlioMaxTransferBytes(); bool parlioWs2812Transmit(ParlioWs2812Handle& h, uint8_t buffer, size_t bytes); // Block until buffer `buffer`'s in-flight transfer finishes, bounded by `timeoutMs`. -// Returns TRUE only when the transfer actually completed; FALSE on timeout — see i80Ws2812Wait for +// Returns TRUE only when the transfer actually completed; FALSE on timeout: see i80Ws2812Wait for // why the caller must not reuse the buffer then. bool parlioWs2812Wait(ParlioWs2812Handle& h, uint8_t buffer, uint32_t timeoutMs); -// Duration in microseconds of the most recent completed DMA transfer — the pure wire/DMA output +// Duration in microseconds of the most recent completed DMA transfer: the pure wire/DMA output // time (the WS2812 floor / fps ceiling). See i80Ws2812LastTransmitUs. 0 until the first completes. uint32_t parlioWs2812LastTransmitUs(const ParlioWs2812Handle& h); void parlioWs2812Deinit(ParlioWs2812Handle& h); -// Parlio loopback self-test — same contract + result shape as the LCD/RMT +// Parlio loopback self-test: same contract + result shape as the LCD/RMT // loopbacks: a private Parlio TX unit transmits the caller's real frame back to // back while rmtWs2812RxCapture reads it off `rxGpio` (lane 0 carries the // pattern) and every bit is verified. `dataBytes`/`rowBits` as in i80Ws2812Loopback. @@ -1360,8 +1399,8 @@ RmtLoopbackResult parlioWs2812Loopback(const uint16_t* dataPins, uint8_t laneCou // --------------------------------------------------------------------------- // I2S audio input (digital MEMS microphone, e.g. INMP441). Two seams only: -// the I2S read (audioMic*) and the FFT kernel (audioFft). Everything else — -// DC strip, RMS, windowing, the magnitude->16-band log mapping, noise-floor/gain — +// the I2S read (audioMic*) and the FFT kernel (audioFft). Everything else - +// DC strip, RMS, windowing, the magnitude->16-band log mapping, noise-floor/gain - // is host-tested domain code (src/core/AudioLevel.h, AudioBands.h), so the level // and band math runs in CI without hardware. On desktop audioMicRead returns 0 // (no capture) but audioFft is a real (naive) DFT, so the whole @@ -1408,16 +1447,16 @@ size_t audioCaptureDevices(const char* const** optionsOut); bool audioCaptureInit(AudioMicHandle& h, uint8_t deviceIndex, uint32_t sampleRate); // Bring up an I2S RX channel reading the mic on the given pins at `sampleRate` -// (24-bit data in a 32-bit slot, mono). `mclkPin` drives the I2S master clock — +// (24-bit data in a 32-bit slot, mono). `mclkPin` drives the I2S master clock - // −1 for a self-clocked direct MEMS mic (INMP441), or the codec's MCLK pin when a // codec needs the clock to run (the ES8311 won't even answer I2C without it, so // AudioService starts I2S *before* audioCodecInit on a codec board). Returns false -// on failure (bad pins, no I2S, out of memory) — the module idles with a status error. +// on failure (bad pins, no I2S, out of memory): the module idles with a status error. bool audioMicInit(AudioMicHandle& h, uint16_t wsPin, uint16_t sdPin, uint16_t sckPin, int16_t mclkPin, uint32_t sampleRate); // Read up to `maxSamples` 32-bit samples into `out`; returns the count read -// (0 if none ready / not initialised). Non-blocking enough for the render tick. +// (0 if none ready / not initialized). Non-blocking enough for the render tick. size_t audioMicRead(AudioMicHandle& h, int32_t* out, size_t maxSamples); void audioMicDeinit(AudioMicHandle& h); @@ -1425,23 +1464,23 @@ void audioMicDeinit(AudioMicHandle& h); // Real-input FFT kernel: `windowed` holds `n` (a power of two) windowed samples; // fills `outMag` with the n/2 magnitude bins. esp-dsp's float `dsps_fft2r_fc32` // on ESP32 (the FPU makes float faster than fixed-point); a naive O(n^2) DFT on -// desktop — correct, only fast enough for the host tests' small n. +// desktop: correct, only fast enough for the host tests' small n. void audioFft(const float* windowed, size_t n, float* outMag); // --------------------------------------------------------------------------- -// I2C bus diagnostics — domain-neutral, not audio-specific. Probes a bus and +// I2C bus diagnostics: domain-neutral, not audio-specific. Probes a bus and // reports which 7-bit addresses ACK, the standard `i2cdetect` operation. Used // by the I2cScanModule diagnostic (src/core/I2cScanModule.h) to help bring up -// any I2C peripheral (a codec, a sensor, an expander) — confirm wiring and read +// any I2C peripheral (a codec, a sensor, an expander): confirm wiring and read // off a device's address. Self-contained: opens a temporary master bus on the // given pins, scans, tears it down. The bus is transient, so it only conflicts // with a driver that *currently* holds the port (e.g. the ES8311 codec keeps -// I2C_NUM_0 open while AudioService is active) — that case is reported as +// I2C_NUM_0 open while AudioService is active): that case is reported as // kI2cBusUnavailable, not silently as "0 devices". Internal pull-ups enabled. // --------------------------------------------------------------------------- // Sentinel: the bus couldn't be opened (already held by another driver, or no -// I2C on this target) — distinct from a successful scan that found 0 devices. +// I2C on this target): distinct from a successful scan that found 0 devices. inline constexpr size_t kI2cBusUnavailable = static_cast(-1); // Scan the I2C bus on (sda, scl); write the 7-bit addresses that ACK into @@ -1451,7 +1490,7 @@ 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 — +// (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. IrService is the sole caller. ESP32 decodes NEC over RMT; desktop has no IR // hardware and always returns false. @@ -1463,12 +1502,12 @@ bool irRead(uint16_t pin, uint32_t& codeOut); // on desktop (no IR hardware). void irStop(); -// Open (or confirm) the IR RX channel on `pin` and report whether it's live — the difference between +// Open (or confirm) the IR RX channel on `pin` and report whether it's live: the difference between // "a pin is configured" (which irRead can't distinguish from "no code this tick") and "the RMT-RX // channel actually bound and is armed". IrService calls this to give a truthful status: a busy pin or // a bad GPIO fails to open, and the user must see that, not a stale "ready". Idempotent for an // unchanged pin (reuses the open channel). Returns true on ESP32 when the channel is live; desktop -// has no IR hardware, so it returns true (no channel to fail — the desktop status stays "ready"). +// has no IR hardware, so it returns true (no channel to fail: the desktop status stays "ready"). bool irChannelReady(uint16_t pin); } // namespace mm::platform diff --git a/src/ui/app.js b/src/ui/app.js index 013dd0ee..18236c32 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -1,8 +1,8 @@ -// projectMM Web UI — all logic in one hand-maintained file per CLAUDE.md. +// projectMM Web UI: all logic in one hand-maintained file per CLAUDE.md. // Loaded as