diff --git a/CLAUDE.md b/CLAUDE.md
index 94407d9a..d5b0a7ee 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -210,7 +210,7 @@ Each name is a script under `moondeck/`, run through `uv run`; the command and w
**`check_docgen` is a ratchet.** Errors are resolved before a commit, and warnings may only fall: the committed `docs/reference/metrics/docgen.md` is the number to beat, on the total and on every rule. It fails a run that raises either. Per rule as well as per total, because a total hides one rule paying for another, and because the cheapest way to satisfy a width rule is to split a line, which raises the block count and fixes nothing. A rule whose own limit changed is the one case to say so in the commit.
-**A file with warnings is left better than it was found.** Holding the line is the floor, not the goal: the report is meant to shrink, and it only does so if each change spends a little effort on the warnings in the files it already touches. Reasonable effort, in the spirit of principle 5: the ones a reader would agree with, not a rewrite of every comment in the file. What resists is left with its count unchanged rather than forced, since a comment split to satisfy a width rule is the move the per-rule ratchet exists to refuse. Files the change never opened are a sweep of their own.
+**A file with warnings is left better than it was found, and the aim is zero.** Errors stay at 0, always. Holding the warning line is the floor, not the goal. The report shrinks only if each change spends effort on the warnings in the files it already touches. **A touched file that still has warnings gets all of them resolved, so the file leaves the list entirely.** Clearing a file beats shaving one finding off each of five: a file at zero stays there, where a file at four drifts back. Reasonable effort, in the spirit of principle 5: the ones a reader would agree with, not a rewrite of every comment in the file. What resists is left with its count unchanged rather than forced, since a comment split to satisfy a width rule is the move the per-rule ratchet exists to refuse. A file that cannot reach zero says in one line what remains and why. The overflow moves rather than shrinks: module behavior into the header's `///`, cross-module rationale into the file's `@moreinfo` appendix, reached by `@xref`. Files the change never opened are a sweep of their own.
Three checks earn their place for a reason worth knowing. **Repo health** is the only place the creeping numbers are visible: flash and DRAM per target, binary size, the tick matrix, line counts, complexity warnings. Its diff belongs in the commit and its deltas in the commit message. It runs when the code changes rather than on every commit, because its timings drift with the host: on a docs-only diff it records a regression that nothing in the diff caused. **The no-backend build** catches a helper left unused outside its guard, fatal under GCC while clang stays silent. **ESP32 firmware fresh** compares the binary against every source in a tenth of a second and catches the edit that was never compiled; compile for real after an sdkconfig or toolchain change. The [provisioning path](moondeck/MoonDeck.md#improv_smoke_test) is the five files MoonDeck names.
diff --git a/docs/assets/light/drivers/RtspDriver.png b/docs/assets/light/drivers/RtspDriver.png
new file mode 100644
index 00000000..efc482cf
Binary files /dev/null and b/docs/assets/light/drivers/RtspDriver.png differ
diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md
index 40a95d22..acb33cc0 100644
--- a/docs/moonmodules/light/drivers.md
+++ b/docs/moonmodules/light/drivers.md
@@ -201,6 +201,22 @@ The grid becomes the frame, output correction applied. Latency is HLS's own, **2
Detail: [technical](moxygen/HlsDriver.md) · [the transport-stream muxer](moxygen/MpegTs.md)
+
+
+### RTSP 🖥️ · video out
+
+
+
+Streams the layer as **H.264 over RTSP**, which a player pulls rather than fetching segments. Point VLC or `ffplay` at the `url` the card shows. It reaches a viewer **much sooner than HLS**, which buffers whole segments before it plays one, so this is the remote view to reach for. [Preview](#preview) stays the one that keeps pace with the lights, sending raw pixels and no codec at all.
+
+Runs on the **ESP32-P4** and on the desktop. One encoder, so **RTSP and [HLS](#hls) run one at a time**. See [the details below](#rtsp-details).
+
+- `targetFps`: encode-rate ceiling (default 30, 1–60), which is also the keyframe interval.
+- `scale`: video pixels per light (0 = auto). Each light is a solid block, never a blur.
+- read-only: `url` to play. The card's status line names the connected viewer.
+
+Detail: [technical](moxygen/RtspDriver.md) · [the RTP packetiser](moxygen/RtpH264.md) · [the session](moxygen/RtspSession.md)
+
## Shared, details
@@ -303,6 +319,36 @@ An encoder your ffmpeg lacks starts and exits immediately; the status then reads
**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.
+
+
+## RTSP, details
+
+**Playing the stream.** Any RTSP player opens the `url` the card shows. These two are what the driver is tested against:
+
+```sh
+ffplay -fflags nobuffer -flags low_delay rtsp://:554/
+vlc rtsp://:554/
+```
+
+`-fflags nobuffer -flags low_delay` is what makes ffplay show the stream as it arrives rather than filling a buffer first, which is the whole point of reaching for RTSP. VLC buffers about a second by default, so `--network-caching=100` brings it closer.
+
+**Watching LEDs on a monitor.** A grid is small in pixels and large in meaning, so scale it up with nearest-neighbor and each light stays a crisp square instead of a blurred blob:
+
+```sh
+ffplay -fflags nobuffer -flags low_delay -probesize 32 -analyzeduration 0 \
+ -vf "scale=768:768:flags=neighbor,format=yuv420p" rtsp://:554/
+```
+
+`-probesize 32 -analyzeduration 0` skips the startup probing, so the picture appears at once. `format=yuv420p` silences ffplay's `No accelerated colorspace conversion` notice, which reports a missing SIMD path in the player's own window conversion and says nothing about the stream.
+
+**The newest viewer is the viewer.** One session plays at a time, since each viewer costs another send on a device that is also driving lights. A new connection takes the session over rather than being refused. The reason is that a player which vanishes without `TEARDOWN` leaves a socket open and silent, and TCP reports a peer's absence only to a write it stops acknowledging, so waiting on that would strand the stream for minutes. The displaced viewer sees its connection close, which every player reports.
+
+**UDP carries the video.** The control conversation runs over TCP on port 554, and the frames go to the UDP port the viewer names in `SETUP`. A network that blocks that port pair leaves the stream silent while the session looks connected. A player asking for interleaved TCP instead is told so by code, since this server speaks UDP.
+
+**The rate is what the device renders.** `targetFps` is a ceiling rather than a promise: a heavy effect that ticks at 8 fps is streamed at 8 fps, since a frame that was never rendered cannot be sent. A stream slower than expected is therefore a question about the render loop rather than the transport. Each module's own tick time in the UI says which effect is spending the time.
+
+**The delay that remains is the codec's.** H.264 emits a frame once it has the whole frame, and a decoder holds one more, so tens of milliseconds stay whatever the transport does. What RTSP removes is HLS's segment buffering, which is the seconds.
+
## Preview, details
diff --git a/docs/reference/MIGRATING.md b/docs/reference/MIGRATING.md
index c29b5c45..06b217ec 100644
--- a/docs/reference/MIGRATING.md
+++ b/docs/reference/MIGRATING.md
@@ -24,6 +24,8 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul
## Unreleased (`next-iteration`)
+
+
### System: `expertMode` became `mode`, with three levels
**Action: re-set a control, and only if you had expert mode on.**
diff --git a/docs/reference/hardware/control-surfaces.md b/docs/reference/hardware/control-surfaces.md
index 01a49525..0fc577f2 100644
--- a/docs/reference/hardware/control-surfaces.md
+++ b/docs/reference/hardware/control-surfaces.md
@@ -4,7 +4,7 @@ What projectMM needs to know about the physical desks on the bench, so a control
**The headline, because it contradicts the obvious assumption:** neither desk speaks OSC. Both are
**Mackie Control** surfaces. OSC is the right protocol for the wider ecosystem (Resolume,
-TouchDesigner, TouchOSC, DIY Arduino rigs) and is planned on that basis, but it does not reach these two. See [the OSC plan](../../work/present/Plan-20260829%20-%20OSC%20control%20ingest.md).
+TouchDesigner, TouchOSC, DIY Arduino rigs) and is planned on that basis, but it does not reach these two. See [the OSC plan](../../work/past/plans/Plan-20260829%20-%20OSC%20control%20ingest%20(shipped).md).
## Behringer X-Touch (Universal)
diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md
index a576694a..bd897e1f 100644
--- a/docs/reference/metrics/docgen.md
+++ b/docs/reference/metrics/docgen.md
@@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do
Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks.
-**0 error(s)** and **3131 warning(s)** across 217 page(s).
+**0 error(s)** and **3090 warning(s)** across 215 page(s).
An error is in a file that generates a documentation page, a header or a catalog page, so the finding is a defect in what gets published and it fails the gate. A warning is in an implementation file, which publishes nothing: its comments are a note to the next reader, worth fixing without being worth stopping a commit for. Both are counted here, because a warning nobody sees is a warning nobody fixes.
@@ -14,8 +14,8 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of
| Rule | Errors | Warnings |
|---|---:|---:|
-| over-wide comment lines | 0 | 2225 |
-| multi-line comment blocks | 0 | 604 |
+| over-wide comment lines | 0 | 2192 |
+| multi-line comment blocks | 0 | 596 |
| over-long sentences | 0 | 108 |
| over-wide doc lines | 0 | 102 |
| member deep dives | 0 | 92 |
@@ -27,7 +27,7 @@ The unit a sweep runs in: one summary page and the headers it owns, as the [hier
| Summary page | Errors | Warnings |
|---|---:|---:|
| `(tests, no card)` | 0 | 2203 |
-| `platform/index.md` | 0 | 518 |
+| `platform/index.md` | 0 | 477 |
| `core/system.md` | 0 | 410 |
## Where the work is
@@ -56,7 +56,7 @@ By rule, warnings: 1668 over-wide comment lines, 239 multi-line comment blocks,
### platform/index.md
-**0 error(s)** and **518 warning(s)** across 25 file(s).
+**0 error(s)** and **477 warning(s)** across 23 file(s).
| Findings | File | |
|---:|---|---|
@@ -67,12 +67,12 @@ By rule, warnings: 1668 over-wide comment lines, 239 multi-line comment blocks,
| 34 | `src/platform/esp32/moonlive_asm_xtensa.cpp` | warning |
| 30 | `src/platform/desktop/moonlive_asm_x86_64.cpp` | warning |
| 30 | `src/platform/esp32/platform_esp32_rmt.cpp` | warning |
-| 24 | `src/platform/esp32/platform_esp32_ota.cpp` | warning |
| 23 | `src/platform/esp32/moonlive_asm_riscv.cpp` | warning |
| 21 | `src/platform/desktop/moonlive_asm_arm64.cpp` | warning |
-| 1-19 each | *15 more warning files, 100 findings* | |
+| 19 | `src/platform/esp32/platform_esp32_parlio.cpp` | warning |
+| 1-10 each | *13 more warning files, 64 findings* | |
-By rule, warnings: 363 over-wide comment lines, 155 multi-line comment blocks.
+By rule, warnings: 330 over-wide comment lines, 147 multi-line comment blocks.
### core/system.md
diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json
index bbd75663..e53c733e 100644
--- a/docs/reference/metrics/repo-health.json
+++ b/docs/reference/metrics/repo-health.json
@@ -1,11 +1,11 @@
{
- "commit": "eaa904dc",
+ "commit": "09e28ac6",
"flash": {
"esp32s3-n16r8": 2134032,
- "desktop": 1970712,
+ "desktop": 1991400,
"esp32": 2085360,
- "esp32p4rev1-eth": 2035152,
- "esp32p4rev1-eth-wifi": 2284640,
+ "esp32p4rev1-eth": 2045840,
+ "esp32p4rev1-eth-wifi": 2331904,
"esp32s3-n8r8": 2087168,
"esp32s31": 2428080,
"esp32-16mb": 2060368,
@@ -17,28 +17,28 @@
"esp32-pico": 2107168
},
"measured": {
- "esp32p4rev1-eth": "2026-09-21",
- "esp32s31": "2026-09-21",
- "esp32": "2026-09-21",
+ "esp32p4rev1-eth": "2026-09-22",
+ "esp32s31": "2026-09-22",
+ "esp32": "2026-09-22",
"esp32-pico": "2026-09-09",
"esp32s3-n16r8": "2026-09-21",
- "desktop": "2026-09-21",
+ "desktop": "2026-09-22",
"esp32s3-n8r8": "2026-09-08",
"esp32s3-zero": "2026-09-08",
"esp32-16mb": "2026-09-09",
- "esp32p4rev1-eth-wifi": "2026-09-08",
+ "esp32p4rev1-eth-wifi": "2026-09-22",
"esp32-eth": "2026-09-11"
},
"perf": {
"desktop": {
- "tick_us": 125,
- "fps": 8000,
+ "tick_us": 180,
+ "fps": 5555,
"scenario_p50": {
"Layer_base_pipeline": {
- "p50": 70,
+ "p50": 69,
"p95": 74,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"Layer_memory_1to1": {
"p50": 5,
@@ -172,9 +172,9 @@
"Audio_mutation": {
"desktop-macos": {
"p50": 22,
- "p95": 38,
+ "p95": 41,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"desktop-windows": {
"p50": 40,
@@ -206,9 +206,9 @@
"Driver_mutation": {
"desktop-macos": {
"p50": 20,
- "p95": 30,
+ "p95": 38,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"desktop-windows": {
"p50": 42,
@@ -231,10 +231,10 @@
},
"Effects_composition": {
"desktop-macos": {
- "p50": 144,
- "p95": 160,
+ "p50": 145,
+ "p95": 184,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"desktop-windows": {
"p50": 549,
@@ -331,10 +331,10 @@
},
"Layer_base_pipeline": {
"desktop-macos": {
- "p50": 70,
+ "p50": 69,
"p95": 74,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"desktop-windows": {
"p50": 118,
@@ -360,9 +360,9 @@
"Layouts_mutation": {
"desktop-macos": {
"p50": 93,
- "p95": 100,
+ "p95": 104,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"desktop-windows": {
"p50": 111,
@@ -414,7 +414,7 @@
"p50": 5,
"p95": 6,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"esp32s3-n16r8": {
"p50": 8255,
@@ -512,9 +512,9 @@
"modifier_chain": {
"desktop-macos": {
"p50": 43,
- "p95": 47,
+ "p95": 46,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"desktop-windows": {
"p50": 69,
@@ -534,7 +534,7 @@
"p50": 23,
"p95": 25,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"esp32-eth": {
"p50": 1010,
@@ -569,10 +569,10 @@
},
"perf_full": {
"desktop-macos": {
- "p50": 251,
+ "p50": 252,
"p95": 295,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"esp32s3-n16r8": {
"p50": 16915,
@@ -601,10 +601,10 @@
},
"perf_light": {
"desktop-macos": {
- "p50": 15,
+ "p50": 16,
"p95": 21,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"esp32s3-n16r8": {
"p50": 2485,
@@ -648,7 +648,7 @@
"p50": 254,
"p95": 324,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"desktop-windows": {
"p50": 649,
@@ -674,7 +674,7 @@
"p50": 4,
"p95": 4,
"n": 32,
- "last": "2026-09-21"
+ "last": "2026-09-22"
},
"esp32p4rev1-eth": {
"p50": 217,
@@ -698,54 +698,54 @@
}
},
"loc": {
- "core": 22010,
- "light": 29965,
- "platform": 16346,
- "ui": 11324,
- "test": 56401,
- "moondeck": 27241
+ "core": 22120,
+ "light": 30733,
+ "platform": 16771,
+ "ui": 11329,
+ "test": 56882,
+ "moondeck": 27247
},
"comments": {
"core": {
- "lines": 5579,
+ "lines": 5611,
"ratio": 0.28
},
"light": {
- "lines": 7272,
- "ratio": 0.272
+ "lines": 7392,
+ "ratio": 0.27
},
"platform": {
- "lines": 3481,
- "ratio": 0.238
+ "lines": 3605,
+ "ratio": 0.24
},
"ui": {
- "lines": 3381,
+ "lines": 3384,
"ratio": 0.315
},
"test": {
- "lines": 6362,
+ "lines": 6408,
"ratio": 0.131
},
"moondeck": {
- "lines": 4575,
+ "lines": 4578,
"ratio": 0.191
}
},
"tests": {
- "cases": 2071,
+ "cases": 2099,
"scenarios": 27
},
"docs": {
- "md_files": 138,
- "md_lines": 28290,
- "plans_files": 31,
- "backlog_lines": 3034,
+ "md_files": 140,
+ "md_lines": 28611,
+ "plans_files": 37,
+ "backlog_lines": 3110,
"lessons_lines": 526,
"claude_md_lines": 281
},
"complexity": {
- "functions": 3631,
- "over_threshold": 267,
+ "functions": 3688,
+ "over_threshold": 276,
"worst_ccn": 128
}
}
diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md
index 6b6219e2..39df888f 100644
--- a/docs/reference/metrics/repo-health.md
+++ b/docs/reference/metrics/repo-health.md
@@ -1,6 +1,6 @@
# Repo health
-Measured at `eaa904dc`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
+Measured at `09e28ac6`. 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/reference/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human.
@@ -8,19 +8,19 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Target | Flash | Capacity | Used | Built |
|---|---:|---:|---:|:--:|
-| desktop | 1,925 KB | - | - | yes |
-| esp32 | 2,036 KB (+448 B) ⚠ | 2,496 KB | 82% | yes |
-| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 12d** |
-| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 10d** |
-| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 12d** |
+| desktop | 1,945 KB (+560 B) ⚠ | - | - | yes |
+| esp32 | 2,036 KB | 2,496 KB | 82% | carried 0d |
+| esp32-16mb | 2,012 KB | 4,096 KB | 49% | **STALE 13d** |
+| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 11d** |
+| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 13d** |
| esp32-wrover | 1,801 KB | - | - | carried (age?) |
-| esp32p4rev1-eth | 1,987 KB (+560 B) ⚠ | 4,096 KB | 49% | yes |
-| esp32p4rev1-eth-wifi | 2,231 KB | 4,096 KB | 54% | **STALE 13d** |
+| esp32p4rev1-eth | 1,998 KB (+2 KB) ⚠ | 4,096 KB | 49% | yes |
+| esp32p4rev1-eth-wifi | 2,277 KB | 4,096 KB | 56% | yes |
| esp32p4rev3-eth | 1,605 KB | - | - | carried (age?) |
-| esp32s3-n16r8 | 2,084 KB | 4,096 KB | 51% | carried 0d |
-| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | **STALE 13d** |
-| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | **STALE 13d** |
-| esp32s31 | 2,371 KB (+448 B) ⚠ | 4,096 KB | 58% | yes |
+| esp32s3-n16r8 | 2,084 KB | 4,096 KB | 51% | carried 1d |
+| esp32s3-n8r8 | 2,038 KB | 3,072 KB | 66% | **STALE 14d** |
+| esp32s3-zero | 1,977 KB | 2,496 KB | 79% | **STALE 14d** |
+| esp32s31 | 2,371 KB | 4,096 KB | 58% | carried 0d |
| qemu | 1,351 KB | - | - | carried (age?) |
`Built: yes` was measured this run. `carried (age?)` was not rebuilt either and predates this record, so its age is unknown: it dates itself on the next build. `carried Nd` was NOT rebuilt and its number is N days old, so an absent delta says nothing about the change. **STALE** marks a carry older than 7 days: the number has gone unchecked long enough that growth will surface later as one jump, blamed on whichever commit happens to rebuild that target. `Used` is against the app slot in the firmware's own partition table.
@@ -29,7 +29,7 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Target | Tick | FPS |
|---|---:|---:|
-| desktop | 125 µs (+4 µs) ⚠ | 8,000 (−264) ⚠ |
+| desktop | 180 µs (+28 µs) ⚠ | 5,555 (−1,023) ⚠ |
| esp32 | 8,354 µs | 119 |
### Scenario tick by target (p50 of each sample window)
@@ -37,14 +37,14 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | unknown |
|---|---|---|---|---|---|---|---|---|---|
| Audio_mutation | 22 | 40 ? | 13,152 | 47 ? | - | - | - | - | - |
-| Aurora_fps | 1,522 (+1) ⚠ | - | - | - | - | - | - | - | - |
+| Aurora_fps | 1,522 | - | - | - | - | - | - | - | - |
| Driver_mutation | 20 | 42 ? | 12,812 | 39 ? | - | - | - | - | - |
-| Effects_composition | 144 | 549 ? | - | - | - | - | - | - | - |
+| Effects_composition | 145 (+1) ⚠ | 549 ? | - | - | - | - | - | - | - |
| Fields_polar_lut | 1,263 | - | - | - | - | - | - | - | - |
| Fluid_solver | 217 | - | - | - | - | - | - | - | - |
| GridBlacks_blackpixel | 2 | 8 ? | 269 ? | 267 ? | - | - | - | - | - |
| GridLayout_resize | 120 | 219 ? | 1,352 ? | 1,011 ? | 1,143 ? | - | 95,771 ? | 82,231 ? | - |
-| Layer_base_pipeline | 70 | 118 ? | - | - | - | - | - | - | - |
+| Layer_base_pipeline | 69 (−1) ✓ | 118 ? | - | - | - | - | - | - | - |
| Layer_memory_1to1 | 5 | 1 ? | - | - | - | - | - | - | - |
| Layouts_mutation | 93 | 111 ? | 13,692 | 45 ? | - | - | 27 ? | - | - |
| MoonLiveEffect_controls | 11 ? | - | 12,901 | 4,624 ? | - | - | - | - | - |
@@ -59,8 +59,8 @@ Current state only; the trend is this file's git history (`git log -p docs/refer
| Trails_ladder | 358 | - | - | - | - | - | - | - | - |
| modifier_chain | 43 | 69 ? | 13,337 | - | - | - | - | - | - |
| modifier_swap | 23 | 41 ? | 12,250 | 354 ? | 362 ? | - | 1,010 ? | - | - |
-| perf_full | 251 | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - |
-| perf_light | 15 | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - |
+| perf_full | 252 (+1) ⚠ | 592 ? | 10,392 | 16,915 ? | 17,433 ? | - | - | - | - |
+| perf_light | 16 | 35 ? | 2,183 | 2,485 ? | 2,038 ? | - | - | - | - |
| peripheral_grid_sweep | 254 | 649 ? | 6,991 ? | - | 11,495 ? | 12,273 ? | - | - | - |
| peripheral_switch | 4 | 9 ? | 437 | 46 ? | 217 ? | - | - | - | - |
@@ -72,7 +72,7 @@ Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first
| Scenario | p50 | p95 | n |
|---|---:|---:|---:|
-| Layer_base_pipeline | 70 µs | 74 µs | 32 |
+| Layer_base_pipeline | 69 µs (−1 µs) ✓ | 74 µs | 32 |
| Layer_memory_1to1 | 5 µs | 24 µs | 32 |
These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers.
@@ -81,36 +81,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan
| Area | Lines | Comments | Comment share |
|---|---:|---:|---:|
-| core | 22,010 | 5,579 | 28.0 % |
-| light | 29,965 (+4) ⚠ | 7,272 | 27.2 % |
-| platform | 16,346 | 3,481 | 23.8 % |
-| ui | 11,324 (+6) ⚠ | 3,381 | 31.5 % |
-| test | 56,401 | 6,362 | 13.1 % |
-| moondeck | 27,241 (+4) ⚠ | 4,575 | 19.1 % |
+| core | 22,120 (+30) ⚠ | 5,611 | 28.0 % (+0.1 %) ⚠ |
+| light | 30,733 (+102) ⚠ | 7,392 | 27.0 % |
+| platform | 16,771 (+148) ⚠ | 3,605 | 24.0 % (+0.3 %) ⚠ |
+| ui | 11,329 | 3,384 | 31.5 % |
+| test | 56,882 (+66) ⚠ | 6,408 | 13.1 % |
+| moondeck | 27,247 | 4,578 | 19.1 % |
## Tests
| Kind | Count |
|---|---:|
-| unit cases | 2,071 |
+| unit cases | 2,099 (+3) ✓ |
| scenarios | 27 |
## Complexity
| Metric | Value |
|---|---:|
-| functions | 3,631 |
-| over threshold | 267 |
+| functions | 3,688 (+9) ✓ |
+| over threshold | 276 (+3) ⚠ |
| worst CCN | 128 |
## Documentation
| Metric | Value |
|---|---:|
-| markdown files | 138 |
-| markdown lines | 28,290 (+8) ⚠ |
-| plan files | 31 |
-| backlog lines | 3,034 |
+| markdown files | 140 (+1) ⚠ |
+| markdown lines | 28,611 (+203) ⚠ |
+| plan files | 37 (+5) ⚠ |
+| backlog lines | 3,110 (+74) ⚠ |
| lessons lines | 526 |
| CLAUDE.md lines | 281 |
diff --git a/docs/work/future/backlog-core.md b/docs/work/future/backlog-core.md
index fe7386a0..975b0d94 100644
--- a/docs/work/future/backlog-core.md
+++ b/docs/work/future/backlog-core.md
@@ -138,7 +138,7 @@ declared rather than for a buffer to fill, and to time out on stall rather than
- **Live RMII Ethernet reconfigure** — runtime PHY/pin config shipped (`ethType` + pin controls in NetworkModule, per-board defaults in `deviceModels.json`, `platform::setEthConfig`/`ethInit` dispatch). W5500 (SPI) on S3 applies **live** — `ethStop()` tears down the SPI bus and `ethInit()` re-runs on the next `loop1s()` with no reboot. RMII (classic/P4 internal EMAC) still saves config and asks for a restart to apply, because the EMAC bring-up is fiddlier to hot-cycle cleanly. Make RMII live too: a hot `esp_eth_stop` + EMAC/netif teardown + re-init on config change, matching the W5500 path, so every interface honours the no-reboot principle.
- **GCC below 16 needs four warnings demoted, and nothing exercises those versions** - `-Wnull-dereference`, `-Wrestrict`, `-Wstringop-overflow` and `-Wformat-truncation` fire on provably correct code from GCC 12 through 15 (five of the twelve inside libstdc++ and glibc headers, unreachable from our source), so CMakeLists demotes them to non-fatal there and keeps them fatal on 16+. That unblocks CI and from-source builds on Debian and Raspberry Pi OS alike, but it is a suppression, not an understanding: nobody routinely compiles with 12-15, so a REAL instance of one of these on those versions is now a warning nobody reads. Revisit when the runner's default GCC reaches 16, at which point the whole block can be deleted.
- **Installer UX polish** — clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion.
-- **Offer projectMM/MoonLight as a library** — a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from* — the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([rename-to-moonlight.md § Phase 1.3](rename-to-moonlight.md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API, not speculatively now.
+- **Offer projectMM/MoonLight as a library** — a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from* — the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([the MoonLight plan](../present/Plan-20260922%20-%20MoonLight,%20from%20v5.0.0%20to%20the%20rename.md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API, not speculatively now.
- **HTTP: a request whose headers or body arrive a few ms late is dropped, intermittently
(2026-08-20).** `handleConnection` runs SYNCHRONOUSLY inside `tick20ms`, so its waits are kept
short to protect the render loop: a freshly accepted connection gets **~5 ms** for its request
@@ -1415,11 +1415,75 @@ Art-Net gains the same way (4800 lights 3/6 → 6/6; 1350 pkt/s 2/6 → 4/6) —
Related: WLED is smooth on the same stream because it receives via `AsyncUDP` — packets are consumed in a callback from the lwIP task the instant they arrive, rather than polled once per render tick. Moving to that model is the structural fix, and it needs `staging_` synchronized against the render thread.
+## MIDI as a control surface, and the OpenLamp convention (2026-09-22)
+
+[ControlSurface](../../../src/core/util/ControlSurface.h) was written for MIDI hardware and has no MIDI transport. Its own documentation cites the APC40 mk2's ring-style CCs at 0x18/0x38, the X-Touch MINI's CC 1-8, and per-vendor SysEx for RGB pads, and its four verbs (`sendValue`, `sendRing`, `sendColor`, `sendLabel`) exist because MIDI hardware needs exactly those. OSC is the only transport that implements it. A MIDI transport is therefore a gap the architecture already anticipated rather than a new concept, and it is the obvious second implementation that proves the abstraction holds.
+
+**What OpenLamp offers.** [openlamp-spec-midi](https://github.com/openlamp/openlamp-spec-midi) is an MIT-licensed convention for driving WLED over MIDI: notes 59-68 for hues plus black and white, notes 48-56 for off, on, toggle and blackout, CC 1 for brightness, CC 3-4 for hue and saturation, CC 5-8 for effect, speed, intensity and palette, Program Change for presets, MIDI channel for targeting, and MIDI clock for beat sync. The [organisation](https://github.com/openlamp) also has an Ableton Link and MIDI-clock tempo library, and a CC0 asset set of 72 palette illustrations and 216 effect previews in eight languages.
+
+**Why the convention matters more than the code.** Its engine is Python and cloud-free by design, so nothing there ports to a device. The value is in agreeing what a note and a CC *mean*, since a MIDI transport has to answer that whatever we do, and answering it the same way as a project already aimed at WLED costs nothing and buys a user their existing mappings. The palette and effect artwork is CC0 and separately interesting for the catalog, which today has one screenshot per module and no palette illustrations at all.
+
+**Judge it against principle 2** before adopting: the standard construct beats a bespoke one, and this is a candidate standard. The caution is that the spec says plainly it is a draft, "likely to change, and early on to change quickly", and the organisation has low single-digit stars. So the sequence is a MIDI transport shaped by our own `ControlSurface` first, with the OpenLamp note and CC numbers as the default mapping where they fit, rather than a port of their model. Their beat-sync library is worth reading against [the audio work](backlog-light.md), since MIDI clock is a tempo source we do not have.
+
+**What it is not.** Not a replacement for OSC, which carries labels and arbitrary addresses that MIDI cannot. Not a lighting-control protocol in the DMX or Art-Net sense. This is about a musician's controller driving the show.
+
+Build trigger: someone with a MIDI controller who wants it to drive a device, or the DMX work reaching a point where a tempo source is the missing piece.
+
+### Notes as an effect input, which is the other half
+
+Routing a keyboard through a device so **each note triggers something inside an effect** is a different feature from the surface above, and the difference is worth stating before either is built. A control surface *sets controls*: a fader moves `brightness`, a pad selects a preset, and the effect never knows a surface exists. Notes are **performance data**: an effect reads them the way it reads audio, so what a note does is the effect's decision rather than a mapping table's.
+
+The architecture already has the shape this needs, which is why it belongs here rather than as a new concept. [AudioService](../../../src/core/services/AudioService.h) publishes an [AudioFrame](../../../src/core/util/AudioFrame.h) each block, effects read it through `latestFrame()`, and MoonLive scripts reach the same values through builtins such as `level` and `bands`. `AudioFrame::onset` is already "a hit happened this block", which is exactly a note-on without a pitch. A `MidiService` publishing a `MidiFrame` alongside it would need no new plumbing: the same publish-and-read rule, one more producer.
+
+What a `MidiFrame` plausibly carries: which notes are held and how hard, which arrived this tick and which left, the pitch bend and modulation positions, and the clock's beat phase. Held notes are the interesting part, because that is what audio cannot give an effect. An effect can then light a pixel per key, place a particle at a pitch, colour by velocity, or hold a shape while a chord is held and release it on note-off, none of which is expressible as "a control moved".
+
+The open design questions, all cheap to answer badly and expensive to redo: whether a note is a level that decays or an event that fires once, how polyphony maps onto a grid without the effect hardcoding a keyboard's range, whether an effect opts in (as audio effects do, where `hasAudio` gates them) or every effect sees the frame, and what happens on a device with no MIDI attached. The audio path answered all four already, so the honest first step is to read how it did and follow it rather than invent a parallel set of answers.
+
+**The relationship to OpenLamp:** their spec maps notes to *fixed meanings* (note 59 is a hue, note 48 is off), which is the control-surface half. Notes as performance data is the opposite: the note has no meaning until an effect gives it one. Both can share one MIDI transport, and they want different things from it, so the transport should deliver raw messages and let each consumer interpret, rather than translating to lamp actions on the way in.
+
+Build trigger: the same MIDI transport as above, since both halves need it and neither is worth building it alone.
+
+### The rest of the band: drums, guitar, voice
+
+A keyboard is the easy instrument, because MIDI hands us the notes already decided. Every other instrument in a live setting arrives as **audio**, and the work is deciding what happened in it. That splits into three paths with very different costs, and conflating them is how this becomes a project with no end.
+
+**Path one: the instrument already speaks MIDI, or can.** Electronic drums are MIDI over a cable, so each pad is a note and the work is zero beyond the transport above. A guitar reaches MIDI through a hex pickup or a converter pedal, a voice through a pitch-to-MIDI box. In every case the hard problem sits in a device the musician already owns and has already paid for accuracy on. **This is the path that reaches the most instruments for the least code**, and it argues for building the MIDI transport before anything else here.
+
+**Path two: one instrument on its own channel.** A live rig already splits the band across a mixer's channels, so a direct out or an aux send gives one input carrying one instrument. That is a source-seam question, and [the audio roadmap](audio-dsp-roadmap.md) already covers widening the seam with line-in, codecs and multi-channel front-ends. What it does not cover is the analysis, which is the real work:
+
+- **Drums**: our single `onset` fires on any hit anywhere in the spectrum, so a kick and a snare are indistinguishable. Per-band onsets separate them cheaply, since a kick lives in the low bands and a snare in the mids and highs, and the bands are already computed. This is the smallest useful step and it needs no new hardware.
+- **Guitar and bass**: `peakHz` gives a dominant frequency, which tracks a single note and falls apart on a chord. Real polyphonic transcription is a research problem and out of scope; monophonic pitch tracking on a bass or a lead line is not, and the existing peak is most of it.
+- **Voice**: a pitch track plus a loudness envelope is achievable and genuinely useful for lighting a vocal line. Recognising words is not, and nothing in a live show needs it.
+
+**Path three: the whole mix through one microphone.** What the device does today. Source separation to pull a drummer out of a mixed stereo field is a machine-learning problem that does not fit a microcontroller, and pretending otherwise would be the kind of speculative build principle 1 rejects. The honest ceiling here is what the band structure already gives: bass energy, spectral flux, a tempo estimate.
+
+**The one unifying idea worth keeping.** Whatever the source, the useful output is the same shape: *this instrument did this thing, this hard, just now*. A MIDI note-on is that. A per-band onset is that. A pitch track is that with a pitch attached. So the frames stay separate at the source (`AudioFrame` and a `MidiFrame`) and an effect reads whichever it wants, rather than one merged everything-frame that forces every producer to pretend it is the others. That keeps each producer honest about what it actually knows.
+
+**The order this argues for**, cheapest and most certain first: the MIDI transport (which reaches electronic drums, MIDI guitars and pitch-to-MIDI voice with no analysis at all), then per-band onsets (drums from a channel, and a better beat for everything), then monophonic pitch (bass and vocal lines), and only then anything about separating a mixed signal.
+
+Build trigger: a live rig to test against. Every step is measurable in an evening with an instrument in the room, and none of it is worth guessing at without one.
+
+### Everything as MIDI, and where the conversion should live
+
+The appealing shape is one where every instrument on stage arrives as MIDI: the drums, the guitar and the voice each converted, so the device reads one kind of event and an effect never asks where a hit came from. It is the right instinct, and the question it turns into is **where the conversion runs**, because that decides whether this is a weekend or a research project.
+
+**Converted off the device, it is available today.** Drum triggers, hex pickups and pitch-to-MIDI pedals all exist, are well made, and put the hard analysis in hardware the musician already trusts. A mixer's direct outs feed those boxes, their MIDI merges onto one cable, and the device reads notes. **Nothing here needs writing beyond the MIDI transport.** That is the version worth having first, and it is genuinely what a working stage rig already looks like.
+
+**Converted on the device, each instrument is a different problem.** Calling them all "X to MIDI" hides that the three have nothing in common technically. A drum channel is an onset detector, which we nearly have. A bass line is monophonic pitch tracking, which is a known algorithm and real work. A voice is pitch plus envelope, doable. A strummed guitar chord is polyphonic transcription, which is a research problem that does not fit a microcontroller and should be named as out of scope rather than left as an implied maybe.
+
+**The cost that bounds it.** One mono audio input measures about **8 ms per tick on a P4**, already the second-largest consumer on the board after the effect itself. Four instruments analysed on-device is four FFTs and four detectors, which is most of a frame budget before a single light is drawn. So the ceiling is not the algorithms, it is the arithmetic: **one or two analysed channels on a device, not a band.** A mixer feeding eight direct outs into one ESP32 is not a design this hardware supports, and saying so early is cheaper than discovering it late.
+
+**Where a mixer does fit.** A direct out or aux send giving **one clean instrument** is exactly the input that makes path two work, and it is worth more than any algorithm: a kick drum alone on a channel needs a threshold rather than a separator. So the mixer plug-in is valuable for a *small* number of channels, which is also the number the frame budget allows. The seam for that is the multi-channel front-end [the audio roadmap](audio-dsp-roadmap.md) already anticipates.
+
+**The architectural conclusion.** Rather than converting everything to MIDI on the way in, keep the frames separate and let an effect read what each producer honestly knows: a `MidiFrame` for what arrived as notes, an `AudioFrame` per analysed channel for what arrived as sound. Forcing a per-band onset to pretend it is a note-on adds a fake pitch and a fake velocity, and an effect that reads them cannot tell which are real. Same rule as above, applied to the tempting case.
+
+Build trigger: a stage with more than one instrument to point at it. The first honest experiment is one drum channel through a direct out, because it answers the cost question and the usefulness question at the same time.
+
## OSC pads and the Open Stage Control session's labels (2026-08-30)
Two gaps found wiring a real control surface to the [OSC module](../../moonmodules/core/services.md).
-**`/mm/pad/N` has no handler.** The [OSC plan](../present/Plan-20260829%20-%20OSC%20control%20ingest.md)
+**`/mm/pad/N` has no handler.** The [OSC plan](../past/plans/Plan-20260829%20-%20OSC%20control%20ingest%20(shipped).md)
lists it (`i 1 -> apply preset in slot 12`), and `OscModule::handle` routes `/mm/fader/`,
`/mm/encoder/`, `/mm/switch/` and `/mm/control/` but not pads. So a surface can drive every
continuous control and every switch, but cannot fire a preset, which is the one thing a pad grid
@@ -1638,7 +1702,7 @@ Lower risk than the RGMII case (six pins rather than twelve, and nothing of ours
## Input transports: foot pedals, USB game controllers, and MoonLive at the pins (2026-09-01)
-`ButtonService` shipped with the [GPIO seam](../present/Plan-20260901%20-%20Input%20mapping%20and%20scripted%20sensors.md)
+`ButtonService` shipped with the [GPIO seam](../present/Plan-20260901%20-%20Input%20mapping%20and%20scripted%20sensors%20(partial).md)
(`gpioInputBegin` / `gpioRead` / `gpioWrite`). It names a target as `Module.control` and writes it
through `Scheduler::setControl`, so a press and an OSC message are indistinguishable downstream.
Three follow-ups build on that seam rather than beside it.
diff --git a/docs/work/future/backlog-light.md b/docs/work/future/backlog-light.md
index f70eb222..ed7a59a2 100644
--- a/docs/work/future/backlog-light.md
+++ b/docs/work/future/backlog-light.md
@@ -476,6 +476,16 @@ 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.
+### One encode, two readers: HLS and RTSP sharing the encoder (2026-09-22)
+
+Both video drivers call `platform::encoderStart`, and the encoder is a single instance on every target: one ffmpeg child on the desktop, one hardware session on the P4. The second caller used to reconfigure the first's stream silently, so HLS kept serving at RTSP's geometry and bitrate without saying so. **Shipped instead: a claim**, so the second driver is refused with a visible status, the way a driver already reports a port another module holds. That makes the conflict impossible rather than unlikely, and it costs a guard.
+
+The better end state is the one the RTSP plan describes and the P4 already half-implements: **one encode, two readers**. `rtspTakeFrame` and `hlsSegment` read the same encoded frame today, so a device serving both would encode once and each reader would take what it needs, which is cheaper than either driver running alone twice over.
+
+What makes it a design task rather than a patch: the two drivers must **agree** on geometry, fps and bitrate, and nothing makes them agree now. HLS derives its bitrate from the grid and picks an encoder by name; RTSP takes the frames raw and names no encoder. The open questions are whose configuration wins when both are enabled, what happens when one changes `targetFps` mid-stream, and whether a reader that stops should relax the shared settings. A refcount alone answers none of those, which is why the claim ships first.
+
+Build trigger: someone wanting both streams from one device at once. Until then the claim is the honest behavior, and the refusal message names the other driver.
+
### 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
@@ -1009,6 +1019,8 @@ A HUB75 "panel" is a family, and the differences are not discoverable from its d
Build trigger: a panel that misbehaves in one of these ways. Adding all three speculatively is three controls nobody can act on; adding the one whose symptom appears is a control with a reason.
+**The trigger has appeared: `latch_blanking` (2026-09-22).** A tester reports the final column dark on two panels, everything else correct. The encoder emits ONE blank word per row ([Hub75Slots.h](../../../src/light/drivers/Hub75Slots.h), the word carrying `oe | lat` after each row's data), where the reference library defaults to 2 and makes 1 to 4 configurable. A single pulse leaves the last column sitting in the shift register as LAT fires, which is the column that goes dark, and the loop already encodes every column so the data is present rather than missing. The fix is a `latchBlanking` control that repeats that blanking word, defaulting to 2 to match the prior art. A panel showing the symptom is the measurement, since the right count is visible in one flash.
+
### Panel brightness through the output-enable window
Brightness works: `Correction::apply` writes every channel through `briLut`, so the slider dims a HUB75 panel exactly as it dims a strip. The question is HOW it dims, and on this driver it costs color resolution.
diff --git a/docs/work/future/input-mapping-analysis.md b/docs/work/future/input-mapping-analysis.md
index f755a62d..62ac8cc7 100644
--- a/docs/work/future/input-mapping-analysis.md
+++ b/docs/work/future/input-mapping-analysis.md
@@ -40,7 +40,7 @@ one button and wrong for everything else:
unrelated.
- The surface already exists, is already persisted, and is already what OSC drives. A second wiring
model beside it is the split brain
- [the OSC plan](../present/Plan-20260829%20-%20OSC%20control%20ingest.md) forbids.
+ [the OSC plan](../past/plans/Plan-20260829%20-%20OSC%20control%20ingest%20(shipped).md) forbids.
- Feedback needs it. A motorised fader or an LED-ringed encoder has to be *told* the current value;
that lives on the surface, and an input mapped straight to a module control has nowhere to read it
back from.
diff --git a/docs/work/present/Plan-20260630 - MoonLight migration (multi-stage).md b/docs/work/past/plans/Plan-20260630 - MoonLight migration (multi-stage, superseded).md
similarity index 96%
rename from docs/work/present/Plan-20260630 - MoonLight migration (multi-stage).md
rename to docs/work/past/plans/Plan-20260630 - MoonLight migration (multi-stage, superseded).md
index df79231d..e976fcbf 100644
--- a/docs/work/present/Plan-20260630 - MoonLight migration (multi-stage).md
+++ b/docs/work/past/plans/Plan-20260630 - MoonLight migration (multi-stage, superseded).md
@@ -1,5 +1,7 @@
# Plan — Migrate MoonLight effects / modifiers / layouts (multi-stage)
+> **Superseded on 2026-09-22** by [Plan-20260922 - MoonLight, from v5.0.0 to the rename](../../present/Plan-20260922%20-%20MoonLight,%20from%20v5.0.0%20to%20the%20rename.md), which consolidates the five MoonLight files into one. Kept for the reasoning behind decisions already taken.
+
## Goal & shape
Bring MoonLight's full library of **effects, modifiers and layouts** into projectMM. This is large, so it is **staged**: each stage ships independently, builds on the previous, and is its own `/plan` + commit. This document is the *map* — the per-stage plans get written when we reach them. Stages 1–2 are specified enough to start; later stages are scoped, not detailed.
@@ -60,7 +62,7 @@ what the trees say, not what the stages below predicted.
- **DMX Out** (and **DMX In**). The fixture model, the channel roles and the moving-head effects all
landed, so a head can be driven over Art-Net today; what is missing is WIRED DMX-512 over RS-485.
- Tracked in [backlog-light § RS-485](../future/backlog-light.md), where the analysis notes the
+ Tracked in [backlog-light § RS-485](../../future/backlog-light.md), where the analysis notes the
channel-mapping half is already solved and what remains is the transport (a UART in RS-485 mode,
break/mark timing) plus a physical transceiver. **This is the one Must-class gap for the rename.**
- ~~**HUB75.**~~ **Out of scope, decided 2026-09-07.** MoonLight drives these panels; projectMM
@@ -115,7 +117,7 @@ The previous status recorded four gates beyond effect breadth. Two have since sh
bench-verified over Art-Net; wired DMX output has not. See the driver gap above.
- **LightsControl maturity — NOT STARTED.** No such module exists. `LightPresetsModule` is the
fixture-preset library, a different thing. Still backlogged
- ([backlog-mixed](../future/backlog-mixed.md)).
+ ([backlog-mixed](../../future/backlog-mixed.md)).
- **Documentation pass — OPEN**, and cheaper than it was: the catalog pages exist and
`check_specs.py` keeps them honest, so what remains is a read-through rather than a build-out.
@@ -154,7 +156,7 @@ void loop() {
**Open design questions**, to settle in the stage plan rather than now:
- Where an animated palette script is ticked. MoonLight runs it as a node in the layer; our MoonLive scripts are modules, and a palette is global state owned by Drivers, so the tick site is not automatic.
- Whether animating the active palette every frame is acceptable on the hot path, given the 256-entry expansion our `colorFromPalette` interpolates against.
-- Interaction with the eventual LightsControl hub ([backlog-mixed](../future/backlog-mixed.md)), which is slated to absorb the palette control from Drivers.
+- Interaction with the eventual LightsControl hub ([backlog-mixed](../../future/backlog-mixed.md)), which is slated to absorb the palette control from Drivers.
Checkout note: the MoonLight tree read for this research was at `65869217` (2026-05-26) and may lag upstream; re-fetch before implementing.
@@ -214,7 +216,7 @@ and fixture model, and the doc model (in a better shape than this plan proposed)
The proving-ground stage: build the shared tools, prove them on one hard effect.
- **Palette.** Take **MoonLight's palette set** (~80 gradient palettes, [palettes.h](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Modules/palettes.h) — study + carry the gradient *data*, written into our own format). The definition format is the textbook **gradient-stop** one: a compact `{position, R, G, B, …}` list (position 0..255, terminating at 255), expanded off-loop into a 256-entry lookup. Our `Palette` type + `colorFromPalette(palette, index, brightness)`: the per-light lookup is an array index + one `scale8` (hot-path-tuned; the 256-entry table precomputed on selection, not per frame). Generalises `PlasmaPaletteEffect`'s hard-coded table.
- - **Ownership (decided 2026-06-30):** the **active palette is global**, owned by the **Drivers** container (already the home of global render params — brightness, lightPreset, the shared Correction) via a new `palette` select control. Effects read it through a static `Palettes::active()` seam (the `AudioModule::latestFrame()` pattern), so an effect just calls `colorFromPalette(Palettes::active(), idx)`. This mirrors MoonLight's global `layerP.palette` without needing MoonLight's `ModuleLightsControl` — which, with **presets** and the **external-controller hub** concept, is **backlogged** ([backlog-mixed.md](../future/backlog-mixed.md)) and will absorb the palette control from Drivers when built. Presets are *not* a palette dependency — separate feature, backlogged.
+ - **Ownership (decided 2026-06-30):** the **active palette is global**, owned by the **Drivers** container (already the home of global render params — brightness, lightPreset, the shared Correction) via a new `palette` select control. Effects read it through a static `Palettes::active()` seam (the `AudioModule::latestFrame()` pattern), so an effect just calls `colorFromPalette(Palettes::active(), idx)`. This mirrors MoonLight's global `layerP.palette` without needing MoonLight's `ModuleLightsControl` — which, with **presets** and the **external-controller hub** concept, is **backlogged** ([backlog-mixed.md](../../future/backlog-mixed.md)) and will absorb the palette control from Drivers when built. Presets are *not* a palette dependency — separate feature, backlogged.
- Palettes are light-domain → live under `src/light/` (file split decided in the stage plan).
- **The shared primitive library** (file split — one `light/Fx.h` vs focused `light/Beat.h`/`Noise.h`/`Blend.h` — decided in the stage plan; recognisable names, our implementation, FastLED credited as prior art). Hot-path-tuned, integer-only, LUT-backed:
- *timing/beat:* `beatsin8/16`, `beat8/16`, `triwave8` (on `sin8` + `elapsed()`).
@@ -250,7 +252,7 @@ Stage-2 exit: the library pages render with gifs, `check_specs.py` green on the
With foundations + doc model in place, migrate MoonLight effects in **themed batches**, each a stage/commit: study behaviour → write fresh on our primitives → unit + scenario test → add to `effects.md` + gif. Batching keeps each commit reviewable.
-**Scope: ALL effects across MoonLight's `Nodes/Effects/E_*.h` files**, not a cherry-picked subset — the [breadth-parity gate](../future/rename-to-moonlight.md) needs the full set. The source files (each an effect library, mapped to our origin sections + future per-library doc pages):
+**Scope: ALL effects across MoonLight's `Nodes/Effects/E_*.h` files**, not a cherry-picked subset — the [breadth-parity gate](rename-to-moonlight%20(superseded).md) needs the full set. The source files (each an effect library, mapped to our origin sections + future per-library doc pages):
- **`E_MoonModules.h`** (MoonModules-authored, 3): **GameOfLife** (Conway, 2D/3D, rulesets/wrap/color-aging/infinite-mode), **GEQ3D** ♫ (perspective 3D equalizer bars), **PaintBrush** ♫ (frequency-modulated animated lines, chaos/softness). — verified 2026-06-30 from source.
- **`E_MoonLight.h`** (MoonLight-original geometric set).
- **`E_WLED.h`** (WLED ports/enhancements).
@@ -291,7 +293,7 @@ carrying MoonLight's Troy / Wowi / Ambient looks. A head is drivable today over
**Left:** the WIRED transport. A DMX-512 output driver over RS-485 (UART, break/mark-after-break
timing, a transceiver on the board) and, if wanted, DMX input. The channel-mapping half is
already solved by the per-light channel model, so this is a transport and a hardware question
-rather than a domain one: [backlog-light § RS-485](../future/backlog-light.md) has the
+rather than a domain one: [backlog-light § RS-485](../../future/backlog-light.md) has the
analysis. **This is the one remaining Must-class item for the rename**, and since 2026-09-07 it is this
plan's alone: the Release 4 scope plan also listed it, shipped without it, and closed pointing here.
One home for it now.
diff --git a/docs/work/present/Plan-20260827 - Config backup and restore.md b/docs/work/past/plans/Plan-20260827 - Config backup and restore (shipped).md
similarity index 100%
rename from docs/work/present/Plan-20260827 - Config backup and restore.md
rename to docs/work/past/plans/Plan-20260827 - Config backup and restore (shipped).md
diff --git a/docs/work/present/Plan-20260829 - OSC control ingest.md b/docs/work/past/plans/Plan-20260829 - OSC control ingest (shipped).md
similarity index 99%
rename from docs/work/present/Plan-20260829 - OSC control ingest.md
rename to docs/work/past/plans/Plan-20260829 - OSC control ingest (shipped).md
index 03410e47..abd94569 100644
--- a/docs/work/present/Plan-20260829 - OSC control ingest.md
+++ b/docs/work/past/plans/Plan-20260829 - OSC control ingest (shipped).md
@@ -15,7 +15,7 @@ big-endian address string, a type-tag string, and 32-bit aligned arguments.
**A correction worth recording, because it shaped this plan.** The premise that reached us was
"OSC is the way, I have a Behringer X-Touch". OSC is indeed the way for the ecosystem, but **the
X-Touch does not speak it**, and neither does the QCon Pro G2 that `control.md` also names. Both
-are **Mackie Control** surfaces ([control surfaces reference](../../reference/hardware/control-surfaces.md)).
+are **Mackie Control** surfaces ([control surfaces reference](../../../reference/hardware/control-surfaces.md)).
So OSC does not connect the desks we own, and this plan deliberately does not pretend
otherwise. Driving those needs RTP-MIDI plus the MCU semantic layer, including motor feedback,
which is a much larger job and is scoped separately at the end.
diff --git a/docs/work/present/Plan-20260830 - Two-way control surfaces.md b/docs/work/past/plans/Plan-20260830 - Two-way control surfaces (shipped).md
similarity index 100%
rename from docs/work/present/Plan-20260830 - Two-way control surfaces.md
rename to docs/work/past/plans/Plan-20260830 - Two-way control surfaces (shipped).md
diff --git a/docs/work/present/Plan-20260903 - MoonLive palettes.md b/docs/work/past/plans/Plan-20260903 - MoonLive palettes (shipped).md
similarity index 100%
rename from docs/work/present/Plan-20260903 - MoonLive palettes.md
rename to docs/work/past/plans/Plan-20260903 - MoonLive palettes (shipped).md
diff --git a/docs/work/present/Plan-20260908 - Stream the WebSocket state instead of buffering it (attempted, reverted).md b/docs/work/past/plans/Plan-20260908 - Stream the WebSocket state instead of buffering it (attempted, reverted).md
similarity index 99%
rename from docs/work/present/Plan-20260908 - Stream the WebSocket state instead of buffering it (attempted, reverted).md
rename to docs/work/past/plans/Plan-20260908 - Stream the WebSocket state instead of buffering it (attempted, reverted).md
index 846257cc..b69dc729 100644
--- a/docs/work/present/Plan-20260908 - Stream the WebSocket state instead of buffering it (attempted, reverted).md
+++ b/docs/work/past/plans/Plan-20260908 - Stream the WebSocket state instead of buffering it (attempted, reverted).md
@@ -12,7 +12,7 @@
> over WebSocket**. `GET /api/state` already streams through a 1 KB socket-mode sink with no document
> in RAM, and the value patches already exist; what is missing is the UI fetching the snapshot on WS
> open and a small `{"resync":true}` on a structural change. That deletes the full-state-over-WS path
-> rather than shrinking it. Tracked in [backlog-core.md](../future/backlog-core.md).
+> rather than shrinking it. Tracked in [backlog-core.md](../../future/backlog-core.md).
>
> Kept from this work: `JsonSink` now FLAGS a refused heap grow instead of truncating silently
> (`unit_JsonSink_overflow`), which is the bug that made a cut document indistinguishable from a
diff --git a/docs/work/present/Plan-20260910 - MoonCloud.md b/docs/work/past/plans/Plan-20260910 - MoonCloud (shipped).md
similarity index 100%
rename from docs/work/present/Plan-20260910 - MoonCloud.md
rename to docs/work/past/plans/Plan-20260910 - MoonCloud (shipped).md
diff --git a/docs/work/present/Plan-20260910 - projectMM writes British English.md b/docs/work/past/plans/Plan-20260910 - projectMM writes British English (cancelled).md
similarity index 93%
rename from docs/work/present/Plan-20260910 - projectMM writes British English.md
rename to docs/work/past/plans/Plan-20260910 - projectMM writes British English (cancelled).md
index 8f80bb3b..b816ef68 100644
--- a/docs/work/present/Plan-20260910 - projectMM writes British English.md
+++ b/docs/work/past/plans/Plan-20260910 - projectMM writes British English (cancelled).md
@@ -2,7 +2,7 @@
## Context
-The project is American-spelled by an explicit rule ([coding-standards.md:18](../../contributing/coding-standards.md), [CLAUDE.md:101](CLAUDE.md)), enforced by `check_prose.py` at the commit gate and by `hook_prose.py` on every write. The PO wants British: `colour`, `behaviour`, `initialise`, `centre`, `grey`, `catalogue`, `analyse`.
+The project is American-spelled by an explicit rule ([coding-standards.md:18](../../../contributing/coding-standards.md), [CLAUDE.md:101](CLAUDE.md)), enforced by `check_prose.py` at the commit gate and by `hook_prose.py` on every write. The PO wants British: `colour`, `behaviour`, `initialise`, `centre`, `grey`, `catalogue`, `analyse`.
The deciding argument is user impact. The project has no official launch, so ADR-0013's "no migration code, documented break" applies at its cheapest: nothing outside our control has adopted our names yet. What *is* outside our control keeps its American spelling, and the PO accepts that discrepancy: **CSS/HTML properties, WLED/Home-Assistant wire keys, vendor symbols, SPDX headers.**
@@ -50,7 +50,7 @@ Three traps the inverted dict must handle, each needing an exclusion in the chec
- `analysis`/`analyses` (already correct; only `analyze`→`analyse`)
- `license` as a *verb* and in SPDX headers stays; only the noun becomes `licence`
-Update the rule statements: [docs/contributing/coding-standards.md:18](../../contributing/coding-standards.md) (rewrite the paragraph and its rationale, which currently argues the opposite) and [CLAUDE.md:101](CLAUDE.md). Both must name the external-contract exception explicitly, or the next reader will "fix" a CSS property.
+Update the rule statements: [docs/contributing/coding-standards.md:18](../../../contributing/coding-standards.md) (rewrite the paragraph and its rationale, which currently argues the opposite) and [CLAUDE.md:101](CLAUDE.md). Both must name the external-contract exception explicitly, or the next reader will "fix" a CSS property.
**Verify:** `uv run moondeck/check/check_prose.py` now flags American spellings in added lines; write a file containing `colour` and confirm the hook permits it.
@@ -60,7 +60,7 @@ Rename the builtins in `src/core/moonlive/MoonLiveBuiltins_common.h` and `src/li
Update the **12 call sites across 9 files** under `moonlive/effects/` (aurora, balls, comet-trail, fractal, metal, noise, octopus, stadbeest-eyes, stadbeest-legs). `setPaletteColorZ` is used only by `aurora.mle` (2 of those 12).
-**MIGRATING entry**, following the format of the `floor` entry at [docs/reference/MIGRATING.md:27](../../reference/MIGRATING.md): state the action (rename the call in any script you wrote), and name the shadowing hazard, which is the real trap: `/moonlive` (user) shadows `/.moonlive` (factory), so a stale user copy of a shipped script keeps calling the old name and fails with "unknown function" at the call site, while the factory copy is fixed.
+**MIGRATING entry**, following the format of the `floor` entry at [docs/reference/MIGRATING.md:27](../../../reference/MIGRATING.md): state the action (rename the call in any script you wrote), and name the shadowing hazard, which is the real trap: `/moonlive` (user) shadows `/.moonlive` (factory), so a stale user copy of a shipped script keeps calling the old name and fails with "unknown function" at the call site, while the factory copy is fixed.
**Verify:** `./build/macos/test/mm_tests -tc="*every script in moonlive*"` (the test that compiles every shipped script), then the Xtensa codegen test that compiles all 33 at the device budget. On the bench, load a renamed script on a board and see it run.
diff --git a/docs/work/future/moonlight-effect-inventory.md b/docs/work/past/plans/moonlight-effect-inventory (superseded).md
similarity index 87%
rename from docs/work/future/moonlight-effect-inventory.md
rename to docs/work/past/plans/moonlight-effect-inventory (superseded).md
index 9d777ffe..d38fd35e 100644
--- a/docs/work/future/moonlight-effect-inventory.md
+++ b/docs/work/past/plans/moonlight-effect-inventory (superseded).md
@@ -1,6 +1,8 @@
# MoonLight effect inventory (migration reference)
-The full set of MoonLight effects to migrate, grouped by **origin library** (a *section* within the shipped `effects.md` catalog page; a per-library page `effects_.md` only when a section outgrows it — see the folder-structure decision), with audio/3D markers. Source: [MoonLight effects.md](https://github.com/MoonModules/MoonLight/blob/main/docs/moonlight/effects.md) + the `E_*.h` source files — studied for *behaviour*, reimplemented fresh per the migration plan's *Industry standards, our own code* rule. This reference feeds the [migration plan's](../present/Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md) Stage-3 batches; it is *what to build*, not a copy of how.
+> **Superseded on 2026-09-22** by [Plan-20260922 - MoonLight, from v5.0.0 to the rename](../../present/Plan-20260922%20-%20MoonLight,%20from%20v5.0.0%20to%20the%20rename.md), which consolidates the five MoonLight files into one. Kept for the reasoning behind decisions already taken.
+
+The full set of MoonLight effects to migrate, grouped by **origin library** (a *section* within the shipped `effects.md` catalog page; a per-library page `effects_.md` only when a section outgrows it — see the folder-structure decision), with audio/3D markers. Source: [MoonLight effects.md](https://github.com/MoonModules/MoonLight/blob/main/docs/moonlight/effects.md) + the `E_*.h` source files — studied for *behaviour*, reimplemented fresh per the migration plan's *Industry standards, our own code* rule. This reference feeds the [migration plan's](Plan-20260630%20-%20MoonLight%20migration%20(multi-stage,%20superseded).md) Stage-3 batches; it is *what to build*, not a copy of how.
**Markers:** ♫ / ♪ audio-reactive · 🧊 native 3D. **Status:** ✅ already in projectMM · ⬜ to migrate.
diff --git a/docs/work/future/moonlight-fidelity-tensions.md b/docs/work/past/plans/moonlight-fidelity-tensions (superseded).md
similarity index 94%
rename from docs/work/future/moonlight-fidelity-tensions.md
rename to docs/work/past/plans/moonlight-fidelity-tensions (superseded).md
index f0d6207f..9a22d9f9 100644
--- a/docs/work/future/moonlight-fidelity-tensions.md
+++ b/docs/work/past/plans/moonlight-fidelity-tensions (superseded).md
@@ -1,5 +1,7 @@
# MoonLight migration — fidelity tensions
+> **Superseded on 2026-09-22** by [Plan-20260922 - MoonLight, from v5.0.0 to the rename](../../present/Plan-20260922%20-%20MoonLight,%20from%20v5.0.0%20to%20the%20rename.md), which consolidates the five MoonLight files into one. Kept for the reasoning behind decisions already taken.
+
A running log of places where **strict fidelity to MoonLight's behaviour** (the migration mandate:
end users must see the same effect they always have) collides with a **projectMM principle**
(robustness / no-crash-at-any-grid-size, correctness, hot-path discipline, *common patterns first*).
@@ -16,7 +18,7 @@ Status legend: 🟡 open (needs PO decision) · 🟢 resolved (decision recorded
Resolved per the "same UX, improvements allowed" rule: **clamp the drawn band count to the column
count** so bars spread instead of piling at x=0 on a narrow grid. Invisible on normal grids
(cols ≥ numBands → no-op), so no fidelity loss where it matters. See
-[moonlight-improvements.md](moonlight-improvements.md). (GEQ — the flat 2D one — was *not* affected:
+[moonlight-improvements.md](moonlight-improvements (superseded).md). (GEQ — the flat 2D one — was *not* affected:
it maps each column to a band, so it never had the collapse.)
## 2. 🟢 GEQ3D — frame-counter sweep → time-based — RESOLVED (2026-07-01)
@@ -24,7 +26,7 @@ it maps each column to a band, so it never had the collapse.)
Resolved: converted the projector sweep to a **time-based triangle wave** (`triwave8(beat8(...))`),
so `speed` means the same on every device (frame-rate-independent). Not throttled — a fast board
renders the same sweep more smoothly, a slow one choppier. Once-per-frame, no per-pixel cost. See
-[moonlight-improvements.md](moonlight-improvements.md).
+[moonlight-improvements.md](moonlight-improvements (superseded).md).
---
@@ -74,7 +76,7 @@ effects that want WLED's calm `volume`/`volumeSmth` can read it, and doing an **
to point each effect at the value matching its behaviour: NoiseMeter → raw `level` (unchanged, VU
snaps to beats); FreqMatrix, AudioSpectrum's VU bar, AudioVolume → `levelSmoothed` (breathing/flowing
look). Bands-driven effects (GEQ, GEQ3D, PaintBrush, FreqSaws, Blurz) read per-band magnitudes,
-unaffected. See [moonlight-improvements.md](moonlight-improvements.md).
+unaffected. See [moonlight-improvements.md](moonlight-improvements (superseded).md).
## 6. 🟡 Reconstructed logic — effects whose MoonLight source was incomplete (cross-check on bench)
diff --git a/docs/work/future/moonlight-improvements.md b/docs/work/past/plans/moonlight-improvements (superseded).md
similarity index 94%
rename from docs/work/future/moonlight-improvements.md
rename to docs/work/past/plans/moonlight-improvements (superseded).md
index dbb662ab..51dfc176 100644
--- a/docs/work/future/moonlight-improvements.md
+++ b/docs/work/past/plans/moonlight-improvements (superseded).md
@@ -1,12 +1,14 @@
# Effect improvements over MoonLight
+> **Superseded on 2026-09-22** by [Plan-20260922 - MoonLight, from v5.0.0 to the rename](../../present/Plan-20260922%20-%20MoonLight,%20from%20v5.0.0%20to%20the%20rename.md), which consolidates the five MoonLight files into one. Kept for the reasoning behind decisions already taken.
+
Where a migrated projectMM effect **deliberately behaves differently from the MoonLight original** — a
change that *improves* the effect (more correct, smoother, works at more grid sizes, a control that
matches its label) rather than a straight port. The migration mandate is fidelity ("effects look like
MoonLight"), so every intentional divergence is registered here with its reason, so it's a decision on
record, not accidental drift.
-Distinct from [moonlight-fidelity-tensions.md](moonlight-fidelity-tensions.md): that log
+Distinct from [moonlight-fidelity-tensions.md](moonlight-fidelity-tensions (superseded).md): that log
holds *undecided* fidelity-vs-principle conflicts awaiting a call; this doc holds *decided* improvements
the product owner approved (correctness/UX wins that ship).
diff --git a/docs/work/future/rename-to-moonlight.md b/docs/work/past/plans/rename-to-moonlight (superseded).md
similarity index 90%
rename from docs/work/future/rename-to-moonlight.md
rename to docs/work/past/plans/rename-to-moonlight (superseded).md
index e0625b38..8756aa50 100644
--- a/docs/work/future/rename-to-moonlight.md
+++ b/docs/work/past/plans/rename-to-moonlight (superseded).md
@@ -1,5 +1,7 @@
# Rename projectMM → MoonLight (phased)
+> **Superseded on 2026-09-22** by [Plan-20260922 - MoonLight, from v5.0.0 to the rename](../../present/Plan-20260922%20-%20MoonLight,%20from%20v5.0.0%20to%20the%20rename.md), which consolidates the five MoonLight files into one. Kept for the reasoning behind decisions already taken.
+
The project will be renamed **projectMM → MoonLight**, and two repos swap names at the same time:
| Repo today | Becomes | What it is |
@@ -64,7 +66,7 @@ Decoupling and groundwork that's safe while both repos still hold their current
> **Could we reuse `library.json`'s `name` now where a literal sits (subtraction, not a new constant)?** Surveyed `moondeck/` for it — verdict: **no genuine low-hanging fruit.** ~95% of `projectMM` literals there are the **binary name** (`build/…/projectMM`, `.bin`, `.exe`, `.log`, `pkill projectMM`, crash `.ips`) which must track the **CMake target**, not `library.json` (wiring them to the product name would break the path to the file on disk); plus one **wire literal** (`_net_probe.py` ArtNet source-name, must byte-match the device) and ~15 prose/docstrings. The only product-name candidates — `generate_manifest.py`'s manifest `name`/`home_assistant_domain` — must *stay* `projectMM` today (Step 2), don't currently read `library.json`, and flip alongside `library.json` in the sweep anyway, so wiring them is new plumbing for zero present benefit. The principle (reuse an existing source of truth over a hardcoded literal) is right; it just has no payoff here because the literals are either binary-coupled or static-until-the-switch. (The real home for product-identity reuse is still the library API — see the box above.)
>
- > **The constant has a real future home: projectMM/MoonLight as a library.** When the project is offered as an embeddable library, a consumer will want one runtime identity to read (an "About"/banner string, the protocol source-name they can query) — *that* is the ongoing, widely-referenced use a `kProjectName` constant genuinely earns (the test the rename failed). But build it **then**, against a real library API surface (it may want to be a small `ProjectInfo` — name + version + url — not a bare string) — not speculatively now. Tracked as a seed in [backlog-core](backlog-core.md); when the library work starts, introduce the identity constant as part of its public API and let the wire-strings + UI derive from it.
+ > **The constant has a real future home: projectMM/MoonLight as a library.** When the project is offered as an embeddable library, a consumer will want one runtime identity to read (an "About"/banner string, the protocol source-name they can query) — *that* is the ongoing, widely-referenced use a `kProjectName` constant genuinely earns (the test the rename failed). But build it **then**, against a real library API surface (it may want to be a small `ProjectInfo` — name + version + url — not a bare string) — not speculatively now. Tracked as a seed in [backlog-core](../../future/backlog-core.md); when the library work starts, introduce the identity constant as part of its public API and let the wire-strings + UI derive from it.
4. **Author the mechanical sweep script** — ✅ **Done:** [`moondeck/rename/rename_to_moonlight.py`](../../moondeck/rename/rename_to_moonlight.py), dry-run by default (`--apply` writes; reserved for switch-day Phase 3.3, *after* the repo rename). What the dry-run against today's tree established: replaces two tokens (`ProjectMM` the enum, then `projectMM`) — a plain token swap is correct for *every* form (repo URL, host path, `projectMM.bin`, product name, `deviceName` slug) since `projectMM` is never a substring of another token; file list comes from `git ls-files` so build output (`build/`, `esp32/build/`) is excluded without a brittle blocklist; `docs/history` (era record) + the rename doc itself are content-excluded. Verified: **542 hits across 113 files**, and `MoonLive` / predecessor `MoonLight` / `namespace mm` are provably never touched (0 files where their count changes). The enum rename is safe — device classification keys on the `"modules"` marker, not the label string. The script de-risks switch-day; it is NOT run with `--apply` until then.
5. **Prep MoonDeck / `moondeck.json` / bench registry** — ✅ **investigated; nothing to change now, two things flagged for switch-day.** (a) **The functional chain stays `projectMM` until the switch (and flips together in the sweep):** `moondeck_config.json`'s `process_name: "projectMM"` ↔ the CMake binary `projectMM` ↔ the `build//projectMM` run/log path ↔ `pkill projectMM`. These are tracked files the sweep rewrites in one pass, so they stay consistent — changing `process_name` early would break MoonDeck's process detection against today's binary, so don't. (b) **The sweep cannot reach the gitignored bench registry** `moondeck/moondeck.json` (it's private, per [[bench-setup]]; the sweep uses `git ls-files`). Its `"board": "projectMM testbench …"` values reference catalog `name`s that *do* flip — so after the switch they'd mismatch only on your bench. **Switch-day local-tooling note: hand-update `moondeck/moondeck.json` board names** (and re-provision bench devices if you want the new mDNS identity) — the sweep covers tracked files only. The MoonDeck prose (`MoonDeck.md`, code comments) flips in the normal sweep.
@@ -100,29 +102,29 @@ Taking the **MoonLight** name sets an expectation: someone arriving from the pre
This is parity-to-take-the-name, not parity-for-parity's-sake — projectMM's architecture (live reconfiguration, robustness, the generic module/UI) is already ahead in places the count doesn't show. Prioritise what a predecessor user would *miss*, not raw feature count.
-**Live scripting is not a gap — [MoonLive](../../explanation/architecture/moonlive.md) overrules it.** The predecessor's on-device scripting was an *interpreter* lineage; MoonLive is a **native-codegen compiler** (source → typed IR → real machine code, called by function pointer at near-100% native speed in the hot path) — the architecture's named *standout*. So live scripting is a projectMM **advantage to lead with**, not a parity item to close; it is deliberately absent from the MoSCoW below.
+**Live scripting is not a gap — [MoonLive](../../../explanation/architecture/moonlive.md) overrules it.** The predecessor's on-device scripting was an *interpreter* lineage; MoonLive is a **native-codegen compiler** (source → typed IR → real machine code, called by function pointer at near-100% native speed in the hot path) — the architecture's named *standout*. So live scripting is a projectMM **advantage to lead with**, not a parity item to close; it is deliberately absent from the MoSCoW below.
These are pointers to existing backlog items; the rename doesn't create new work so much as set a **bar** for which items gate it. Each links to its detailed entry rather than restating it.
### Must — the rename is a downgrade without these
-- **Effect breadth at a credible fraction of 60+** — not all 60, but enough that the library doesn't feel thin. Today's ~20 cover the common families (noise, fire, plasma, particles, audio); a Must is closing the obvious *category* gaps a predecessor user expects (see Should), not matching the count. (MoonLive softens even this: a user can *author* a missing effect on-device rather than wait for a built-in.) **This gate is executed by the staged MoonLight migration** — its plan ([`Plan-20260630 - MoonLight migration (multi-stage)`](../present/Plan-20260630%20-%20MoonLight%20migration%20(multi-stage).md)) brings the predecessor's effects / modifiers / layouts across in batches on a shared palette + primitive foundation; the rename's bar is "enough batches landed to not feel thin," not "all stages done."
+- **Effect breadth at a credible fraction of 60+** — not all 60, but enough that the library doesn't feel thin. Today's ~20 cover the common families (noise, fire, plasma, particles, audio); a Must is closing the obvious *category* gaps a predecessor user expects (see Should), not matching the count. (MoonLive softens even this: a user can *author* a missing effect on-device rather than wait for a built-in.) **This gate is executed by the staged MoonLight migration** — its plan ([`Plan-20260630 - MoonLight migration (multi-stage)`](Plan-20260630%20-%20MoonLight%20migration%20(multi-stage,%20superseded).md)) brings the predecessor's effects / modifiers / layouts across in batches on a shared palette + primitive foundation; the rename's bar is "enough batches landed to not feel thin," not "all stages done."
- **Mapping / layout parity for real fixtures** — the predecessor's "memory-optimised mapping" across non-trivial fixtures (matrices, rings, cubes, custom). projectMM has Grid/Sphere/Wheel + modifiers; a Must is that a user's existing physical layout from the predecessor has a path here.
- **OTA continuity for in-field devices** (also in Phase 2/3) — a predecessor user's deployed devices must keep updating across the rename, not brick on a dead URL.
### Should — expected, but can trail slightly under the new name
-- **More LED driver types toward the 11** — projectMM has RMT, LCD, Parlio, NetworkSend. Gaps a predecessor user may rely on: 16-lane I2S parallel (classic ESP32), shift-register expanders, additional protocols. ([backlog-light](backlog-light.md))
-- **Moving-head / DMX fixture model** ([backlog-light § Fixture model](backlog-light.md)) — if predecessor users drive moving heads, this is a felt gap; long-term there, so likely Should/Could.
-- **E1.31 multicast receive, async ArtNet** ([backlog-core](backlog-core.md)) — network-output completeness a show operator expects.
-- **Audio-reactive follow-ups** ([backlog-light § Audio-reactive](backlog-light.md)) — projectMM has the audio pipeline; closing the effect/feature follow-ups keeps audio parity.
+- **More LED driver types toward the 11** — projectMM has RMT, LCD, Parlio, NetworkSend. Gaps a predecessor user may rely on: 16-lane I2S parallel (classic ESP32), shift-register expanders, additional protocols. ([backlog-light](../../future/backlog-light.md))
+- **Moving-head / DMX fixture model** ([backlog-light § Fixture model](../../future/backlog-light.md)) — if predecessor users drive moving heads, this is a felt gap; long-term there, so likely Should/Could.
+- **E1.31 multicast receive, async ArtNet** ([backlog-core](../../future/backlog-core.md)) — network-output completeness a show operator expects.
+- **Audio-reactive follow-ups** ([backlog-light § Audio-reactive](../../future/backlog-light.md)) — projectMM has the audio pipeline; closing the effect/feature follow-ups keeps audio parity.
### Could — nice for the launch, not blocking
-- **z-axis variation in 2D effects**, **full-density interpolated preview**, **RGBW preview end-to-end** ([backlog-light](backlog-light.md)) — polish that makes the new MoonLight feel finished.
-- **Runtime board presets**, **per-layout coordinate offset** ([backlog-core](backlog-core.md)) — usability wins, independent of parity.
+- **z-axis variation in 2D effects**, **full-density interpolated preview**, **RGBW preview end-to-end** ([backlog-light](../../future/backlog-light.md)) — polish that makes the new MoonLight feel finished.
+- **Runtime board presets**, **per-layout coordinate offset** ([backlog-core](../../future/backlog-core.md)) — usability wins, independent of parity.
- **Sensor input breadth** (IMU/line-in beyond the mic) — extends the platform, not core to the predecessor's identity.
### Won't (this rename) — explicitly out of scope for the switch
- **100% effect-count parity** — chasing all 60+ before the rename would block it indefinitely; close categories, not the literal count.
-- **Raspberry Pi 5 sensor input**, **fixture model for beams** ([backlog](backlog-light.md)) — post-1.0, land under the new name.
+- **Raspberry Pi 5 sensor input**, **fixture model for beams** ([backlog](../../future/backlog-light.md)) — post-1.0, land under the new name.
- **Renaming MoonLive** or any non-`projectMM` identifier — out of scope (see blast radius).
**The gating question for the product owner:** which of the Musts must be *shipped* vs. *credibly announced as in-progress* at switch time? With live scripting off the list (MoonLive overrules it — and is a *lead* feature, not a gap), the remaining Musts are lighter: effect breadth and mapping/layout parity are incremental, and MoonLive's author-it-yourself path further softens the effect gap. The likely real gate is just "enough effects + a clean migration path for existing layouts" — a much shorter pole than the predecessor's headline capability would have implied.
diff --git a/docs/work/past/release-notes-v5.0.0.md b/docs/work/past/release-notes-v5.0.0.md
new file mode 100644
index 00000000..c55d9837
--- /dev/null
+++ b/docs/work/past/release-notes-v5.0.0.md
@@ -0,0 +1,91 @@
+# projectMM v5.0.0
+
+**166 commits across 27 PRs**, and the last release under this name: the next one is MoonLight. Highlights: the wall leaves the device as video over NDI, HLS and RTSP; HUB75 panels drive straight from the board's pins; a second boot image gives 4 MB boards their flash back; configuration travels by backup and restore; and audio-reactive effects work on the desktop.
+
+If you like projectMM, give it a ⭐️, fork it, or open an issue or pull request. It helps the project grow, improve, and get noticed.
+
+### 🌗 About the name
+
+This is the final projectMM release. The project becomes **MoonLight** at v6.0.0, which is a rename rather than a rewrite: the same code, the same modules, the same configuration. Your settings carry across with Backup and Restore, and a device running v5.0.0 finds the v6.0.0 release on its own, because this firmware already knows both addresses.
+
+### ✨ Highlights
+
+**The wall as video (new)**
+
+- **NDI** publishes the layer as a source OBS, Resolume and TouchDesigner pick up by name.
+- **HLS** serves an H.264 stream from the device's own HTTP server, playable in VLC, a browser or an Apple TV. Desktop encodes through your ffmpeg; the **ESP32-P4 encodes in hardware** and serves segments from a PSRAM ring, never touching flash.
+- **RTSP** streams the same H.264 as a pull stream a player opens directly. It reaches a viewer far sooner than HLS, which buffers whole segments before showing one, so this is the remote view to reach for. Point `ffplay` or VLC at the card's URL.
+
+**HUB75 panels, driven directly (new)**
+
+A HUB75 panel now lights from the board's own pins, with no ColorLight receiving card in between. Board presets for MoonHub75, the Adafruit MatrixPortal S3 and the Waveshare RGB Matrix; selectable scan rate, bit depth and clock edge; and two backends, LCD_CAM and Parlio, to choose between.
+
+**MoonBase: a second boot image (new)**
+
+A 4 MB board stops spending half its flash on a second copy of the firmware. The app partition grows from **1856 to 2496 KB** and the filesystem from **256 to 548 KB**. One click covers reboot, install and reboot, and a power cut mid-update lands back in MoonBase rather than a half-written app. Installs measure about three times faster than the path they replace.
+
+**Backup and restore (new)**
+
+One button in the File Manager downloads WiFi, modules, presets and MoonLive scripts as a single bundle. Restore converts settings written by an older firmware and applies them live, with no reboot. It works from a freshly erased device's own access point, which is what makes a rebuild from nothing a two-minute job.
+
+**Audio on the desktop (new)**
+
+Audio-reactive effects now run on macOS, Windows and Linux, reading a microphone or a loopback device from a dropdown. A desktop with audio sending enabled becomes a WLED audio-sync source for a whole fleet, so one machine hears the room and every device reacts.
+
+**Control surfaces and scripted inputs**
+
+Open Stage Control is now a two-way surface: move a fader on the device and the surface follows. Infrared and buttons became **lists of rows** you add, learn and point at any target the REST API can set, rather than fixed firmware actions. Analog inputs are readable, and sensor scripts run on the board.
+
+**MoonLive grows a language**
+
+Functions take arguments and return values, local variables are real, and a script sees the whole rig, including aiming moving heads. **Scripted palettes** (`.mlp`) recompute their sixteen entries every frame and sit in the same picker as the sixty built-ins. The whole shipped script library is browsable and downloadable in the UI.
+
+**Moving heads**
+
+Moving heads work in 2D and 3D with per-fixture placement, effects steer pan and tilt through the same buffer that carries color, and the preview draws each head's beam as a colored cone.
+
+**New effects**
+
+Fluid (Navier-Stokes dye jets), Nebula, Trails, ColorTrails, Aurora, BeatRipples, RadialSpectrum, VuMeters, FishTank, Pacman, Pong, SpaceInvaders, FlyingToasters, SpriteFountain and FixedPoint. Underneath them, Perlin gradient noise replaces value noise, joined by polar and oscillator kernels, all with 3D forms.
+
+**New boards and distribution**
+
+ESP32-S3-Zero and the QuinLED Dig-Next-2 join the shipped boards, with Ethernet presets for Classic RMII, the P4-NANO and the S31 CoreBoard. The web installer flashes every chip projectMM ships, the S31 included. Linux gains an **arm64 package**, Windows a zip install when Defender blocks the installer, and every release publishes a **container image** whose instances generate their own MAC and name so a fleet is not a row of identical devices.
+
+**MoonCloud, strictly opt-in**
+
+Off by default. Report hardware and configuration statistics, and post to a public board. The installation id is a SHA-256 of a salt and the MAC, so the raw MAC never leaves the device.
+
+### 📐 Measured improvements
+
+- **RMT LED driver** rewritten to ship wire bytes: **96 bytes per light down to 3**, and the ceiling where a long strand silently stopped updating is gone.
+- **Parallel LEDs on a classic ESP32**, alongside the microphone, where any pin change used to reset the board: **256 lights at 114 fps** on a Dig-Next-2, **64 lights at 414 fps** on an Olimex Gateway with Ethernet up.
+- **Fluid** on a 64x64 panel costs **134 us**, and a 20x20x20 cube **232 us**. The pressure solve is the knob, near-linear from 20 us at one iteration to 69 at twenty.
+- **Polar effects** read angle and radius from a table built once per geometry, which is **34% of a PolarNoise frame**.
+- **MoonLive on a classic ESP32**: transient heap for the largest script falls from about **110 KB to 40 KB**.
+- **Per-band audio conditioning** measures each band against its own noise floor, so a quiet band is audible without a loud one clipping, and beats are detected rather than inferred from volume.
+- **Brightness follows the CIE 1931 lightness curve**, so a slider at half reads as half.
+- **Every one of the 61 effects has a preview**, where twelve did.
+
+### 🐛 Notable fixes
+
+- **A silent configuration wipe on upload**: HTTP header matching was case-sensitive and missed a lowercase `content-length`, committing an empty file with a 200. Found on the bench when it zeroed a test device's configs and scripts.
+- **Flicker under WiFi on classic ESP32**, fixed by moving the RMT refill to a level-5 interrupt. Two boards that flickered all evening are clean.
+- **Scripts painted into a corner**: a script read width as 255 on any larger grid, so every 2D effect drew a complete picture into one corner.
+- **A remote overread in the OSC parser**, which is a security fix.
+- **HUB75 row addressing off by one**, and a DMA wrap that double-lit row 0.
+- **Frame-rate coupling in sprite effects**, where a motion took two seconds at 60 fps and half a second at 240.
+- **The PDM microphone on the Dig-Next-2**, whose two-wire mode the I2S seam did not support.
+- **The preview dropped lights** whose coordinates shared a large factor.
+
+### ⚠️ Breaking changes
+
+Seventeen entries, each with the action it asks of you, are in [MIGRATING](../../reference/MIGRATING.md). Most need nothing. The ones most likely to affect a running device:
+
+- `expertMode` became `mode`, with three levels, so a device comes up in `user` mode.
+- Infrared is a list of learned rows, and the remote must be re-learned.
+- 4 MB boards and `esp32-16mb` move to the MoonBase partition table, which is an erase-and-reflash.
+- AudioVolume is gone, and Noise2D folded into Noise.
+- `soundReactive` is now `audioReactive`, and PreviewDriver's `fps` is now `targetFps`.
+
+**Backup before upgrading.** The File Manager's Backup carries configuration across every one of these, converting what it can and reporting what it cannot.
diff --git a/docs/work/past/reviews/2026-07-20-driver-feature-audit.md b/docs/work/past/reviews/2026-07-20-driver-feature-audit.md
deleted file mode 100644
index 9b6f2289..00000000
--- a/docs/work/past/reviews/2026-07-20-driver-feature-audit.md
+++ /dev/null
@@ -1,145 +0,0 @@
-# Driver feature audit — pre-merge review of `next-iteration` (2026-07-20)
-
-A Fable-agent review of the whole `next-iteration` branch (10 commits, 92 files, ~10.3K insertions) before it merges to `main`, run per the product owner's request. **Part A** is the standard pre-merge drift review; **Part B** is the per-driver, per-feature inventory (modularity / cost / still-needed?) that is the basis for the merge-prep actions.
-
-**Status of the Part A findings (updated 2026-07-20 after the review):**
-
-| # | Finding | Status |
-|---|---------|--------|
-| A1 | `Drivers::tick()` cross-core race on quiesce timeout | **FIXED** — `tick()` now `stopEncodeTask()`s the wedged worker before the inline fallback. |
-| A2 | `isr_cache_safe` + `termNode` + "OPEN BUG" comments contradict the code | **FIXED** — all four comment blocks rewritten to present-state truth. |
-| A3 | Per-row bench diagnostics in the hot encode loop | **Deferred** (post-merge) — diagnostics are kept deliberately through the tuning era (PO). |
-| A4 | Duplicated slice-fill (ISR vs prime) | **FIXED** — one shared `fillSlice()`; `ea` real-refill accounting preserved (wall-verified). |
-| A5 | Two hand-rolled fork-join workers | **Deferred** (post-merge) — backlog a core `ForkJoinWorker` primitive. |
-| A6 | `ringDbg` cryptic 18-field control | **Kept** — diagnostic, tuning era, PO wants it. Trim post-tuning. |
-| A7 | Stale ~18ms cost number in the fork-join rationale | **Deferred** — folds into the "measure-then-delete the snapshot half" follow-up. |
-
-The **MERGE-PREP ACTION LIST** at the end is the forward-looking part; items in its group 1 (before-merge) are done, groups 2-3 are the keep/follow-up decisions. This doc is the record — the branch commit and the backlog carry the actions.
-
----
-
-## PART A — Pre-merge drift review (ranked)
-
-**A1. HIGH — Drivers::tick() races a live core-1 encode after a quiesce timeout.** `Drivers.h:375-379 + 519-531`. When `quiesceEncode()` times out in `tick()`, it sets `renderSplitActive_ = false` and returns — but does **not** stop/join the worker. `tick()` then immediately composites into `outputBuffer_` and falls through to `MoonModule::tick()`, ticking every driver inline on core 0 **while the wedged core-1 task may still be inside a driver's `tick()`** reading the same buffer and the same `inFlight_[]`/bus state. Failure scenario: a transiently-starved worker (the exact case the timeout exists for) un-wedges a moment later → two cores concurrently in one driver's `tickAsync()` → double `busTransmit` on one buffer, corrupted `inFlight_` bookkeeping, potentially a freed-buffer read on the next prepare. `prepare()` and `quiesce()` both already do `if (!quiesceEncode()) stopEncodeTask();` — tick() is the one caller that doesn't. Minimal fix: in `tick()`, on `quiesceEncode()` failure call `stopEncodeTask()` (a join; slow but this is the declared-broken path) before falling back inline — the same rule the other two call sites follow.
-
-**A2. MEDIUM — Safety-invariant comments contradict the code (isr_cache_safe).** `platform_esp32_moon_i80.cpp:364-373` (the `moonI80EofCb` header block) and `platform.h:780-793` both state "the channel does NOT set `isr_cache_safe` … a flash-resident callback is permitted … full-flash-write hardening (isr_cache_safe + IRAM encode) is a later increment." But `initRingDma` **does** set `chanCfg.flags.isr_cache_safe = true` (line 1053) and the whole encode chain is now MM_RAMFUNC/IRAM — that "later increment" shipped, and the ISR carries the `spi_flash_cache_enabled()` defer guard (line 397) precisely because of it. A future editor trusting the comment could legitimately remove the defer guard or move the encode back to flash and get the measured Cache-error panic back. These are the two doc blocks that define the ISR's safety contract; rewrite both to the present state (ring channel = cache-safe + IRAM chain + defer guard; whole-frame channel = not cache-safe, flash callback fine). Also stale: `MoonI80State::termNode`'s comment (line 262-265) says "so the next arm can restore its loop link" — no restore code exists; it is diagnostic-only (`termNodeDiag`).
-
-**A3. MEDIUM — Per-row bench diagnostics are unconditionally compiled into the shift encode hot loop.** `ParallelLedDriver.h:817-848` (`dbgSegGatherCy/EmitCy/Rows`, two `platform::cycleCount()` calls + three volatile RMWs **per row**, marked "TEMP DIAGNOSTIC") and `tickRing`'s `dbgTickWaitUs/SnapUs/PrimeUs` with the hardcoded `constexpr uint32_t kCyPerUs = 240; // S3 at 240 MHz` (line 555). Three problems: (a) they run in the ISR refill and on every whole-frame shift encode, for every user, forever — a few % of the hottest loop paid for a bench instrument; (b) `kCyPerUs=240` is wrong on the P4 (360 MHz) and meaningless on desktop (cycleCount is ns there) — a bespoke constant where `platform` owns clock facts; (c) they are `static inline volatile` on a class template, shared across instances (documented, but a 2-driver board reads garbage). These earned their keep during the 48×256 hunt; at merge they should be gated (an `MM_RING_DIAG` compile flag or deleted with the lesson recorded). Same for the `segT1/segT2` "TEMP DIAGNOSTIC" stamps inside `encodeRows`.
-
-**A4. MEDIUM — Duplicated slice-fill logic in the platform ring (ISR vs prime).** `platform_esp32_moon_i80.cpp:427-472` (EOF ISR batch refill) and `965-994` (`primeRingRange`) implement the same body twice: short-last-slice tail memset + `shortSlice → bufNeedsPrefill`, encode-slice, past-frame zero-fill + `bufNeedsPrefill`, and the `s == nSlices` frame-close call. That is the *No duplication* smell in the most delicate code in the branch — the two copies have already diverged once (the ISR adds timing/late instrumentation) and any future fix (e.g. the close-word rule) must be made twice. Minimal fix: one `fillSlice(st, slot, sliceIdx)` helper both call (IRAM).
-
-**A5. LOW — Two hand-rolled fork-join worker patterns, one mechanism.** `Drivers` (encodeTask_/encodeDone_/encodeStop_/quiesceEncode, Drivers.h:471-583) and `MoonLedDriver` (snapHelper_/snapHelperDone_/snapHelperStop_/helperJoin, MoonLedDriver.h:511-598) are the same construct — spawn-parked worker, notify-kick, acquire-spin join with timeout + self-heal — written twice with slightly different timeouts and degradation latches. Per *"when core already owns a mechanism for one path, extend it"* this wants a small core/platform `ForkJoinWorker` primitive; both call sites would shrink to a few lines. Not a merge blocker (both copies are individually correct and tested), but it should be named in the backlog as the standard fix, per the interim rule in CLAUDE.md.
-
-**A6. LOW — `ringDbg` is a bespoke 18-field cryptic string control.** `MoonLedDriver.h:289-307` — `"sl%u/bf%u dn%u ld%u lt%u tx%u ipb%u ci%u tn%d de%u enc%u ea%u sg%u se%u tw%u ts%u tp%u gap%u"`. No widely-used project ships a UI control like this; it is a serial-log line living in a control (the stated reason — /api/state polling beats serial scraping — is real and recorded, so it passes the bespoke-with-reason bar *as a diagnostic*). It also carries the marker "TEMP DIAGNOSTIC" on its backing buffer (line 602). Post-merge it should shrink to the few fields that remain meaningful in operation (`lt`, `enc/ea`, `gap`) or move behind a debug build. Related nit: `refreshBusKpi`'s read-and-clear of the ISR-written `dbgSeg*` volatiles is a cross-context RMW race — harmless for a diagnostic, but that's another reason to gate it.
-
-**A7. LOW — stale cost numbers in MoonLedDriver's fork-join rationale.** `MoonLedDriver.h:512-513`: "the snapshot correction (~18 ms at 48×256) and the pool prime (~14 ms)". The pre-corrected snapshot was replaced by the raw memcpy + fused correction this same branch (encodeRows doc, ParallelLedDriver.h:796-803: "deletes the whole ~4.7 ms pre-correction pass"), so the ~18 ms figure describes a deleted mechanism and currently over-justifies the snapshot half of the fork-join (see B, snapshot fork-join). Fix the comment (and see the action list — the snapshot half itself may now be removable).
-
-**A8. Clean.** Domain boundary: `check_platform_boundary.py` passes; all LCD_CAM/GDMA/FreeRTOS code is in `src/platform/esp32/`; the drivers reach hardware only through `platform::` seams; `MM_RAMFUNC` is a platform_config macro (empty on desktop) — the right shape. `pinExpanderMode()` (a pass-through alias of `pinExpander`) carries its stated reason at the site; borderline but within the rules. Spec/docs: ADR-0014, the two catalog pages, and MIGRATING.md all landed with the rename (`I80LedDriver`→`MultiPinLedDriver`, ✓ with a migration note); `kExactLaneCount`→`kPowerOfTwoBus` rename is consistent. Tests: the branch adds serious pinning — unit_ParallelLedDriver_ring.cpp (978 lines), _pinexpander (545), unit_ParallelSlots growth (+457), unit_MoonLedDriver (141) — including the recycled-buffer==fresh and prefill+data==whole-slot equivalences the correctness story depends on.
-
----
-
-## PART B — Driver feature audit
-
-### DriverBase (shared base)
-
-| Feature | Modularity | Cost (flash / memory+degradation / hot-path) | Still needed? |
-|---|---|---|---|
-| preset/whiteMode/localBrightness correction controls + `Correction` LUT | Clean: base owns wiring, `Correction` is a flat POD applied per light. | LUT = 256 B/driver; apply() is per-light hot but integer/LUT. | **Keep — load-bearing** for every physical driver. |
-| `wire_` scratch (now internal-RAM-first, ×kMaxCores) | Clean grow-only lifecycle on base; per-CPU slicing is the textbook per-CPU-data pattern. | ≤ 64×outCh×2 ≈ 384 B. Internal-first is measured (PSRAM scratch multiplied encode). Degrades to alloc(), then idles. | **Keep.** |
-| `driverHeapBytes()`/`publishHeapBytes()` accounting | Good shape: one virtual summing hook instead of setDynamicBytes pasted per alloc site — exactly the centralize-the-rule principle. | Zero hot-path (cold-path calls only). | **Keep.** |
-| `setDrivingInfo(..., mode)` ring-regime suffix | Small, additive. | Nil. | Keep — "primed"/"lapping" in the status is genuinely user-meaningful. |
-| `kFailBufLen` 48→64 | Trivial, justified by -Wformat-truncation. | +16 B. | Keep. |
-
-### ParallelLedDriver (CRTP base — where most features live)
-
-| Feature | Modularity | Cost (flash / memory+degradation / hot-path) | Still needed? |
-|---|---|---|---|
-| **doubleBuffer** (deferred-wait async, tickSync/tickAsync) | Clean: mode fixed by whether buffer 1 was allocated (no stale-flag routing); OFF path is byte-for-byte the original. Excision would be clean but unwanted. | 2nd DMA buffer (frame-sized; reserve-guarded, allocate-and-degrade ✓). Hot path: `max(encode,wire)` vs sum — the win. | **Keep — load-bearing** (48→76 fps measured). The A/B switch itself is cheap and documented as measurement knob; fine to keep. |
-| **pinExpander + latchPin** ('595 shift mode) | Very clean at this layer: a bool + a pin; all geometry (`outputsPerPin`, latchBit, busWidth rounding, frame ×8) derives from it. Encoders live in ParallelSlots (domain-pure, host-tested). | Flash: the shift encoders are templated ×2 widths + IRAM-resident (MM_RAMFUNC) — a real few-KB IRAM cost on S3, but only on chips that compile them. Memory: frame ×8 (whole-frame) or ring pool. | **Keep — this is the 48×256 goal feature.** |
-| **prefillShiftConstants / prefillShiftRows + needsPrefill skip** | Good split: constants once (cold), data-word-only per frame (hot); the buffer-lifecycle fact (`needsPrefill`) is computed by the one party that knows (platform) — right seam. | Saves 2/3 of encode stores (9.7→3 µs/light measured); prefill skip saved ~1/3 of ISR refill. | **Keep — load-bearing** for the ISR deadline. Note the mask-run loop in `prefillShiftRows` duplicates encodeRows' mask build (minor, acceptable). |
-| **ringSnapshot (memcpy snapshot + fused correction)** | Clean: one bool, `encodeSrc_` bias pointer keeps encodeRows' index formula unchanged; sized off hot path; freed when OFF or on whole-frame fallback (readout honest). | ~36 KB internal at 48×256 (PSRAM fallback = measured ~10% encode cost, degrade not crash ✓). Hot: one windowed memcpy/frame. | **ON path is load-bearing** (ISR reads a frozen frame; UAF-on-resize guard). The **OFF A/B leg is now risk**, not lever: it re-opens the exact concurrent-read hazard the snapshot exists for, is reachable from the UI, and the question it answered (snapshot cost) is settled by the `ts` meter. Candidate: remove the control, keep the mechanism. |
-| **Snapshot fork-join (snapHelperKick/copyRange/copyHelperRange/snapLineAlignedHalf)** | The hooks are clean CRTP no-ops on other drivers, but it drags 5 members + a gcd/cache-line-alignment function + `` into the base for what is now a **single memcpy**. | Splitting one ~36 KB internal-SRAM memcpy across two cores is memory-bandwidth-bound — near-zero win post-fusion. | **Likely OBSOLETE — superseded by its own branch-mate.** It was designed for the ~18 ms *pre-corrected* snapshot (see A7); the raw memcpy this branch replaced it with is sub-ms. Measure `ts` with the helper off; if flat, delete the snapshotHalf job + alignment math (the **prime** fork-join stays — that one parallelizes real encode work, ~14 ms). |
-| **tickRing** (async wait/snapshot/arm) | Clean third path, explicitly separated; never blocks a frame on the wire (the UI-refresh-freeze fix). | Hot: wait + memcpy + prime per frame; prime is the big one (fork-joined). | **Keep — load-bearing.** But strip the dbgTick* stamps / kCyPerUs (A3). |
-| **busWaitIfBusy / deadFrames_ / busGaveUp + periodic retry** | Textbook give-up-with-retry breaker, well placed in the base (all backends inherit). Status re-derivation via parseConfig on recovery is correct. | Hot path: two branches/tick when healthy. | **Keep — load-bearing robustness** (the "misconfigured LED driver made the device unreachable" fix). |
-| **waitBudgetMs (frame-derived timeout)** | Clean, derived not constant. | Nil. | Keep. |
-| **Loopback self-test (private-bus)** + loopbackTxPin/loopbackStrand | Well-contained control cluster; conditional-hidden control shape is consistent. But it deinits/rebuilds the live bus — on the 48×256 ring that means re-allocating a ~121 KB pool post-test (fragmentation risk, acknowledged in code). | Zero unless enabled; allocs are control-driven. | **Keep** — it is the project's only ground-truth instrument (the memory notes repeatedly say "the wall is the instrument"; this is the machine version). |
-| **loopbackIntrusive (ride mode + patternHoldStrand_)** | Cleaner than the private-bus mode for the ring (no teardown, no alloc, driver-agnostic via `ws2812LoopbackRide`); the pattern-hold hook in snapshotSourceForRing is a small but real domain-logic intrusion into the hot snapshot (one branch/frame, `patternHoldStrand_ >= 0` — pays one compare when off). Comment says "the coming loopbackMode dropdown folds this + loopbackTest into one" — mildly future-tense. | One compare/frame when off; `delayMs(40)` only in the control path. | **Keep** — it's the only test that can verify the ring at 48×256 *at zero extra RAM*. Fold the two bools into the promised dropdown post-merge (that comment is a forward-looking note in present-tense code — do it or cut the promise). |
-| **frameTime KPI (tick1s)** | Clean, cheap, per the sub-hot-path rule. | One snprintf/s. | Keep. |
-| **kMaxStrands=64 / uint64 activeMask + 32-bit halves** | The 32-bit-half discipline (Xtensa __ashldi3 lesson) is applied consistently in all four sites. | Hot-path win, measured. | Keep. |
-| **dbgSeg*/dbgTick* statics** | See A3. | Per-row cost. | **Remove/gate before or shortly after merge.** |
-
-### MoonLedDriver (+ platform_esp32_moon_i80.cpp)
-
-| Feature | Modularity | Cost (flash / memory+degradation / hot-path) | Still needed? |
-|---|---|---|---|
-| **Own gapless i80 DMA backend (whole-frame)** | Exemplary platform split: driver is one-liner forwards; platform file mirrors esp_lcd function-for-function with cited line numbers; ADR-0014 records the decision; GPIO teardown on destroy is complete (matrix detach). | Flash: ~1.8 K-line platform file, S3/P4 only. Whole-frame path: same costs as esp_lcd sibling minus the ghost pins. | **Keep — load-bearing** (it's what proved the PSRAM-at-shift-clock measurement AND hosts the ring). |
-| **useRing** (path selector) | Clean; the no-auto-router rationale (silent fallback hid the active path) is a genuinely good call, documented at the site. | Nil. | **Keep** the switch; whole-frame remains the A/B reference below ~96/strand and the fallback degrade path. |
-| **Streaming ring: looping GDMA chain, ISR inline refill, clock oracle, prime-only self-termination** | The heart of the branch. Layering is right (platform owns descriptors/ISR/oracle; domain owns encode via the `MoonI80EncodeFn` seam with `needsPrefill` — a well-designed seam). Internals are intricate but every non-obvious choice carries its measured reason (auto_update_desc=false, one-node-per-buffer clamp, mark_eof-on-terminator-only, kResetLowUs timed reset, kLead/kBatchMax). | Memory: pool = rows×rowBytes×bufs internal DMA (auto up to ~free−64K reserve — at 48×256 ~121 KB, deliberate spend); reserve honored; falls back to whole-frame on any alloc failure ✓. Hot: EOF ISR at intr priority 3 encodes slices — *the* hot path; IRAM-resident; cache-off defer guard. | **Keep — THE load-bearing mechanism for 48×256.** Duplication finding A4 applies. The stale "OPEN BUG — 8..16 slices" comment block (lines 172-186) describes a bug the clock-oracle/lapping work has since resolved per the memory/commit trail — verify and rewrite present-tense, it currently tells a reader the ring is broken. |
-| **ringAuto / ringRows / ringBufs / ringPadUs** | The DHCP write-back pattern (auto fills the visible controls) is recognisable and honest. Bounds shared via platform constants (kRingNodeMaxBytes etc.) so driver/platform can't drift — good. `busInitRing` mutating controls during prepare is unusual but documented. | Nil hot. | **Keep ringAuto + the three manual controls** (lapping frontier tuning is real, per the memory notes: pad is a per-wall hardware fact). Consider whether `ringRows` max of 64 in the control is honest when the node clamp makes 7 the effective max at 16 strands — the auto path shows real values, manual can silently clamp (documented, acceptable). |
-| **shiftOverclock** (20 vs 26.67 MHz) | Clean switch-not-divider with wall-verified rationale; div plumbed as a file-static global (`g_shiftClockDiv`) — a documented, single-knob exception. | Nil. | **Keep — a real hardware A/B** (151 vs 118 fps; per-wall reliability). The default OFF matches the memory's reliability findings. |
-| **Prime fork-join (primeHalf via snapHelper)** | Buffers are index-independent so disjoint ranges are safely parallel; join-fence-then-arm ordering is right; self-heal latch on timeout. | ~14 ms serial prime split across cores — real win at 48×256. | **Keep.** (The *snapshot* half of the same helper is the obsolete part — see ParallelLedDriver row.) |
-| **ringDbg control** | See A6. | One snprintf/s. | **Trim post-merge** to lt/enc/ea/gap; delete the "TEMP DIAGNOSTIC" fields whose bugs are closed (ipb/ci/tn were the prime-only bug instruments; ld/dn the coalescing one). |
-| **busRingMode ("primed"/"lapping")** | Clean. | Nil. | Keep. |
-| **Loopback over the ring (copy-slice encoder, pool step-down, largest-first capture alloc)** | Thoughtful: tests the actual transport, geometry from the live controls. | Control-driven only. | Keep. |
-
-### MultiPinLedDriver (esp_lcd reference)
-
-| Feature | Modularity | Cost (flash / memory+degradation / hot-path) | Still needed? |
-|---|---|---|---|
-| **Whole driver (esp_lcd LCD_CAM/I2S i80)** | Excellent — ~230 lines of CRTP hooks, shares everything with ParallelLedDriver. | Nearly free (shares the base). | **Keep — load-bearing on classic ESP32** (the ONLY i80 path there, I2S backend). On S3/P4 it is the declared reference/default with a written retirement criterion ("retired only if the challenger demonstrably beats it"). Settle the S3/P4 A/B post-merge. |
-| **clockMultiplier shift path through esp_lcd** (`i80Ws2812Init(..., clockMultiplier)`) | First-gen shift path; superseded in *capability* by MoonI80's ring. | Whole-frame ×8 (internal-RAM-bound, ~96 lights/strand cap). | **Keep for now; named RETIREMENT candidate** — the ring strictly supersedes it. Retiring it also deletes the ghost-pin/dcPin tax it drags along. Retire this leg first when the challenger is promoted. |
-| **kSupportsPinExpander / kPowerOfTwoBus / kLoopbackFullWidth constexpr hooks** | Clean compile-time capability flags. | Zero (compile-time). | Keep. |
-
-### ParlioLedDriver
-Small delta: `kSupportsPinExpander=false` with the 65,535-byte transfer-cap reason at the site ✓; rename fallout only. All base features inherited; nothing ring/shift compiles for it. **Keep as-is.**
-
-### RmtLedDriver
-Delta is only `driverHeapBytes` accounting + rename comments. Unchanged behavior. Keep.
-
-### PreviewDriver
-
-| Feature | Modularity | Cost (flash / memory+degradation / hot-path) | Still needed? |
-|---|---|---|---|
-| **resumableFrames** (staged gather + resumable buffered send) | Clean: control + affectsPrepare, buffers allocated only when ON, freed when OFF, cancel-before-free UAF guards present, degradation statuses name *why* (alloc-miss → warning). | stage_ ~3×points (~24 KB), keptIdx_ cache; both accounted via driverHeapBytes. Removes a measured ~17 ms *synchronous socket write on the encode worker* — a sub-hot-path fix. | **ON is load-bearing** (the LED-hitch fix). The **OFF leg** is declared "the proven-correct reference to A/B on hardware" — legitimate short-term; once soaked, the OFF path (the blocking sender) is the thing the fix exists to kill. Candidate for post-merge removal of the *control*, keeping sync only as the automatic alloc-failure degrade (which refreshStatus already surfaces). |
-| **keptIdx_ index cache** | Right: cache lifecycle == coord-table lifecycle, alloc-miss falls back to the full walk (correct-but-slower). | Removes an O(total-lights) forEachCoord walk per frame (~8 ms at 12K). | **Keep.** |
-
-### NetworkSendDriver / HueDriver
-**Unchanged on this branch** (not in the diff). No audit action; they inherit nothing from the new machinery (windowed DriverBase only).
-
-### Drivers (container)
-
-| Feature | Modularity | Cost (flash / memory+degradation / hot-path) | Still needed? |
-|---|---|---|---|
-| **multicore render↔encode split** (encodeTask_, quiesceEncode, forced identity outputBuffer_) | Right home (container owns the boundary, buffer, task); engage predicate from alloc *outcome* (allocate-and-degrade ✓); core's tickChildren gate reused on both sides ✓; quiesce() wired into structural mutations ✓. | One frame-sized handoff buffer + 8 KB task stack; hot: one atomic wait per frame (`renderWait` KPI measures it). | **Keep — load-bearing** (the core-0 network-starvation fix; also what makes the ring's core-1 tick + core-0 helper topology exist). Fix A1. |
-| **renderWait KPI** (peak-per-window) | Clean; hidden when off. | Nil. | Keep — it is the declared Step-2b decision meter. |
-| **multicore switch** | ON-is-better documented; OFF is the escape hatch. | Nil. | Keep the switch (escape hatch on a 2-core chip with a misbehaving worker is worth it). |
-
-### Platform seams added (platform.h)
-`allocInternal`, `cycleCount`, `currentCore`/`kMaxCores`, `cpuInfo`, `wifiApClientCount`, the MoonI80 family + `MoonI80RingStats`, shared ring constants (kRingRowsDefault/BufsDefault/PadMaxUs/NodeMaxBytes/BufsMin/Max), `ws2812LoopbackRide`, RmtLoopbackResult capture diagnostics. All are recognisable primitives with named precedents at their introduction sites (per-CPU data, rdtsc-class counter, DHCP-style constants sharing). `cycleCount`'s only questionable consumer is the diagnostics (A3) — the seam itself is fine and core-worthy. `kMaxCores=2` as an inline constexpr in platform.h is the right shape.
-
----
-
-## MERGE-PREP ACTION LIST
-
-> **Superseded (2026-07-20, later same day).** This forward-looking list is kept verbatim as the review's own record of what it recommended, but it is no longer the live action list — that belongs in `docs/backlog/`, not this history snapshot. What has happened since: group-1 items **A1, A2, A4 shipped** (see the status table above); the group-3 follow-up **"measure-then-delete the snapshot half of the fork-join" also shipped** — the snapshot fork was removed (serial snapshot on core 1, prime fork kept) as part of the flicker + idle-starvation fix, deleting `copyHelperRange`/`snapHelperLo_/Hi_`/`snapLineAlignedHalf` and the `HelperJob` enum. The still-open items (ringSnapshot OFF-leg retirement, PreviewDriver sync-send retirement, `loopbackMode` dropdown, `ringDbg` trim, the `ForkJoinWorker` core primitive, the S3/P4 A/B) live in [backlog-light.md](../../future/backlog-light.md) as the authoritative to-do. The list below is left intact as the retrospective it is.
-
-### 1. Fix / simplify before merge
-1. **Fix A1** — `Drivers::tick()`: on `quiesceEncode()` timeout, `stopEncodeTask()` before falling back inline (3-line change, closes a real cross-core race on the declared-broken path).
-2. **Fix A2** — rewrite the two isr_cache_safe comment blocks (moon_i80.cpp:364-373, platform.h:780-793) and the `termNode` "restore" comment to present-tense truth; also verify-and-rewrite the "OPEN BUG — 8..16 slices" block (moon_i80.cpp:172-186) which describes a since-fixed failure as open.
-3. **Gate or delete the TEMP diagnostics (A3)** — `dbgSegGatherCy/EmitCy/Rows` (per-row cycleCount in the ISR encode), `dbgTickWaitUs/SnapUs/PrimeUs` + the `kCyPerUs=240` hardcode, and their `sg/se/tw/ts/tp` fields in ringDbg. Cheapest honest form: one `MM_RING_DIAG` compile-time flag defaulting off; the lessons they produced are already in memory/lessons.
-4. **De-duplicate the platform slice-fill (A4)** — one `fillSlice()` shared by the EOF ISR and `primeRingRange` (this is the code a future ring bug will be fixed in; two copies is how the fix gets missed).
-
-### 2. Safe to keep (load-bearing or earning their A/B keep)
-- The **ring** itself (looping chain, ISR refill, clock oracle, prime-only termination, ringAuto + manual geometry, ringPadUs), **pinExpander** + prefill/data-word split + needsPrefill, **memcpy snapshot with fused correction** (ON), **prime fork-join**, **doubleBuffer**, **multicore split** + renderWait, **dead-frame give-up + retry**, **frame-derived waitBudget**, **both loopback modes**, **shiftOverclock**, **driverHeapBytes accounting**, **GPIO drive-strength CAP_3** (hpwit-verified need), **MultiPinLedDriver as reference/classic-ESP32 path**, **useRing switch**, **PreviewDriver resumableFrames ON + keptIdx cache**, all new platform seams.
-
-### 3. Follow-up after merge (ordered by payoff)
-1. **Measure-then-delete the snapshot half of the fork-join** (A7/B): with the memcpy snapshot, time `ts` helper-off at 48×256; if sub-ms, delete `snapCopySrc_/snapCopyCh_/snapHelperLo_/Hi_`, `copyHelperRange`, `snapLineAlignedHalf` (+``), keeping the helper task for primeHalf only. Net-negative diff on the hairiest file.
-2. **Retire the `ringSnapshot` OFF leg** (control → always-on mechanism): the A/B answered its question; OFF is a live UAF-hazard toggle in the UI.
-3. **Retire PreviewDriver's synchronous send as a *control*** once resumable has soaked; keep it only as the automatic alloc-failure degrade.
-4. **Fold `loopbackTest`+`loopbackIntrusive` into the promised `loopbackMode` dropdown** (the code comment already commits to it).
-5. **Trim `ringDbg`** to lt/enc/ea/gap once the lapping work stabilizes; delete the closed-bug instrument fields (ipb/ci/tn, ld/dn) and the `descErr` B1-discriminator if it stays 0 through the soak.
-6. **Lift the fork-join worker into a core/platform primitive (A5)** and re-base Drivers + MoonLedDriver on it; backlog it with the core fix named, per the interim rule.
-7. **Settle the S3/P4 A/B** (MoonLed vs MultiPin): the retirement criterion is already written in MoonLedDriver's doc; when the challenger wins, retire the esp_lcd *shift-mode* leg (`clockMultiplier` through i80Ws2812Init) first — it is the one capability the ring strictly supersedes — and only then consider the whole reference on LCD_CAM chips (classic ESP32 keeps MultiPin regardless).
-8. **Per-pin ≤16-bit active mask** to lift kMaxStrands=64 — already correctly deferred in the kMaxStrands doc; leave backlogged until a board needs >56 strands.
-
-Key files: `src/light/drivers/ParallelLedDriver.h`, `src/light/drivers/MoonLedDriver.h`, `src/light/drivers/Drivers.h`, `src/platform/esp32/platform_esp32_moon_i80.cpp`, `src/platform/platform.h`, `src/platform/esp32/platform_esp32_worker.cpp`.
diff --git a/docs/work/present/Plan-20260901 - Input mapping and scripted sensors.md b/docs/work/present/Plan-20260901 - Input mapping and scripted sensors (partial).md
similarity index 100%
rename from docs/work/present/Plan-20260901 - Input mapping and scripted sensors.md
rename to docs/work/present/Plan-20260901 - Input mapping and scripted sensors (partial).md
diff --git a/docs/work/present/Plan-20260912 - Documentation sweep.md b/docs/work/present/Plan-20260912 - Documentation sweep (partial).md
similarity index 100%
rename from docs/work/present/Plan-20260912 - Documentation sweep.md
rename to docs/work/present/Plan-20260912 - Documentation sweep (partial).md
diff --git a/docs/work/present/Plan-20260915 - Native HUB75 output.md b/docs/work/present/Plan-20260915 - Native HUB75 output (partial).md
similarity index 100%
rename from docs/work/present/Plan-20260915 - Native HUB75 output.md
rename to docs/work/present/Plan-20260915 - Native HUB75 output (partial).md
diff --git a/docs/work/present/Plan-20260922 - MoonLight, from v5.0.0 to the rename.md b/docs/work/present/Plan-20260922 - MoonLight, from v5.0.0 to the rename.md
new file mode 100644
index 00000000..22628cac
--- /dev/null
+++ b/docs/work/present/Plan-20260922 - MoonLight, from v5.0.0 to the rename.md
@@ -0,0 +1,171 @@
+# Plan: MoonLight, from v5.0.0 to the rename
+
+projectMM becomes MoonLight. **v5.0.0 is the last release under the old name and v6.0.0 is the first under the new one.** This file is the whole record: what ships before the switch, what happens at it, what follows, and the decisions already taken along the way. It replaces the five files that held pieces of it.
+
+## The two decisions that shape everything
+
+**Migration from the predecessor is finished.** No further effects, layouts or modifiers are ported from the old MoonLight. What exists today is what ships, and the gap tables in the old plans are closed rather than outstanding. New effects are written on merit from here, not to match a list.
+
+**A user's configuration survives both releases.** v5.0.0 upgrades in place, subject only to the breaks [MIGRATING](../../reference/MIGRATING.md) already records. v6.0.0 carries configuration across too, by Backup on v5 and Restore on v6, because **nothing persisted carries the product name**: config files are named after module types (`Effects.json`, `Drivers.json`) and the sweep leaves every type and `namespace mm::` untouched. The migration engine in [migrate.js](../../../src/ui/migrate.js) therefore has no rename to apply for the rename itself, which is the easiest case it can be handed.
+
+What does break at v6.0.0 is **live interoperation**: a projectMM device and a MoonLight device on one network stop recognising each other, because discovery compares a literal name. That is a same-day nuisance rather than lost work, and the fix is to update both.
+
+## Where we stand
+
+Verified against the tree on 2026-09-22 rather than read from the plans, because several of their status lines had gone stale.
+
+| | Count | Note |
+|---|---|---|
+| Effects | 67 headers, ~64 registered | Migration complete by decision |
+| Modifiers | 12 | All 9 predecessor ones plus three of ours |
+| Layouts | 18, 17 registered | Three install-specific ones absent, and staying so |
+| MoonLive | 33 `.mle` scripts, 5 `.mlp` palettes | Palettes shipped |
+| HUB75 | Ships | `Hub75Driver` is registered |
+| Fidelity | 4 of 6 settled | Two open, both bench questions rather than code |
+
+## What the rename touches
+
+393 occurrences of `projectMM` across `src/`, `moondeck/` and `mooninstaller/`; the sweep script measures 542 across 113 tracked files tree-wide. The categories matter more than the count.
+
+[rename_to_moonlight.py](../../../moondeck/rename/rename_to_moonlight.py) handles almost all of it and runs dry by default. It replaces two tokens, `ProjectMM` then `projectMM`, which is correct for every form because `projectMM` is never a substring of another token. Its file list comes from `git ls-files`, so build output is excluded without a blocklist. `MoonLive`, the predecessor's own name, and `namespace mm` are provably never touched.
+
+### The device builds its own OTA URL, and that turns out to be safe
+
+[MqttModule.cpp:324](../../../src/core/system/MqttModule.cpp) formats `github.com/MoonModules/projectMM/releases/download/v/firmware--v.bin` in firmware, so a device flashed today asks the old repository for its updates forever. Three things make that work anyway, and all three were verified in the code rather than assumed:
+
+- GitHub issues a permanent redirect for a transferred repository, for the API and for release assets.
+- The OTA client follows redirects deliberately: [platform_esp32_ota.cpp:117](../../../src/platform/esp32/platform_esp32_ota.cpp) sets `disable_auto_redirect = false` with a redirect count of 10, and raises the header buffer specifically because GitHub's asset redirect overflows the default. It was hardened for this shape of URL already.
+- The asset filename is `firmware--v.bin`, which carries no product name and so does not change at the rename.
+
+**A v5 device therefore finds and installs v6 with no code change.** Only recreating `MoonModules/projectMM` would break the redirect, and the name sits inside an organisation we control, so nothing to do beyond leaving it alone.
+
+This was worth checking rather than believing: reading the URL construction alone suggests a hard break, and only the fetch path shows there is none.
+
+### What a clean break still costs
+
+Two things the sweep changes that a user feels, neither needing code:
+
+- **Peer discovery** compares a literal `"projectMM"` at [DevicesModule.h:109](../../../src/core/system/DevicesModule.h), and [E131Packet.h:55](../../../src/light/util/E131Packet.h) writes a fixed nine-byte source name. A projectMM device and a MoonLight device will not see each other, so a mixed network is a transitional state to move through rather than live in. Accepted rather than bridged: carrying both tokens forever is the debt this project exists to avoid.
+- **The `MM-` device prefix** in [SystemModule.h:57](../../../src/core/system/SystemModule.h) is every device's mDNS name and Home Assistant entity id. Changing it to `ML-` renames all of that, and unlike the configuration it is not something Restore carries back. Keeping `MM-` costs an odd prefix forever; changing it costs every user their bookmarks and automations once. Product owner's call, in the same sweep commit either way.
+
+## v5.0.0, the last projectMM release
+
+1. **An in-place upgrade from any earlier version.** Whatever a tester has configured keeps working across the upgrade, with [MIGRATING](../../reference/MIGRATING.md) as the only exception list. v5.0.0 is where the installed base gathers before the switch, so it cannot be the release that asks them to start over.
+2. **The two open fidelity questions**, both bench work rather than code: the audio `volume` scale (0..1 float against our 0..255 `level`), and a cross-check of effects whose predecessor source was incomplete.
+3. **Windows day's findings**, since the Sept 29 pass is the first time that platform has ever been tested and anything it turns up is cheaper to fix under the old name.
+
+The release is therefore small by design. Its value is being a known-good, widely-installed baseline that the rename can be measured against, rather than a feature drop.
+
+Everything else is optional polish under the old name.
+
+## v6.0.0, the rename itself
+
+One repository transfer, one sweep commit, one release. The installer manifest, the release asset names and the in-firmware URL builder are a lockstep set, so a half-applied rename leaves devices unable to update.
+
+1. **Dry-run the sweep on a throwaway branch** and run the full gate set on it: every ESP32 variant, the tests, the scenarios, `check_devices`, `check_specs`. Fix what the sweep gets wrong, including the fixed-length wire-protocol fields and their golden-vector tests. Throw the branch away; the point is to harden the script.
+2. **Transfer the repository**, which redirects the old URLs.
+3. **Run the sweep with `--apply`** on a branch off the renamed repo, as one auditable commit, and read the diff in full.
+4. **Flip the identity set together** in that same commit: binary name, asset names, the manifest `name` and `home_assistant_domain`, the docs domain, and the device prefix if it changes.
+5. **Cut v6.0.0**, verify OTA from a v5 device on the bench, and document the upgrade as Backup on v5 then Restore on v6.
+6. **Hand-edit `moondeck/moondeck.json`**, which is gitignored and so outside the sweep.
+
+`namespace mm::` stays. It is not the product name, and renaming it would touch every file for nothing.
+
+## The cutover
+
+Dates are the product owner's. The two fixed points are Windows day and release day, and the rest hangs off them.
+
+### Before: v5.0.0 ships (date to set)
+
+v5.0.0 has to exist before anything downstream matters, because it is the release the installed base updates *from* and the only one that can carry them across the move. Its three items are listed above. **Cut it far enough ahead that a tester can run it for a few days**, since a defect found after the rename is a defect in two releases at once.
+
+### Sept 29: Windows day
+
+The desktop build runs on Windows and is packaged by CI, but **no test has ever run there**. [release.yml:318](../../../.github/workflows/release.yml) has the only Windows runner in the repository and it compiles and packages without invoking `mm_tests`, the scenarios, or anything else. Every item below is therefore unverified rather than lightly verified.
+
+Four features are a genuinely different program on Windows rather than a thin shim, and they come first:
+
+1. **Raw Ethernet output** (the L2 panel driver). Loads Npcap's `wpcap.dll` at runtime and batches through `pcap_sendqueue`, where POSIX opens a raw socket and sends per packet. Needs Npcap installed. The struct layouts must match the installed Npcap exactly.
+2. **The Ethernet interface picker.** Enumerates through `GetIfTable2` and matches GUIDs against pcap names. A Hyper-V switch or a VPN adapter can empty the list or report another adapter's link speed.
+3. **RTSP and HLS video out.** `CreateProcessA` takes a hand-built command string where POSIX passes an argv array, and the stop path is `TerminateProcess` plus `CancelIoEx` rather than a signal and a pipe EOF. Both were written this week and neither has run. Test that the stream plays, and that changing the layout while it plays does not hang the app.
+4. **NDI.** Resolves `Processing.NDI.Lib.x64.dll` off the PATH the NDI installer sets, so a missing runtime reports "not installed" rather than failing loudly.
+
+Then the JIT, which fails as a crash or as wrong pixels rather than an error:
+
+5. **MoonLive scripts.** The x86-64 backend emits for the **Win64 ABI**, a different register assignment and a 32-byte shadow space, with its own hand-assembled blobs and patch offsets. Open a script and watch it render. Executable memory comes from `VirtualAlloc` with `PAGE_EXECUTE_READWRITE`, which antivirus or a hardened policy can refuse outright, taking all scripting with it.
+
+Then the things a user meets on day one:
+
+6. **The installer.** Install, launch from the Start menu, upgrade over a running instance (NSIS runs `taskkill` first), and uninstall.
+7. **Saving configuration.** Windows gets a plain `fopen` with no owner-only ACL, and `std::filesystem::rename` over an open file fails where POSIX replaces it, so a save can fail silently. Save a preset twice.
+8. **Port binding.** `SO_REUSEADDR` is deliberately omitted because on Windows it means "steal the port", so a second instance behaves the opposite way from macOS. Start two and bind DDP twice.
+9. **Audio input.** miniaudio switches to WASAPI, so the device list, the default entry and whether loopback works are all Windows-specific.
+10. **Serial ports and flashing.** The dropdown reads `SERIALCOMM` from the registry, and [_idf_win_shim.py](../../../moondeck/build/_idf_win_shim.py) forces a UTF-8 locale because idf.py refuses to start under cp1252, which only a non-English Windows reproduces.
+11. **The web installer.** Its documented DTR/RTS reset bug is worse on Windows 11, and the Windows-only hint rows are user-agent gated, so confirm they appear.
+
+Smaller checks to make while the above runs: the browser opens on first launch, HTTPS reaches the cloud through WinHTTP and the Windows certificate store, and the crash log's timestamp is not garbled, since `localtime_s` takes its arguments in the opposite order to `localtime_r`.
+
+**Anything found here is fixed before the rename**, not after: a Windows defect discovered in v6.0.0 costs a patch release under a brand-new name.
+
+### Sept 30: MoonLight v6.0.0
+
+One day, in order, with a stop at each gate:
+
+1. **Dry-run the sweep on a throwaway branch** and run the full gate set: every ESP32 variant, the tests, the scenarios, `check_devices`, `check_specs`. Fix what it gets wrong, particularly the fixed-length wire-protocol fields and their golden vectors. Discard the branch.
+2. **Back up a configured v5.0.0 device** and keep the bundle. This is the evidence for the migration claim, and it has to be taken before anything moves.
+3. **Transfer the repository**, which leaves the old URLs redirecting.
+4. **Run the sweep with `--apply`** on a branch off the renamed repo, read the diff in full, commit it as one change.
+5. **Flip the identity set in that same commit**: binary name, release asset names, the manifest `name` and `home_assistant_domain`, the docs domain, and the `MM-` prefix if it changes.
+6. **Run the full gate set again** on the swept tree, then tag and release v6.0.0.
+7. **Verify the two claims**: a v5.0.0 device finds and installs v6.0.0 over OTA, and the backup from step 2 restores onto it with layouts, effects and scripts intact.
+8. **Hand-edit `moondeck/moondeck.json`**, which is gitignored and outside the sweep.
+
+If step 7 fails, the release stays and the fix is a v6.0.1: the repository has already moved by then, so rolling back is not on the table. That is why steps 1 and 2 happen first.
+
+### After: the week following
+
+Watch for what only real users hit: OTA from versions older than v5.0.0, the documentation redirect, and a mixed network where someone has not updated both devices.
+
+## After v6.0.0
+
+**Wired DMX-512, in and out.** Absent from `src/light/drivers/` entirely, and the largest remaining capability gap. It needs a transceiver board, so it carries the longest lead time of anything here, and it gates nothing about the rename: a driver added under the new name costs exactly what it would have cost under the old one. Deferring it is what keeps v5.0.0 small enough to cut.
+
+Also here: Ants, Spiral Fire, LightsControl, the IMU, and the per-band onset and BPM work. None of it blocks the rename, and none is easier before it.
+
+## Decisions already on record
+
+### Improvements over the predecessor
+
+The migration mandate was fidelity, so every deliberate divergence was registered rather than left as drift. Product-owner ruling, 2026-07-01: improvements that increase user satisfaction are allowed.
+
+| Effect or primitive | Predecessor behavior | Ours | Why it is better |
+|---|---|---|---|
+| `math8::map8` | `lo + scale8(in, hi-lo)`, so the input top never reaches `hi` and a one-step span collapses to 0 | `lo + in*(hi-lo)/255`, reaching `hi` exactly | Audio bars reach full height, and a 1-row bar becomes possible. Matches FastLED's documented `map8` |
+| FreqSaws | Each band's physics advanced once per **column**, so a band spanning K columns ran K times too fast | Each of the 16 bands integrates once per frame | Speed no longer depends on panel width: identical on a 32-wide and a 256-wide grid |
+| SphereMove | An integer divide meant the shell only advanced on whole ticks, about 20 updates a second at 60 fps | The expression stays in float | Smooth motion at all speeds. The predecessor intended float here, so this is also more faithful |
+| Lissajous | A 1-wide or 1-tall grid mapped every sample to coordinate 1, which clips, so nothing drew | The size-1 axis maps to coordinate 0 | Visible output on thin grids; normal grids unchanged |
+| PaintBrush | Oscillator endpoints truncated into `uint8_t`, so grids past 256 per axis swept only a low corner | Oscillators generate 0..255 then scale to the grid | Strokes span any grid and use the full palette range. Grids up to 256 per axis are pixel-identical |
+| FixedRectangle | On RGBW the W channel was written on every box cell, tinting colored tiles and leaving W stale | W follows the checker, cleared to 0 on colored tiles | Colored tiles render as pure RGB, and the checker actually alternates |
+| GEQ3D sweep | A per-frame counter, so the sweep tracked frame rate and ran faster on a quicker board | A time-based triangle wave | `speed` means the same on every device, which is the projectMM convention |
+| GEQ3D bars | Bar width `cols / NUM_BANDS` truncates to 0 when columns are fewer than bands, piling every bar at x=0 | The drawn band count is clamped to the column count | Bars render on narrow grids; a no-op on normal ones |
+| AudioFrame | One level value, where WLED exposes both instant and smoothed | Added `levelSmoothed`, an EMA beside the raw `level` | Effects that should glide no longer jitter per audio block, and beat-reactive ones stay snappy |
+
+Invisible fixes, listed for the record rather than as behavior: overflow guards on huge grids in GEQ, StarSky and PaintBrush; the Tetrix 49-day `millis` wrap; a GoL 3D out-of-bounds read; RubiksCube float to int, which is pixel-identical. StarField's blur control was flagged as inverted and turned out to match the predecessor, so only its comment changed.
+
+### Fidelity tensions
+
+Four of six are settled. The two open ones are listed under v5.0.0 above, and both are bench questions rather than code:
+
+- **The audio level scale.** The predecessor normalizes `volume` to 0..1 where ours is a 0..255 `level`. A real INMP441 cross-check against the synthetic reference settles whether any effect reads differently.
+- **Reconstructed logic.** Where the predecessor's source was incomplete, the behavior was reconstructed: the Tetrix fall cadence, the FreqMatrix scroll, Blurz dot placement, the FreqSaws band response, the GEQ peak fall, the NoiseMeter drift. A bench pass confirms each looks right. Only three `RECONSTRUCTED` markers survive in the tree, all in layouts, so the effect-side markers are gone and this is a visual check rather than a grep.
+
+The accepted-as-is entry: `scale8` against integer `*bri/255` rounding in SolidEffect and elsewhere, kept faithful, no change wanted.
+
+## Verification
+
+1. A device running the current release upgrades to v5.0.0 and keeps its configuration, layouts and scripts, with only the [MIGRATING](../../reference/MIGRATING.md) entries behaving differently.
+2. A v5.0.0 device on the bench updates itself to a v6.0.0 release after the repository has moved. This is the gate the v5 release exists for, and it can only be tested once both exist.
+3. The sweep's diff is read in full before it is committed, since a blanket replace is how a symbol gets renamed by accident.
+4. The full gate set passes on the swept tree: every ESP32 variant, the tests, the scenarios, `check_devices`, `check_specs`.
+5. `moonmodules.org/projectMM` redirects to the new documentation site.
+6. A v5.0.0 backup restores onto a v6.0.0 device with its layouts, effects and scripts intact, which is the claim that replaces the erase.
diff --git a/docs/work/present/Plan-20260922 - RTSP video out on the P4 and the desktop.md b/docs/work/present/Plan-20260922 - RTSP video out on the P4 and the desktop.md
new file mode 100644
index 00000000..f007ba34
--- /dev/null
+++ b/docs/work/present/Plan-20260922 - RTSP video out on the P4 and the desktop.md
@@ -0,0 +1,70 @@
+# Plan: RTSP video out on the P4
+
+## Why
+
+HLS delays a stream by seconds: it ships whole segments, and a player buffers several before it starts. RTSP carries each frame as it leaves the encoder, so a viewer sees it far sooner. The factor is what verification step 3 measures.
+
+## What already exists
+
+The P4 encoder path is most of the work, and it is built:
+
+- `platform_esp32_h264.cpp` drives the hardware encoder and holds each frame's **raw H.264 NAL units** in `nal_`, with a keyframe flag and a 90 kHz PTS already computed, before any muxing.
+- `platform::UdpSocket` is the wire ArtNet and DDP send on, proven at frame rates.
+- `platform::TcpServer` and `TcpConnection` serve the web UI and MQTT.
+
+RTSP therefore reuses the encoder and both sockets, and adds the packetisation and the session protocol.
+
+## Scope of the first version
+
+**P4 only.** `hasRtsp` is true on the P4, so the driver compiles in there and its card appears there.
+
+**UDP/RTP only.** Interleaved TCP arrives when a network blocks the negotiated port pair, which is the trigger that earns the second path.
+
+## Steps
+
+### 1. A platform seam that hands out frames
+
+A narrow seam beside `EncoderConfig`:
+
+- `rtspFrameReady()` reports whether a fresh encoded frame is waiting.
+- `rtspTakeFrame(const uint8_t** nal, size_t* len, uint32_t* pts90, bool* keyframe)` hands out the frame the encoder just produced, by pointer.
+
+Both are gated by `hasRtsp`. On the P4 they read the same `nal_` buffer the muxer reads, so one encode feeds HLS and RTSP together.
+
+### 2. RTP packetisation, RFC 6184
+
+One NAL becomes one RTP packet while it fits the MTU, and an oversized NAL becomes a run of FU-A fragments. The header carries the sequence number, the 90 kHz timestamp the encoder already computed, and the marker bit on a frame's last packet.
+
+Each keyframe is preceded by its SPS and PPS, so a client joining mid-stream decodes from the next keyframe onward.
+
+### 3. The RTSP control server
+
+Four verbs on a TCP listener, RFC 2326: `OPTIONS`, `DESCRIBE` (answering SDP that names H.264 and the profile), `SETUP` (negotiating the client's RTP port pair), `PLAY` and `TEARDOWN`. One session at a time.
+
+### 4. The driver
+
+`RtspDriver.h` beside `HlsDriver.h`, reusing `DriverBase`'s correction and window blocks. Controls: `targetFps` and `scale`, mirroring HLS, plus a read-only `url` a viewer copies into VLC or ffplay, and a status line naming the connected client.
+
+### 5. Tests
+
+- RTP packetisation is pure data: a NAL under the MTU makes one packet, an oversized one fragments and reassembles, and the marker bit lands on the last packet of a frame.
+- The RTSP verb sequence, driven against a fake connection: `SETUP` precedes `PLAY` for a session to start, and `TEARDOWN` releases it.
+- A scenario covering the driver's lifecycle, as the HLS driver has.
+
+### 6. Documentation
+
+A card on the drivers page saying which boards carry it and what to point at it, a `## RTSP, details` section for the transport choice, and a line in the FAQ's "The HLS stream lags by seconds" entry naming RTSP as the lower-latency remote view.
+
+## Verification
+
+1. Host tests for the packetiser and the verb sequence.
+2. `ffplay rtsp:///` on the bench P4, showing the wall.
+3. **The number that justifies this**: glass-to-glass delay measured the same way for HLS and RTSP, on the same P4 and the same content, photographed side by side. No factor is claimed anywhere until this is measured, and this plan is realized when it is.
+4. A second viewer connecting takes the session over, and the displaced one sees its connection close. This replaces the refusal the plan first called for: a player that vanishes without TEARDOWN leaves a socket open and silent, so refusing new arrivals strands the stream for as long as TCP takes to notice.
+5. The render tick holds while streaming: the encode already happens for HLS, and packetisation stays off the render thread.
+
+## Risks
+
+- **The send path runs on a nonblocking tick**, at RTP's packet rate. Where the send falls behind it drops whole frames at the source and the client sees fewer, the rule [the preview transport](../../moonmodules/light/drivers.md#preview) already follows.
+- **UDP wants a reachable port pair.** A network that blocks it leaves the stream silent, which reads as a fault. The status line says a client is connected and how many packets have left the device, so the difference is visible.
+- **PSRAM.** The NAL buffer is 128 KB today and shared with the muxer, so a second reader releases it within the encoder's frame.
diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py
index 39f4e174..1e5f9b22 100644
--- a/moondeck/build/build_esp32.py
+++ b/moondeck/build/build_esp32.py
@@ -542,10 +542,10 @@ def firmware_cmake_args(firmware: str, release: str = "", version: str = "",
if version:
args.append(f'-DMM_VERSION="{version}"')
# And into the IMAGE's app descriptor, the struct IDF puts in every binary. Without it the
- # descriptor keeps IDF's `git describe` fallback, which drifts from what the device reports
- # (a stale tag read "container-test-1-g73e52cb9-dirt" long after that tag was gone). MoonBase
- # carries the same string, so the app can compare the two images by equality and say when its
- # recovery image was built apart from it.
+ # descriptor keeps IDF's `git describe` fallback, which drifts from what the device reports:
+ # describe names the nearest tag, so a one-off tag left in the repo kept surfacing in built
+ # images long after it stopped meaning anything. MoonBase carries the same string, so the app
+ # can compare the two images by equality and say when its recovery image was built apart.
args.append(f"-DPROJECT_VER={version or compute_version.compute('local', '')}")
if spec["eth_only"]:
# Drop the WiFi components from the link, and tell our code to compile
diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py
index a6547cd4..33de9e4a 100644
--- a/moondeck/docs/screenshot_modules.py
+++ b/moondeck/docs/screenshot_modules.py
@@ -142,6 +142,7 @@ def asset_dir_for(type_name: str) -> Path:
("Hub75Driver", "Drivers", {}, False),
("NdiDriver", "Drivers", {}, False),
("HlsDriver", "Drivers", {}, False),
+ ("RtspDriver", "Drivers", {}, False),
]
# A modifier reshapes what an effect draws, so it has nothing to show on an empty Layer: it
diff --git a/moondeck/scenario/run_scenario.py b/moondeck/scenario/run_scenario.py
index 77fb4aa6..cb8fd720 100644
--- a/moondeck/scenario/run_scenario.py
+++ b/moondeck/scenario/run_scenario.py
@@ -61,6 +61,10 @@ def _resolve_runner() -> Path:
# test/ made every unit-test edit report the runner stale, and rebuilding did not clear it
# because CMake correctly relinks nothing: a false alarm that trains people to ignore the guard.
_RUNNER_SOURCE_DIRS = ("src",)
+# src/platform/esp32 is in the tree but NOT in this target: the desktop runner links mm_platform's
+# desktop half, so an ESP32 edit relinks nothing and rebuilding can never clear the warning. Left in
+# scope it wedges the gate permanently, which is the same false alarm the note above is about.
+_RUNNER_SKIP_DIRS = ("src/platform/esp32",)
_RUNNER_SOURCE_FILES = ("test/scenario_runner.cpp",)
_RUNNER_SOURCE_SUFFIXES = {".c", ".cpp", ".h", ".hpp"}
_RUNNER_SKIP_PARTS = {"build", "__pycache__", ".git"}
@@ -105,6 +109,7 @@ def _stale_runner_reason() -> str:
candidates.extend(f for f in (ROOT / d).rglob("*")
if f.is_file() and f.suffix in _RUNNER_SOURCE_SUFFIXES
and not (_RUNNER_SKIP_PARTS & set(f.relative_to(ROOT).parts))
+ and not f.relative_to(ROOT).as_posix().startswith(_RUNNER_SKIP_DIRS)
and f.relative_to(ROOT).as_posix() not in _RUNNER_GENERATED)
candidates.extend(ROOT / f for f in _RUNNER_SOURCE_FILES if (ROOT / f).is_file())
for f in candidates:
diff --git a/src/core/system/FirmwareUpdateModule.h b/src/core/system/FirmwareUpdateModule.h
index 3bb2ddf4..0c09adae 100644
--- a/src/core/system/FirmwareUpdateModule.h
+++ b/src/core/system/FirmwareUpdateModule.h
@@ -17,9 +17,33 @@
/// The status and the byte counters are inline globals rather than module state.
/// The flash route and the platform's own task both write them, and both must see one instance.
/// A module reading them reports the same install a socket handler started.
+///
+/// ## Two addresses, because a rename has to survive in the field
+///
+/// A device flashed before a rename asks its old repository forever, and GitHub's rename redirect is normally what carries it across.
+/// That redirect is the only thing making an in-field update survive the move.
+/// This rename vacates a name and re-takes it in one session, which is worth a belt as well as braces.
+/// So the update path names both addresses and takes whichever answers.
+/// The successor comes first deliberately: once it exists every device reaches it directly, and the redirect stops mattering rather than being depended on forever.
+/// Before it exists that request costs one 404, since the predecessor occupying the name publishes no `firmware-*` asset.
+/// Fetching another project's firmware is prevented separately.
+/// The OTA compares an incoming image's own ESP-IDF descriptor against `kProjectImageName` before a byte is written, so an address answering with a stranger's release is refused rather than flashed.
namespace mm {
+/// Where this project's releases will live, tried FIRST so a renamed repository needs no redirect.
+constexpr const char* kReleaseRepo = "MoonModules/MoonLight";
+
+/// Where they live today, tried where the address above does not answer.
+constexpr const char* kFallbackRepo = "MoonModules/projectMM";
+
+/// The release-asset URL a device updates itself from: repository, version, firmware variant, version.
+constexpr const char* kReleaseAssetUrlFormat =
+ "https://github.com/%s/releases/download/v%s/firmware-%s-v%s.bin";
+
+/// The name this project's app image carries in its ESP-IDF descriptor, which is the CMake `project()` name.
+constexpr const char* kProjectImageName = "projectMM";
+
inline char g_otaStatus[64] = "idle"; ///< the phase the install is in, shared by every unit
inline uint32_t g_otaBytesRead = 0; ///< how much has been written
inline uint32_t g_otaBytesTotal = 0; ///< the image size, zero until it is known
diff --git a/src/core/system/MqttModule.cpp b/src/core/system/MqttModule.cpp
index 4bb55e64..14edb123 100644
--- a/src/core/system/MqttModule.cpp
+++ b/src/core/system/MqttModule.cpp
@@ -280,11 +280,12 @@ void MqttModule::publishUpdateState() {
char topic[128];
buildTopic(topic, sizeof(topic), "update/state");
char payload[256];
+ // RETAINED on the broker and read by a person, so it names where releases will live: a card left behind by a rename is a dead link.
const int pn = std::snprintf(payload, sizeof(payload),
"{\"installed_version\":\"%s\",\"latest_version\":\"%s\","
- "\"release_url\":\"https://github.com/MoonModules/projectMM/releases\","
- "\"title\":\"projectMM firmware\"}",
- kVersion, kVersion);
+ "\"release_url\":\"https://github.com/%s/releases\","
+ "\"title\":\"%s firmware\"}",
+ kVersion, kVersion, kReleaseRepo, kProjectImageName);
if (pn <= 0 || static_cast(pn) >= sizeof(payload)) return;
uint8_t buf[kSendBufLen];
const size_t n = buildMqttPublish(topic, reinterpret_cast(payload),
@@ -303,7 +304,7 @@ void MqttModule::subscribeUpdateSet() {
// HA's install command.
// The payload is the target version string (via HA's payload_install_template, defaults to `{{ latest_version }}`); an empty payload means "install latest".
-// The device builds the download URL from the projectMM release-artifact convention: https://github.com/MoonModules/projectMM/releases/download/v/firmware--v.bin and hands it to platform::http_fetch_to_ota, the same OTA path POST /api/firmware/url takes.
+// The device builds the download URL from the release-artifact convention (`kReleaseAssetUrlFormat`) and hands it to platform::http_fetch_to_ota, the same OTA path POST /api/firmware/url takes, naming both repositories so a rename cannot strand it.
// Guarded by otaInFlight() so a second install command mid-flash returns silently rather than corrupting the running OTA task.
// On desktop platform::http_fetch_to_ota is a stub returning false; the install command safely reports failure via g_otaStatus.
void MqttModule::handleUpdateInstall(const char* payload, size_t payloadLen) {
@@ -320,10 +321,14 @@ void MqttModule::handleUpdateInstall(const char* payload, size_t payloadLen) {
if (v[0] == '\0') v = (kVersion[0] == 'v') ? kVersion + 1 : kVersion;
char url[256];
- const int un = std::snprintf(url, sizeof(url),
- "https://github.com/MoonModules/projectMM/releases/download/v%s/firmware-%s-v%s.bin",
- v, kFirmwareName, v);
+ const int un = std::snprintf(url, sizeof(url), kReleaseAssetUrlFormat,
+ kReleaseRepo, v, kFirmwareName, v);
if (un <= 0 || static_cast(un) >= sizeof(url)) return;
+ // Today's repository, tried where the address above does not answer (FirmwareUpdateModule names why both).
+ char altUrl[256];
+ const int an = std::snprintf(altUrl, sizeof(altUrl), kReleaseAssetUrlFormat,
+ kFallbackRepo, v, kFirmwareName, v);
+ const bool haveAlt = an > 0 && static_cast(an) < sizeof(altUrl);
// Seed the shared globals so the first WS push shows "starting" rather than a stale string from a prior URL-triggered OTA, same seed the HTTP path does.
std::snprintf(g_otaStatus, sizeof(g_otaStatus), "starting");
@@ -331,7 +336,8 @@ void MqttModule::handleUpdateInstall(const char* payload, size_t payloadLen) {
g_otaBytesTotal = 0;
(void)platform::http_fetch_to_ota(url, g_otaStatus, sizeof(g_otaStatus),
- &g_otaBytesRead, &g_otaBytesTotal);
+ &g_otaBytesRead, &g_otaBytesTotal,
+ haveAlt ? altUrl : nullptr);
// No response to publish, HA polls the retained update/state (which the OTA success path implicitly renegotiates on reboot, or a future release-check refreshes).
}
diff --git a/src/core/util/H264Bitstream.h b/src/core/util/H264Bitstream.h
new file mode 100644
index 00000000..83bf6720
--- /dev/null
+++ b/src/core/util/H264Bitstream.h
@@ -0,0 +1,80 @@
+#pragma once
+/// Reading an Annex B H.264 stream: where its NAL units and its frames begin.
+/// Domain-neutral, because both the light domain's RTP packetiser and the platform layer's encoder reader answer the same question of the same bytes.
+/// @moreinfo
+/// ## Where one frame ends and the next begins
+/// An Annex B stream carries no frame delimiter, so the boundary is inferred from the slice header.
+/// `first_mb_in_slice` is the first exp-Golomb value in it, and a leading 1 bit encodes zero, which marks the first slice of a picture.
+/// A picture split into several slices therefore opens once, at the slice sitting at macroblock zero.
+/// Parameter sets and SEI precede the frame they describe, so they ride with the frame that follows rather than closing the one before.
+/// An access unit therefore starts at the first of those introducing NALs rather than at the slice, and a decoder receives the sets that describe a picture together with it.
+
+#include
+#include
+#include
+
+namespace mm::h264 {
+
+/// No offset, since zero is a valid one.
+static constexpr size_t kNoOffset = static_cast(-1);
+
+/// The bytes of Annex B start code at `p`, 3 or 4, or 0 where a NAL begins elsewhere.
+inline size_t startCodeLen(const uint8_t* p, size_t len) {
+ if (len >= 4 && p[0] == 0 && p[1] == 0 && p[2] == 0 && p[3] == 1) return 4;
+ if (len >= 3 && p[0] == 0 && p[1] == 0 && p[2] == 1) return 3;
+ return 0;
+}
+
+/// True where the NAL header at `nal` opens a new access unit: a VCL slice whose first macroblock is zero.
+inline bool opensAccessUnit(const uint8_t* nal, size_t len) {
+ if (!nal || len < 2) return false;
+ const uint8_t type = static_cast(nal[0] & 0x1F);
+ if (type != 1 && type != 5) return false; // a non-VCL NAL never opens a frame
+ return (nal[1] & 0x80) != 0; // first_mb_in_slice == 0
+}
+
+/// True for a NAL that introduces the frame after it: a parameter set, SEI, or access unit delimiter.
+inline bool precedesAccessUnit(const uint8_t* nal, size_t len) {
+ if (!nal || len < 1) return false;
+ const uint8_t type = static_cast(nal[0] & 0x1F);
+ return type == 6 || type == 7 || type == 8 || type == 9; // SEI, SPS, PPS, AUD
+}
+
+/// Every access unit start in an Annex B buffer, appended to `starts` in order, the last marking the tail still arriving.
+inline void findAccessUnits(const uint8_t* buf, size_t len, std::vector* starts) {
+ if (!buf || !starts) return;
+ size_t scan = 0;
+ size_t leading = kNoOffset; // the first introducing NAL since the last frame began
+ while (scan + 3 < len) {
+ const size_t sc = startCodeLen(buf + scan, len - scan);
+ if (sc == 0) { scan++; continue; }
+ const size_t hdr = scan + sc;
+ if (hdr + 1 >= len) break; // the slice header has yet to arrive
+ if (precedesAccessUnit(buf + hdr, len - hdr)) {
+ if (leading == kNoOffset) leading = scan; // the frame starts HERE, not at its slice
+ } else if (opensAccessUnit(buf + hdr, len - hdr)) {
+ starts->push_back(leading == kNoOffset ? scan : leading);
+ leading = kNoOffset;
+ } else {
+ leading = kNoOffset; // a non-VCL NAL that introduces nothing breaks the run
+ }
+ scan = hdr + 1;
+ }
+}
+
+/// True where the access unit at `buf` carries an IDR, which a client decodes from.
+inline bool hasKeyframe(const uint8_t* buf, size_t len) {
+ if (!buf) return false;
+ size_t scan = 0;
+ while (scan + 3 < len) {
+ const size_t sc = startCodeLen(buf + scan, len - scan);
+ if (sc == 0) { scan++; continue; }
+ const size_t hdr = scan + sc;
+ if (hdr >= len) break;
+ if ((buf[hdr] & 0x1F) == 5) return true;
+ scan = hdr + 1;
+ }
+ return false;
+}
+
+} // namespace mm::h264
diff --git a/src/light/drivers/HlsDriver.h b/src/light/drivers/HlsDriver.h
index aeacfa7b..ec8ffd70 100644
--- a/src/light/drivers/HlsDriver.h
+++ b/src/light/drivers/HlsDriver.h
@@ -33,6 +33,9 @@ namespace mm {
/// @card HlsDriver.png
class HlsDriver : public DriverBase {
public:
+ /// A destroyed driver releases the encoder, since nothing else can: a claim outliving its owner would refuse every later driver, and the next one can even land on this address.
+ ~HlsDriver() override { platform::encoderRelease(this); }
+
/// The catalog tag this driver carries.
static constexpr const char* kTags = "🖥️";
/// Where the encoder's segments are written, under the filesystem mount.
@@ -139,7 +142,7 @@ class HlsDriver : public DriverBase {
/// Stop the encoder and drop the segments it wrote.
void release() override {
if (open_) {
- platform::encoderStop();
+ platform::encoderRelease(this); // stops it, and only where this driver holds the claim
open_ = false;
if constexpr (platform::hasFsSegments) clearSegments(); // transient; nothing to keep
}
@@ -318,7 +321,13 @@ class HlsDriver : public DriverBase {
cfg.bitrateKbit = autoBitrateKbit();
cfg.encoderName = kEncoderOptions[encoderSel_ < kEncoderOptionCount ? encoderSel_ : 0];
cfg.outDir = outDir;
+ // One encoder, one claimant: a second driver is refused rather than silently reconfiguring this one's stream.
+ if (!platform::encoderClaim(this)) {
+ setStatus("the video encoder is in use by another driver", Severity::Warning);
+ return false;
+ }
if (!platform::encoderStart(cfg)) {
+ platform::encoderRelease(this);
// Why it failed differs per platform, and a wrong reason sends the user hunting.
if constexpr (platform::hasEncoderChoice) {
setStatus("ffmpeg not found - see the docs", Severity::Warning);
diff --git a/src/light/drivers/RtspDriver.h b/src/light/drivers/RtspDriver.h
new file mode 100644
index 00000000..005b35e1
--- /dev/null
+++ b/src/light/drivers/RtspDriver.h
@@ -0,0 +1,361 @@
+#pragma once
+#include "core/module/Control.h"
+#include "core/util/ScratchBuffer.h"
+#include "light/drivers/DriverBase.h"
+#include "light/util/RtpH264.h"
+#include "light/util/RtspSession.h"
+#include "platform/platform.h"
+
+#include
+#include
+#include // move: the accepted connection becomes the viewer
+
+namespace mm {
+
+/// Output driver: serves the rendered frame as an H.264 stream a player pulls over RTSP, which arrives with a fraction of the delay HLS carries.
+///
+/// HLS ships whole segments and a player buffers several before it starts, so a viewer sees seconds ago. RTSP sends each frame as the encoder produces it, which puts a viewer far closer to live.
+///
+/// Prior art: RTSP is RFC 2326, its RTP payload format for H.264 is RFC 6184, and the hardware encoder is the one HLS already drives.
+///
+/// @moreinfo
+///
+/// ## One encoder, one driver at a time
+///
+/// HLS muxes the encoder's output into segments where this packetises the same NALs into RTP, so the two read the same shape of frame.
+/// They cannot run together: there is one encoder instance, and a second `encoderStart` would silently reconfigure the first driver's stream.
+/// So the encoder is claimed, and whichever driver starts second reports that it is in use.
+/// Sharing one encode between both readers is the better end state, filed in the backlog.
+/// It needs the two drivers to agree on geometry, rate and bitrate, which nothing makes them do today.
+///
+/// ## The newest viewer is the viewer
+///
+/// Each viewer costs another send on a device that is also driving lights, so one session plays at a time and a new connection takes it over.
+/// A player that vanishes without TEARDOWN leaves a socket open and silent, since TCP reports a peer's absence only to a write it stops acknowledging.
+/// Waiting on that would strand the stream for minutes, so someone asking to watch now outranks an old socket's silence.
+/// The displaced viewer sees its connection close, which every player reports.
+///
+/// ## Where it runs
+///
+/// The encoder is the P4's in hardware and the desktop's ffmpeg, so `hasRtsp` is true on both. Every other board reaches viewers through the preview driver, which sends raw pixels and no codec.
+/// @card RtspDriver.png
+class RtspDriver : public DriverBase {
+public:
+ /// A destroyed driver releases the encoder, since nothing else can: a claim outliving its owner would refuse every later driver, and the next one can even land on this address.
+ ~RtspDriver() override { platform::encoderRelease(this); }
+
+ /// The catalog tag this driver carries.
+ static constexpr const char* kTags = "🖥️";
+
+ /// Bind the scratch buffers to this module, so their memory is accounted for.
+ RtspDriver() : rgb_(*this), corrScratch_(*this) {}
+
+ /// The catalog tags shown on this driver's card.
+ const char* tags() const override { return kTags; }
+
+ /// Point the driver at the shared source buffer.
+ void setSourceBuffer(Buffer* buf) override { sourceBuffer_ = buf; }
+
+ /// Bind the frame rate, the scale, and the address a player is pointed at.
+ void defineDriverControls() override {
+ controls_.addControl("targetFps", targetFps, 1, 60);
+ controls_.addControl("scale", scale, 0, kMaxScale);
+ /// The address to paste into VLC or ffplay, carrying the device's own IP.
+ controls_.addReadOnly("url", urlBuf_, sizeof(urlBuf_));
+ }
+
+ /// Which controls need a fresh encode, the encoder fixing its rate at spawn.
+ bool affectsPrepare(const char* name) const override {
+ return std::strcmp(name, "targetFps") == 0 || std::strcmp(name, "scale") == 0 ||
+ isCorrectionControl(name);
+ }
+
+ /// Derive the scaled geometry, start the encoder, and listen for a client.
+ void prepare() override {
+ release();
+ if constexpr (!platform::hasRtsp) {
+ setStatus("this board has no hardware H.264 encoder", Severity::Warning);
+ return;
+ }
+ if (!layer_) return;
+
+ srcWidth_ = layer_->physicalWidth() > 0 ? layer_->physicalWidth() : 1;
+ srcHeight_ = layer_->physicalHeight() > 0 ? layer_->physicalHeight() : 1;
+ scale_ = scale ? scale : autoScale();
+ uint32_t scaledW = static_cast(srcWidth_) * scale_;
+ uint32_t scaledH = static_cast(srcHeight_) * scale_;
+ // Chroma is sampled in 2x2 blocks, so the hardware encoder takes even dimensions only.
+ if ((scaledW & 1u) || (scaledH & 1u)) {
+ scale_ = static_cast(scale_ * 2);
+ scaledW *= 2;
+ scaledH *= 2;
+ }
+ width_ = static_cast(scaledW);
+ height_ = static_cast(scaledH);
+
+ const size_t pixels = static_cast(width_) * height_;
+ if (!rgb_.resize(pixels * 3)) {
+ setStatus("out of memory for the video frame", Severity::Error);
+ return;
+ }
+ if (correction_.outChannels > 3) corrScratch_.resize(correction_.outChannels);
+
+ platform::EncoderConfig cfg{};
+ cfg.width = static_cast(width_);
+ cfg.height = static_cast(height_);
+ cfg.fps = targetFps;
+ cfg.bitrateKbit = bitrateFor(pixels, targetFps);
+ cfg.encoderName = nullptr;
+ cfg.outDir = nullptr; // RTSP takes the frames rather than a directory of them
+ // Claimed before it is configured, since the other video driver's stream would otherwise continue at this one's geometry.
+ if (!platform::encoderClaim(this)) {
+ setStatus("the video encoder is in use by another driver", Severity::Error);
+ return;
+ }
+ if (!platform::encoderStart(cfg)) {
+ platform::encoderRelease(this);
+ setStatus("the encoder refused to start", Severity::Error);
+ return;
+ }
+ open_ = true;
+
+ // release() first: the encoder is already running here, and a driver left so holds an encode task nothing reads.
+ if (!control_.open(mm::rtsp::kPort)) {
+ release();
+ setStatus("port 554 is already in use", Severity::Error);
+ return;
+ }
+ if (!rtpOut_.open()) {
+ release();
+ setStatus("no socket for the video stream", Severity::Error);
+ return;
+ }
+ // The host is EMPTY for the reader to fill from the address it reached the device by.
+ std::snprintf(urlBuf_, sizeof(urlBuf_), "rtsp://:%u/",
+ static_cast(mm::rtsp::kPort));
+ sendEpochMs_ = platform::millis();
+ nextSendMs_ = sendEpochMs_;
+ frameIndex_ = 0;
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "ready at %ux%u, waiting for a viewer",
+ static_cast(width_), static_cast(height_));
+ setStatus(statusBuf_, Severity::Status);
+ }
+
+ /// Stop the encoder, drop the session and stop listening.
+ void release() override {
+ if (open_) {
+ platform::rtspReleaseFrame(); // a frame taken but never shipped, so the buffer is free
+ platform::encoderRelease(this); // stops it, and only where this driver holds the claim
+ open_ = false;
+ }
+ client_.close();
+ control_.close();
+ playing_ = false;
+ packetsSent_ = 0;
+ DriverBase::release();
+ }
+
+ void tick() MM_NONBLOCKING override {
+ if constexpr (!platform::hasRtsp) return;
+ if (!open_ || targetFps == 0 || !sourceBuffer_ || !sourceBuffer_->data()) return;
+
+ serveControl();
+ if (!playing_) return;
+
+ // A FIXED schedule, so the rate is exact at any fps and a late tick shifts nothing.
+ const uint32_t now = platform::millis();
+ 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)) {
+ sendEpochMs_ = now;
+ frameIndex_ = 1;
+ nextSendMs_ = now + periodMs;
+ }
+
+ feedEncoder();
+ shipEncodedFrame();
+ }
+
+private:
+ /// Read one request from the client and answer it, the newest arrival being the viewer.
+ void serveControl() MM_NONBLOCKING {
+ platform::TcpConnection fresh = control_.accept();
+ if (fresh.valid()) {
+ client_.close(); // the previous viewer, whether alive or long gone
+ client_ = std::move(fresh);
+ session_ = mm::rtsp::Session(platform::millis());
+ playing_ = false; // the new arrival negotiates from the start
+ reqLen_ = 0; // and its bytes never mix with the last viewer's
+ }
+ if (!client_.valid()) return;
+ // TCP is a STREAM: bytes accumulate until a blank line marks a whole request, and the surplus waits for the next tick.
+ const size_t room = sizeof(req_) - reqLen_ - 1;
+ if (room == 0) { dropClient(); return; } // no request is this long: the peer is confused
+ const int n = client_.read(reinterpret_cast(req_) + reqLen_, room);
+ if (n == 0) { dropClient(); return; } // the viewer closed
+ if (n < 0) return; // nothing pending this tick
+ reqLen_ += static_cast(n);
+ req_[reqLen_] = '\0';
+
+ // RFC 2326 ends a request's headers with a blank line; this server takes no bodied requests.
+ const char* end = std::strstr(req_, "\r\n\r\n");
+ if (!end) return; // still arriving
+ const size_t used = static_cast(end - req_) + 4;
+
+ mm::rtsp::Request parsed;
+ const bool ok = mm::rtsp::parseRequest(req_, used, &parsed);
+ // Consume it either way: a request this server cannot parse must not be re-parsed forever.
+ std::memmove(req_, req_ + used, reqLen_ - used);
+ reqLen_ -= used;
+ req_[reqLen_] = '\0';
+ if (!ok) return;
+ // 0.0.0.0 in the origin line: a client reads the address it connected to, which is what makes one SDP correct on every interface.
+ char sdp[512];
+ mm::rtsp::buildSdp(sdp, sizeof(sdp), "0.0.0.0", static_cast(width_),
+ static_cast(height_), targetFps, mm::rtp::kPayloadType);
+
+ char out[1024];
+ const size_t len = session_.respond(parsed, sdp, urlBuf_, out, sizeof(out));
+ if (len) client_.write(reinterpret_cast(out), len);
+
+ const bool nowPlaying = session_.state() == mm::rtsp::State::Playing;
+ if (nowPlaying && !playing_) {
+ rtp_ = mm::rtp::Packetiser(platform::millis(), 0);
+ // Where the packets go, read off the control socket: a client names a port it can receive on, and an address it cannot.
+ client_.peerIPv4(peerIp_);
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "streaming %ux%u at %u fps to a viewer",
+ static_cast(width_), static_cast(height_),
+ static_cast(targetFps));
+ setStatus(statusBuf_, Severity::Status);
+ }
+ playing_ = nowPlaying;
+ if (parsed.verb == mm::rtsp::Request::Verb::Teardown) dropClient();
+ }
+
+ /// Release the viewer and go back to waiting for one.
+ void dropClient() MM_NONBLOCKING {
+ client_.close();
+ playing_ = false;
+ reqLen_ = 0;
+ std::snprintf(statusBuf_, sizeof(statusBuf_), "ready at %ux%u, waiting for a viewer",
+ static_cast(width_), static_cast(height_));
+ setStatus(statusBuf_, Severity::Status);
+ }
+
+ /// Pack the corrected frame and hand it to the encoder, which never blocks the caller.
+ void feedEncoder() MM_NONBLOCKING {
+ 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;
+ // A buffer shorter than the layout would leave the rest of the frame holding the previous one.
+ if (n < lights) std::memset(rgb_.data(), 0, frameBytes);
+
+ const uint8_t* src = sourceBuffer_->data();
+ const uint8_t srcCh = sourceBuffer_->channelsPerLight();
+ const uint8_t outCh = correction_.outChannels;
+ if (srcCh < 3) return; // a non-color buffer (DMX roles) has no frame to send
+
+ uint8_t* dst = rgb_.data();
+ 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 rgb[3];
+ if (outCh == 3) {
+ correction_.apply(s, rgb, srcCh);
+ } else if (wide) {
+ uint8_t* c = corrScratch_.data();
+ correction_.apply(s, c, srcCh);
+ rgb[0] = c[0]; rgb[1] = c[1]; rgb[2] = c[2];
+ } else {
+ rgb[0] = s[0]; rgb[1] = s[1]; rgb[2] = s[2];
+ }
+ // One light becomes a scale x scale block, which replication keeps pixel-exact.
+ const size_t lx = (i % srcWidth_) * scale_, ly = (i / srcWidth_) * scale_;
+ for (uint8_t by = 0; by < scale_; by++) {
+ uint8_t* row = dst + (ly + by) * rowBytes + lx * 3;
+ for (uint8_t bx = 0; bx < scale_; bx++) {
+ row[bx * 3 + 0] = rgb[0];
+ row[bx * 3 + 1] = rgb[1];
+ row[bx * 3 + 2] = rgb[2];
+ }
+ }
+ }
+ platform::encoderWrite(dst, frameBytes);
+ }
+
+ /// Packetise whatever the encoder produced and send it to the viewer's RTP port.
+ void shipEncodedFrame() MM_NONBLOCKING {
+ platform::EncodedFrame frame{};
+ if (!platform::rtspTakeFrame(&frame)) return;
+ SendCtx ctx{&rtpOut_, peerIp_, session_.rtpPort(), &packetsSent_};
+ rtp_.writeAccessUnit(frame.nal, frame.len, frame.pts90,
+ packet_, sizeof(packet_), sendPacket, &ctx);
+ platform::rtspReleaseFrame(); // the bytes are on the wire: the encoder may reuse them
+ }
+
+ /// What one datagram needs to reach the viewer, handed to the packetiser's sink.
+ struct SendCtx { platform::UdpSocket* sock; const uint8_t* ip; uint16_t port; uint32_t* sent; };
+
+ /// The packetiser's sink: one datagram to the viewer's RTP port, dropping rather than waiting.
+ static bool sendPacket(void* ctx, const uint8_t* packet, size_t len) {
+ auto* c = static_cast(ctx);
+ if (!c->sock->sendToAddr(c->ip, c->port, packet, len)) return false;
+ (*c->sent)++;
+ return true;
+ }
+
+ /// The bitrate a frame of this size and rate needs, at about 0.1 bits per pixel per frame.
+ static uint16_t bitrateFor(size_t pixels, uint8_t fps) {
+ const uint32_t kbit = static_cast(pixels * fps / 10000u);
+ return static_cast(kbit < 500 ? 500 : (kbit > 8000 ? 8000 : kbit));
+ }
+
+ /// The smallest scale clearing the encoder's minimum frame, so a small wall still encodes.
+ uint8_t autoScale() const {
+ uint8_t s = 1;
+ while (s < kMaxScale && (srcWidth_ * s < 128 || srcHeight_ * s < 96)) s = static_cast(s + 1);
+ return s;
+ }
+
+ /// The largest block one light is drawn as, which bounds the encoded frame.
+ static constexpr uint8_t kMaxScale = 16;
+
+public:
+ /// Frames per second the encoder is asked for, which is also its GOP.
+ uint8_t targetFps = 30;
+ /// Lights per encoded block, or 0 to pick the smallest that clears the encoder's floor.
+ uint8_t scale = 0;
+
+private:
+ Buffer* sourceBuffer_ = nullptr;
+ ScratchBuffer rgb_;
+ ScratchBuffer corrScratch_;
+
+ platform::TcpServer control_;
+ platform::TcpConnection client_;
+ platform::UdpSocket rtpOut_;
+ mm::rtsp::Session session_{0};
+ mm::rtp::Packetiser rtp_{0, 0};
+
+ lengthType srcWidth_ = 0, srcHeight_ = 0;
+ lengthType width_ = 0, height_ = 0;
+ uint8_t scale_ = 1;
+ bool open_ = false;
+ bool playing_ = false;
+ char req_[1024] = {}; ///< the request being assembled, which TCP may split or pipeline
+ size_t reqLen_ = 0; ///< bytes of it held so far
+ uint32_t packetsSent_ = 0;
+ uint8_t peerIp_[4] = {}; ///< where RTP goes, read off the control socket at PLAY
+ uint32_t sendEpochMs_ = 0, nextSendMs_ = 0, frameIndex_ = 0;
+ uint8_t packet_[mm::rtp::kMaxPacketBytes] = {};
+ char urlBuf_[48] = {};
+ char statusBuf_[96] = {};
+};
+
+} // namespace mm
diff --git a/src/light/util/RtpH264.h b/src/light/util/RtpH264.h
new file mode 100644
index 00000000..4621728d
--- /dev/null
+++ b/src/light/util/RtpH264.h
@@ -0,0 +1,139 @@
+#pragma once
+/// RTP packetisation of an H.264 stream, RFC 3550 for the header and RFC 6184 for the payload.
+
+#include
+#include
+#include
+
+#include "core/util/H264Bitstream.h"
+
+namespace mm::rtp {
+
+/// The 90 kHz clock an RTP video timestamp counts in, the same one the encoder stamps frames with.
+static constexpr uint32_t kClockHz = 90000;
+
+/// Bytes of RTP header before the payload: version, marker, type, sequence, timestamp, SSRC.
+static constexpr size_t kHeaderBytes = 12;
+
+/// The payload type H.264 is assigned in the SDP this server answers with.
+static constexpr uint8_t kPayloadType = 96;
+
+/// What one datagram carries, chosen to clear a 1500-byte Ethernet MTU with IP and UDP headroom.
+static constexpr size_t kMaxPacketBytes = 1400;
+
+/// The bytes of Annex B start code at `p`: core reads the same bitstream the packetiser writes.
+using mm::h264::startCodeLen;
+
+/// The next NAL as an offset and a length past its start code, false once the buffer holds none.
+inline bool nextNal(const uint8_t* buf, size_t len, size_t* at, size_t* nalLen) {
+ size_t i = *at;
+ while (i < len && startCodeLen(buf + i, len - i) == 0) i++;
+ const size_t sc = (i < len) ? startCodeLen(buf + i, len - i) : 0;
+ if (sc == 0) return false;
+ const size_t start = i + sc;
+ size_t end = start;
+ while (end < len && startCodeLen(buf + end, len - end) == 0) end++;
+ if (end <= start) return false;
+ *at = end;
+ *nalLen = end - start;
+ // The caller reads from `buf + *at - *nalLen`, which is where this NAL's payload begins.
+ return true;
+}
+
+/// Packetises one access unit into caller-owned datagram buffers, reporting each as it is written.
+/// @moreinfo
+/// ## One NAL is one packet while it fits
+/// A NAL under the MTU rides alone, its own header byte becoming the packet's first payload byte.
+/// An oversized NAL splits into FU-A fragments carrying a two-byte indicator and header, the first setting S and the last E, every one repeating the original's type and reference bits.
+/// A decoder reassembles from those alone, so a lost fragment costs one frame rather than the stream.
+/// ## The marker bit ends an access unit
+/// Set on the last packet of a frame and clear on every other, so a decoder knows a frame is whole without waiting for the next timestamp to change.
+/// Every packet of one frame carries the SAME timestamp, a timestamp naming the moment a frame is displayed rather than sent.
+class Packetiser {
+public:
+ /// `ssrc` identifies this stream for the session's life, and the sequence continues across frames.
+ Packetiser(uint32_t ssrc, uint16_t firstSeq) : ssrc_(ssrc), seq_(firstSeq) {}
+
+ /// Where each datagram is written: the callee sends it before the next call overwrites nothing, since the buffer is the caller's.
+ using Sink = bool (*)(void* ctx, const uint8_t* packet, size_t len);
+
+ /// Packetise one access unit, whose NALs are Annex B framed. Returns the packets written, or 0 where the sink refused: a refused packet abandons the frame rather than sending it torn.
+ size_t writeAccessUnit(const uint8_t* annexB, size_t len, uint32_t pts90,
+ uint8_t* scratch, size_t scratchLen, Sink sink, void* ctx) {
+ // +3, never +2: an FU-A fragment spends two bytes on its header, so room for exactly those carries no payload and the loop never advances.
+ if (!annexB || !scratch || scratchLen < kHeaderBytes + 3 || !sink) return 0;
+ // The LAST NAL carries the marker, so the walk finds the end before emitting anything.
+ size_t at = 0, nalLen = 0, lastEnd = 0;
+ while (nextNal(annexB, len, &at, &nalLen)) lastEnd = at;
+
+ size_t packets = 0;
+ at = 0;
+ while (nextNal(annexB, len, &at, &nalLen)) {
+ const uint8_t* nal = annexB + at - nalLen;
+ const bool last = (at == lastEnd);
+ const size_t n = writeNal(nal, nalLen, pts90, last, scratch, scratchLen, sink, ctx);
+ if (n == 0) return 0;
+ packets += n;
+ }
+ return packets;
+ }
+
+ /// The sequence number the next packet carries, which a session reports in its RTP-Info.
+ uint16_t nextSequence() const { return seq_; }
+
+private:
+ /// One NAL, whole or fragmented: the packets written, or 0 where the sink refused.
+ size_t writeNal(const uint8_t* nal, size_t len, uint32_t pts90, bool lastOfFrame,
+ uint8_t* scratch, size_t scratchLen, Sink sink, void* ctx) {
+ const size_t room = (scratchLen < kMaxPacketBytes ? scratchLen : kMaxPacketBytes) - kHeaderBytes;
+ if (len == 0) return 0;
+
+ if (len <= room) {
+ writeHeader(scratch, pts90, lastOfFrame);
+ std::memcpy(scratch + kHeaderBytes, nal, len);
+ return sink(ctx, scratch, kHeaderBytes + len) ? 1 : 0;
+ }
+
+ // FU-A: the original header's type moves into the fragment header, its top three bits (the forbidden-zero and reference bits) staying with the indicator.
+ const uint8_t nri = static_cast(nal[0] & 0xE0);
+ const uint8_t type = static_cast(nal[0] & 0x1F);
+ size_t off = 1; // the original header byte is replaced, never sent
+ size_t packets = 0;
+ while (off < len) {
+ const size_t take = (len - off < room - 2) ? len - off : room - 2;
+ const bool first = (off == 1);
+ const bool final = (off + take >= len);
+ writeHeader(scratch, pts90, lastOfFrame && final);
+ scratch[kHeaderBytes] = static_cast(nri | 28); // 28 = FU-A
+ scratch[kHeaderBytes + 1] = static_cast((first ? 0x80 : 0) |
+ (final ? 0x40 : 0) | type);
+ std::memcpy(scratch + kHeaderBytes + 2, nal + off, take);
+ if (!sink(ctx, scratch, kHeaderBytes + 2 + take)) return 0;
+ packets++;
+ off += take;
+ }
+ return packets;
+ }
+
+ /// The twelve fixed bytes: version 2, no padding or extension, one CSRC-free source.
+ void writeHeader(uint8_t* p, uint32_t pts90, bool marker) {
+ p[0] = 0x80;
+ p[1] = static_cast((marker ? 0x80 : 0) | kPayloadType);
+ p[2] = static_cast(seq_ >> 8);
+ p[3] = static_cast(seq_ & 0xFF);
+ seq_++;
+ p[4] = static_cast(pts90 >> 24);
+ p[5] = static_cast(pts90 >> 16);
+ p[6] = static_cast(pts90 >> 8);
+ p[7] = static_cast(pts90);
+ p[8] = static_cast(ssrc_ >> 24);
+ p[9] = static_cast(ssrc_ >> 16);
+ p[10] = static_cast(ssrc_ >> 8);
+ p[11] = static_cast(ssrc_);
+ }
+
+ uint32_t ssrc_;
+ uint16_t seq_;
+};
+
+} // namespace mm::rtp
diff --git a/src/light/util/RtspSession.h b/src/light/util/RtspSession.h
new file mode 100644
index 00000000..c8434d75
--- /dev/null
+++ b/src/light/util/RtspSession.h
@@ -0,0 +1,259 @@
+#pragma once
+/// The RTSP control conversation, RFC 2326: one client's request becomes one response and a state move.
+/// @moreinfo
+/// ## An alternative is judged whole
+/// RFC 2326 lets a client offer several transports in one Transport header, comma separated and in preference order.
+/// Each carries its own parameters, so a scan finding the protocol in one and the port in another accepts a combination the client never offered.
+/// `RTP/AVP/TCP;interleaved=0-1,RTP/AVP;multicast;client_port=6000` would read as unicast UDP on port 6000, which is neither thing asked for.
+/// So each alternative is judged as a unit and the first usable one wins.
+/// Usable means RTP/AVP, not the interleaved TCP profile, explicitly unicast, and carrying a port.
+
+#include
+#include
+#include
+#include
+
+namespace mm::rtsp {
+
+/// The port RTSP is registered on, which every client tries first.
+static constexpr uint16_t kPort = 554;
+
+/// How far a session has progressed, which decides what the next request may ask for.
+enum class State : uint8_t {
+ Init, ///< connected, and describing or setting up from here
+ Ready, ///< SETUP agreed a transport, so PLAY may start the stream
+ Playing, ///< packets are flowing to the client's RTP port
+};
+
+/// What one request asked for, parsed out of its first line and headers.
+struct Request {
+ enum class Verb : uint8_t { Unknown, Options, Describe, Setup, Play, Teardown };
+ Verb verb = Verb::Unknown; ///< which verb, or Unknown where the line names none
+ uint32_t cseq = 0; ///< echoed in the response, which is how a client pairs the two
+ uint16_t rtpPort = 0; ///< the client's RTP port, from SETUP's Transport header
+ bool unicastUdp = false; ///< true where SETUP asked for RTP/AVP/UDP unicast
+};
+
+/// Compare `n` bytes without regard to case, which is how RFC 2326 reads a header name.
+inline bool ieq(const char* a, const char* b, size_t n) {
+ for (size_t i = 0; i < n; i++) {
+ const char x = (a[i] >= 'A' && a[i] <= 'Z') ? static_cast(a[i] + 32) : a[i];
+ const char y = (b[i] >= 'A' && b[i] <= 'Z') ? static_cast(b[i] + 32) : b[i];
+ if (x != y) return false;
+ }
+ return true;
+}
+
+/// Compare `n` bytes at `at` against a literal, false where the buffer holds fewer.
+inline bool matchAt(const char* buf, size_t len, size_t at, const char* lit, size_t n) {
+ return at + n <= len && std::memcmp(buf + at, lit, n) == 0;
+}
+
+/// A decimal number at `at`, bounded by the buffer and by `limit`; `limit + 1` where it overflows or holds no digit.
+inline uint32_t boundedDecimal(const char* buf, size_t len, size_t at, uint32_t limit) {
+ uint32_t v = 0;
+ size_t digits = 0;
+ for (; at < len && buf[at] >= '0' && buf[at] <= '9'; at++, digits++) {
+ const uint32_t d = static_cast(buf[at] - '0');
+ // Tested BEFORE the multiply, since `v * 10 + d` wraps past the type's range and lands back under the limit.
+ if (v > (limit - d) / 10) return limit + 1;
+ v = v * 10 + d;
+ }
+ return digits ? v : limit + 1;
+}
+
+/// Read the Transport header, taking the first alternative this server can carry.
+inline void parseTransport(const char* buf, size_t len, Request* out) {
+ // The header's own span, since these tokens are common enough to appear elsewhere in a request.
+ size_t at = len, end = len;
+ for (size_t i = 0; i + 10 <= len; i++) {
+ if (ieq(buf + i, "Transport:", 10)) { at = i + 10; break; }
+ }
+ if (at >= len) return;
+ for (size_t i = at; i + 1 < len; i++) {
+ if (buf[i] == '\r' && buf[i + 1] == '\n') { end = i; break; }
+ }
+
+ while (at < end) {
+ size_t stop = at;
+ while (stop < end && buf[stop] != ',') stop++; // one alternative, comma to comma
+ const size_t n = stop - at;
+ bool rtpAvp = false, tcpProfile = false, unicast = false, multicast = false;
+ uint16_t port = 0;
+ for (size_t j = at; j < stop; j++) {
+ const size_t left = stop - j;
+ if (!rtpAvp && left >= 7 && matchAt(buf, stop, j, "RTP/AVP", 7)) {
+ rtpAvp = true;
+ tcpProfile = left >= 11 && matchAt(buf, stop, j, "RTP/AVP/TCP", 11);
+ }
+ if (left >= 7 && matchAt(buf, stop, j, "unicast", 7)) unicast = true;
+ if (left >= 9 && matchAt(buf, stop, j, "multicast", 9)) multicast = true;
+ if (left >= 12 && matchAt(buf, stop, j, "client_port=", 12)) {
+ // 0 stays refused (SETUP reads it as "no port"), and 65535 has no pair for RTCP.
+ const uint32_t v = boundedDecimal(buf, stop, j + 12, 65534u);
+ if (v >= 1 && v <= 65534u) port = static_cast(v);
+ }
+ }
+ (void)n;
+ if (rtpAvp && !tcpProfile && unicast && !multicast && port != 0) {
+ out->unicastUdp = true;
+ out->rtpPort = port;
+ return; // the first usable alternative wins
+ }
+ at = stop + 1;
+ }
+}
+
+/// Parse a request, false where the buffer holds no complete first line. A client orders its headers as it likes, so each is searched for by name rather than counted to.
+inline bool parseRequest(const char* buf, size_t len, Request* out) {
+ if (!buf || !out || len == 0) return false;
+ *out = Request{};
+
+ struct { const char* name; Request::Verb verb; } kVerbs[] = {
+ {"OPTIONS", Request::Verb::Options}, {"DESCRIBE", Request::Verb::Describe},
+ {"SETUP", Request::Verb::Setup}, {"PLAY", Request::Verb::Play},
+ {"TEARDOWN", Request::Verb::Teardown},
+ };
+ for (const auto& v : kVerbs) {
+ const size_t n = std::strlen(v.name);
+ if (len > n && std::strncmp(buf, v.name, n) == 0 && buf[n] == ' ') {
+ out->verb = v.verb;
+ break;
+ }
+ }
+ if (out->verb == Request::Verb::Unknown) return false;
+
+ // CSeq, matched a letter at a time and bounded by `len`: RFC 2326 headers are case-insensitive, and these bytes arrive with no terminator to stop a scan.
+ for (size_t i = 0; i + 5 <= len; i++) {
+ if (ieq(buf + i, "CSeq:", 5)) {
+ size_t at = i + 5;
+ while (at < len && (buf[at] == ' ' || buf[at] == '\t')) at++;
+ const uint32_t v = boundedDecimal(buf, len, at, 0xFFFFFFFEu);
+ if (v <= 0xFFFFFFFEu) out->cseq = v;
+ break;
+ }
+ }
+ parseTransport(buf, len, out);
+ return true;
+}
+
+/// One client's conversation: what it has agreed to, and what it is told next.
+class Session {
+public:
+ /// `sessionId` identifies this conversation in every response after SETUP.
+ explicit Session(uint32_t sessionId) : id_(sessionId) {}
+
+ /// Where the conversation has reached.
+ State state() const { return state_; }
+ /// The client's RTP port, once SETUP has named one.
+ uint16_t rtpPort() const { return rtpPort_; }
+ /// The session id a client echoes back.
+ uint32_t id() const { return id_; }
+
+ /// Answer one request into `out`, returning the bytes written; `sdp` and `url` serve DESCRIBE.
+ size_t respond(const Request& req, const char* sdp, const char* url,
+ char* out, size_t outLen) {
+ switch (req.verb) {
+ case Request::Verb::Options:
+ return header(out, outLen, 200, "OK", req.cseq,
+ "Public: OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN\r\n", nullptr);
+
+ case Request::Verb::Describe: {
+ if (!sdp) return simple(out, outLen, 500, "Internal Server Error", req.cseq);
+ char extra[160];
+ std::snprintf(extra, sizeof(extra),
+ "Content-Type: application/sdp\r\nContent-Base: %s\r\nContent-Length: %u\r\n",
+ url ? url : "", static_cast(std::strlen(sdp)));
+ return header(out, outLen, 200, "OK", req.cseq, extra, sdp);
+ }
+
+ case Request::Verb::Setup: {
+ // UDP is the transport this server speaks; anything else is told so plainly.
+ if (!req.unicastUdp || req.rtpPort == 0)
+ return simple(out, outLen, 461, "Unsupported Transport", req.cseq);
+ rtpPort_ = req.rtpPort;
+ state_ = State::Ready;
+ char extra[200];
+ std::snprintf(extra, sizeof(extra),
+ "Transport: RTP/AVP;unicast;client_port=%u-%u;server_port=%u-%u\r\n"
+ "Session: %u\r\n",
+ static_cast(rtpPort_), static_cast(rtpPort_ + 1),
+ static_cast(kServerRtpPort),
+ static_cast(kServerRtpPort + 1),
+ static_cast(id_));
+ return header(out, outLen, 200, "OK", req.cseq, extra, nullptr);
+ }
+
+ case Request::Verb::Play: {
+ // A transport is agreed at SETUP, so PLAY before it has nowhere to send.
+ if (state_ != State::Ready && state_ != State::Playing)
+ return simple(out, outLen, 455, "Method Not Valid In This State", req.cseq);
+ state_ = State::Playing;
+ char extra[120];
+ std::snprintf(extra, sizeof(extra), "Session: %u\r\nRange: npt=0.000-\r\n",
+ static_cast(id_));
+ return header(out, outLen, 200, "OK", req.cseq, extra, nullptr);
+ }
+
+ case Request::Verb::Teardown: {
+ state_ = State::Init;
+ rtpPort_ = 0;
+ char extra[64];
+ std::snprintf(extra, sizeof(extra), "Session: %u\r\n", static_cast(id_));
+ return header(out, outLen, 200, "OK", req.cseq, extra, nullptr);
+ }
+
+ case Request::Verb::Unknown:
+ default:
+ return simple(out, outLen, 501, "Not Implemented", req.cseq);
+ }
+ }
+
+ /// The port this server sends RTP from, which SETUP advertises.
+ static constexpr uint16_t kServerRtpPort = 5004;
+
+private:
+ /// A response line, the always-present headers, optional extra headers and an optional body.
+ static size_t header(char* out, size_t outLen, int code, const char* reason, uint32_t cseq,
+ const char* extra, const char* body) {
+ if (!out || outLen == 0) return 0;
+ const int n = std::snprintf(out, outLen,
+ "RTSP/1.0 %d %s\r\nCSeq: %u\r\n%s\r\n%s",
+ code, reason, static_cast(cseq),
+ extra ? extra : "", body ? body : "");
+ return (n > 0 && static_cast(n) < outLen) ? static_cast(n) : 0;
+ }
+
+ static size_t simple(char* out, size_t outLen, int code, const char* reason, uint32_t cseq) {
+ return header(out, outLen, code, reason, cseq, nullptr, nullptr);
+ }
+
+ uint32_t id_;
+ State state_ = State::Init;
+ uint16_t rtpPort_ = 0;
+};
+
+/// The SDP a DESCRIBE answers with, one H.264 stream at the session's payload type: its length.
+inline size_t buildSdp(char* out, size_t outLen, const char* ip, uint16_t width, uint16_t height,
+ uint8_t fps, uint8_t payloadType) {
+ if (!out || outLen == 0) return 0;
+ // a=framesize and a=framerate are advisory, and a player that ignores them reads the same geometry out of the stream's own SPS.
+ const int n = std::snprintf(out, outLen,
+ "v=0\r\n"
+ "o=- 0 0 IN IP4 %s\r\n"
+ "s=projectMM\r\n"
+ "c=IN IP4 0.0.0.0\r\n"
+ "t=0 0\r\n"
+ "m=video 0 RTP/AVP %u\r\n"
+ "a=rtpmap:%u H264/90000\r\n"
+ "a=framesize:%u %u-%u\r\n"
+ "a=framerate:%u\r\n"
+ "a=control:*\r\n",
+ ip ? ip : "0.0.0.0",
+ static_cast(payloadType), static_cast(payloadType),
+ static_cast(payloadType), static_cast(width),
+ static_cast(height), static_cast(fps));
+ return (n > 0 && static_cast(n) < outLen) ? static_cast(n) : 0;
+}
+
+} // namespace mm::rtsp
diff --git a/src/main.cpp b/src/main.cpp
index 16908f11..87d97230 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -101,6 +101,7 @@
#include "light/drivers/NetworkSendDriver.h"
#include "light/drivers/NdiDriver.h"
#include "light/drivers/HlsDriver.h"
+#include "light/drivers/RtspDriver.h"
#include "light/drivers/PreviewDriver.h"
/// LED drivers are compiled in per chip, gated on the peripheral each one needs, so a board carries only the drivers its silicon can run.
/// The preprocessor rather than `if constexpr`, because the goal is excluding the code and a constexpr branch still compiles every arm.
@@ -283,6 +284,8 @@ static void registerModuleTypes() {
mm::ModuleFactory::registerType("NdiDriver", "light/drivers.md#ndi");
if constexpr (mm::platform::hasHls)
mm::ModuleFactory::registerType("HlsDriver", "light/drivers.md#hls");
+ if constexpr (mm::platform::hasRtsp)
+ mm::ModuleFactory::registerType("RtspDriver", "light/drivers.md#rtsp");
// Same firmware gate as the include above.
#if defined(MM_PANEL_CARDS) || MM_LINKS_ALL_LED_DRIVERS
mm::ModuleFactory::registerType("PanelCardDriver", "light/drivers.md#panelcard");
diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h
index 7c43ae3a..ccfb29cc 100644
--- a/src/platform/desktop/platform_config.h
+++ b/src/platform/desktop/platform_config.h
@@ -105,6 +105,9 @@ constexpr bool hasNdi = true;
/// True: the host streams H.264 by piping frames to the ffmpeg on PATH, a dependency of the user's.
constexpr bool hasHls = true;
+
+/// True: ffmpeg encodes the elementary stream and projectMM's own RTSP server ships it.
+constexpr bool hasRtsp = true;
/// True: ffmpeg offers several encoders, so the pick is the user's.
constexpr bool hasEncoderChoice = true;
/// True: ffmpeg writes the playlist to disk, so the server serves segments as files.
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 264ef58f..213191ce 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -133,6 +133,7 @@
#include "platform/platform.h"
#include "core/util/FirmwareImage.h" // identify/moonBaseRejection: shared image vetting
+#include "core/util/H264Bitstream.h" // where the encoder's frames begin and end
#include
#include
@@ -1640,7 +1641,8 @@ void mdnsShutdown() {}
// No update partition here, and the route guards on the capability, so this stub exists for compile coverage only.
bool http_fetch_to_ota(const char* /*url*/,
char* statusBuf, size_t statusBufLen,
- uint32_t* bytesReadOut, uint32_t* bytesTotalOut) {
+ uint32_t* bytesReadOut, uint32_t* bytesTotalOut,
+ const char* /*fallbackUrl*/) {
if (statusBuf && statusBufLen > 0) {
std::snprintf(statusBuf, statusBufLen, "unsupported on desktop");
}
@@ -1921,6 +1923,25 @@ int TcpConnection::read(uint8_t* buf, size_t maxLen) {
return 0; // error → treat as closed
}
+// getpeername rather than a field captured at accept: an earlier copy outlives a reconnect.
+bool TcpConnection::peerIPv4(uint8_t out[4]) const {
+ if (fd_ < 0 || !out) return false;
+ sockaddr_in addr{};
+ socklen_t len = sizeof(addr);
+#ifdef _WIN32
+ if (::getpeername(sock(fd_), reinterpret_cast(&addr), &len) != 0) return false;
+#else
+ if (::getpeername(fd_, reinterpret_cast(&addr), &len) != 0) return false;
+#endif
+ if (addr.sin_family != AF_INET) return false;
+ const uint32_t ip = ntohl(addr.sin_addr.s_addr);
+ out[0] = static_cast(ip >> 24);
+ out[1] = static_cast(ip >> 16);
+ out[2] = static_cast(ip >> 8);
+ out[3] = static_cast(ip);
+ return true;
+}
+
bool TcpConnection::write(const uint8_t* data, size_t len) {
if (fd_ < 0) return false;
// Send every byte, since a response must arrive complete, bounded because this runs on the render thread and a stalled peer would otherwise block it forever. Two bounds, as on a device: progress resets the stall one so a slow but steady transfer finishes, while the total one keeps a trickling peer from holding the loop.
@@ -2609,10 +2630,26 @@ std::string encCapturedArgs_;
#ifdef _WIN32
HANDLE encProcess_ = nullptr;
HANDLE encStdin_ = nullptr;
+HANDLE encStdout_ = nullptr;
#else
pid_t encPid_ = -1;
int encStdin_ = -1;
+int encStdout_ = -1;
+int esWake_[2] = {-1, -1}; // the reader waits on this beside the encoder's output
#endif
+
+// --- The elementary stream, which RTSP ships and HLS has no use for -------------------------
+// ffmpeg writes Annex B to stdout when the encoder runs without an output directory, and this thread cuts it into whole access units.
+std::thread esReader_;
+std::atomic esStop_{false}; // written by the render thread, read by the reader
+std::mutex esMutex_;
+std::vector esFrame_; // the published access unit, read under esMutex_
+uint32_t esFrameSeq_ = 0; // rises per published frame, so a taker can tell one from the next
+uint32_t esTakenSeq_ = 0;
+uint32_t esPts90_ = 0;
+bool esKeyframe_ = false;
+uint8_t esFps_ = 30; // the rate the pts is advanced at, from the running config
+constexpr uint32_t kRtpVideoClockHz = 90000; // the RTP video clock the timestamps count in
} // namespace
@@ -2625,14 +2662,25 @@ static void stopEncoderProcess() {
// The ring counters are NOT reset here: the writer may be mid-write on the head slot, and a producer racing this stop must keep seeing that slot as occupied. encoderStart resets the ring under the lock after the join, when nothing can touch it.
encCv_.notify_all();
}
+ esStop_ = true;
#ifdef _WIN32
if (encProcess_) TerminateProcess(encProcess_, 0);
if (encWriter_.joinable()) encWriter_.join();
+ // CancelIoEx, never a close, while the reader is parked in ReadFile: closing a handle under a blocked read is undefined, and the value can be recycled onto another object.
+ if (encStdout_ && esReader_.joinable()) CancelIoEx(encStdout_, nullptr);
+ if (esReader_.joinable()) esReader_.join();
+ if (encStdout_) { CloseHandle(encStdout_); encStdout_ = nullptr; }
if (encStdin_) { CloseHandle(encStdin_); encStdin_ = nullptr; }
if (encProcess_) { WaitForSingleObject(encProcess_, 500); CloseHandle(encProcess_); encProcess_ = nullptr; }
#else
if (encPid_ >= 0) ::kill(encPid_, SIGTERM);
if (encWriter_.joinable()) encWriter_.join();
+ // Wake the reader through its OWN pipe: a signalled child holds the write end until reaped, so waiting for EOF deadlocks the render thread.
+ if (esWake_[1] >= 0) { const char b = 1; (void)!::write(esWake_[1], &b, 1); }
+ if (esReader_.joinable()) esReader_.join();
+ if (esWake_[0] >= 0) { ::close(esWake_[0]); esWake_[0] = -1; }
+ if (esWake_[1] >= 0) { ::close(esWake_[1]); esWake_[1] = -1; }
+ if (encStdout_ >= 0) { ::close(encStdout_); encStdout_ = -1; }
if (encStdin_ >= 0) { ::close(encStdin_); encStdin_ = -1; }
if (encPid_ >= 0) {
for (int i = 0; i < 20; i++) { // ~200 ms of graceful exit
@@ -2644,8 +2692,28 @@ static void stopEncoderProcess() {
#endif
}
-// 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[]) {
+// Cut `pending` into whole access units, keeping the tail still arriving: core owns where a frame begins, this owns the buffering.
+static void publishAccessUnits(std::vector& pending, uint32_t& frames) {
+ std::vector starts;
+ mm::h264::findAccessUnits(pending.data(), pending.size(), &starts);
+ if (starts.size() < 2) return; // a frame is whole only once the next one has begun
+
+ for (size_t i = 0; i + 1 < starts.size(); i++) {
+ const size_t from = starts[i], to = starts[i + 1];
+ std::lock_guard lk(esMutex_);
+ esFrame_.assign(pending.begin() + static_cast(from),
+ pending.begin() + static_cast(to));
+ esPts90_ = static_cast(static_cast(frames) *
+ kRtpVideoClockHz / (esFps_ ? esFps_ : 30));
+ esKeyframe_ = mm::h264::hasKeyframe(pending.data() + from, to - from);
+ esFrameSeq_++;
+ frames++;
+ }
+ pending.erase(pending.begin(), pending.begin() + static_cast(starts.back()));
+}
+
+// Spawn `argv` with its stdin piped from us, and its stdout too where `captureStdout` asks: that is the elementary stream RTSP ships. This half is pure process plumbing.
+static bool spawnEncoderProcess(const char* const argv[], bool captureStdout = false) {
stopEncoderProcess();
if (encTestMode_ != EncoderTestMode::Off) {
encCapturedArgs_.clear();
@@ -2661,32 +2729,58 @@ static bool spawnEncoderProcess(const char* const argv[]) {
HANDLE readEnd = nullptr, writeEnd = nullptr;
if (!CreatePipe(&readEnd, &writeEnd, &sa, 4 * 1024 * 1024)) return false;
SetHandleInformation(writeEnd, HANDLE_FLAG_INHERIT, 0);
+ HANDLE outRead = nullptr, outWrite = nullptr;
+ if (captureStdout) {
+ if (!CreatePipe(&outRead, &outWrite, &sa, 4 * 1024 * 1024)) {
+ CloseHandle(readEnd); CloseHandle(writeEnd); return false;
+ }
+ SetHandleInformation(outRead, HANDLE_FLAG_INHERIT, 0);
+ }
+ // Quoted only where it needs to be: a bare `-` names ffmpeg's stdin and stdout, and quoting it hands the child a literal `"-"` it rejects. POSIX passes an array and never sees this.
std::string cmd;
for (const char* const* a = argv; *a; a++) {
if (!cmd.empty()) cmd += ' ';
- cmd += '"'; cmd += *a; cmd += '"';
+ const std::string arg(*a);
+ const bool needsQuotes = arg.empty() ||
+ arg.find_first_of(" \t\"") != std::string::npos;
+ if (!needsQuotes) { cmd += arg; continue; }
+ cmd += '"';
+ for (const char c : arg) {
+ if (c == '"') cmd += '\\';
+ cmd += c;
+ }
+ cmd += '"';
}
STARTUPINFOA si{};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = readEnd;
- si.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);
+ si.hStdOutput = captureStdout ? outWrite : GetStdHandle(STD_OUTPUT_HANDLE);
si.hStdError = GetStdHandle(STD_ERROR_HANDLE);
PROCESS_INFORMATION pi{};
const BOOL ok = CreateProcessA(nullptr, cmd.data(), nullptr, nullptr, TRUE,
CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi);
CloseHandle(readEnd);
- if (!ok) { CloseHandle(writeEnd); return false; }
+ if (outWrite) CloseHandle(outWrite); // the child holds it; ours would keep EOF away
+ if (!ok) {
+ CloseHandle(writeEnd);
+ if (outRead) CloseHandle(outRead);
+ return false;
+ }
CloseHandle(pi.hThread);
encProcess_ = pi.hProcess;
encStdin_ = writeEnd;
+ encStdout_ = outRead;
#else
// posix_spawn, not fork/exec: fork in a threaded process can deadlock on the allocator lock before exec, and a plain exec would leak every parent fd (the HTTP listen socket, the Art-Net/DDP ports) into a child that outlives a restart. Everything except the dup2'd stdin is closed in the child: CLOEXEC_DEFAULT on macOS, closefrom on glibc.
int fds[2];
if (::pipe(fds) != 0) return false;
+ int outFds[2] = {-1, -1};
+ if (captureStdout && ::pipe(outFds) != 0) { ::close(fds[0]); ::close(fds[1]); return false; }
posix_spawn_file_actions_t fa;
posix_spawn_file_actions_init(&fa);
posix_spawn_file_actions_adddup2(&fa, fds[0], 0);
+ if (captureStdout) posix_spawn_file_actions_adddup2(&fa, outFds[1], 1);
pid_t pid = -1;
int rc;
#ifdef __APPLE__
@@ -2701,10 +2795,16 @@ static bool spawnEncoderProcess(const char* const argv[]) {
#endif
posix_spawn_file_actions_destroy(&fa);
::close(fds[0]);
- if (rc != 0) { ::close(fds[1]); return false; }
+ if (outFds[1] >= 0) ::close(outFds[1]); // the child holds the write end; ours would keep EOF away
+ if (rc != 0) {
+ ::close(fds[1]);
+ if (outFds[0] >= 0) ::close(outFds[0]);
+ return false;
+ }
::signal(SIGPIPE, SIG_IGN); // a dead ffmpeg surfaces as EPIPE, not a signal
encPid_ = pid;
encStdin_ = fds[1];
+ encStdout_ = outFds[0];
#endif
// The writer thread does the BLOCKING writes: the render tick only ever enqueues, so an encoder that stops reading for a while (scheduler starvation under a free-running render loop stalled it >250 ms on the bench) costs queued-then-dropped frames, never a stalled tick, never a torn frame, and never a false death.
{
@@ -2753,6 +2853,63 @@ static bool spawnEncoderProcess(const char* const argv[]) {
}
}
});
+
+ // The elementary-stream reader, started only where the caller asked for stdout. It owns the splitting, so `rtspTakeFrame` is a copy under a lock and nothing more.
+ if (captureStdout) {
+ // A joinable thread assigned over terminates the process, and a run without stdout capture leaves this one unjoined.
+ if (esReader_.joinable()) { esStop_ = true; esReader_.join(); }
+ esStop_ = false;
+#ifndef _WIN32
+ if (esWake_[0] >= 0) { ::close(esWake_[0]); esWake_[0] = -1; }
+ if (esWake_[1] >= 0) { ::close(esWake_[1]); esWake_[1] = -1; }
+ // Without this pipe the stop path cannot reach a reader parked on a silent encoder, and the join would hold the render thread forever.
+ if (::pipe(esWake_) != 0) {
+ esWake_[0] = esWake_[1] = -1;
+ return true; // the encoder runs; only the frames this thread would publish are absent
+ }
+#endif
+ {
+ std::lock_guard lk(esMutex_);
+ esFrame_.clear();
+ esFrameSeq_ = 0;
+ esTakenSeq_ = 0;
+ esPts90_ = 0;
+ }
+ // Captured BY VALUE: the stop path clears the globals while this thread runs, and reading them here would race that write.
+ esReader_ = std::thread([out = encStdout_, wake = esWake_[0]] {
+ std::vector pending; // bytes read but not yet a whole access unit
+ uint8_t buf[16384];
+ uint32_t frames = 0;
+ for (;;) {
+#ifdef _WIN32
+ DWORD got = 0;
+ // A canceled read reports ERROR_OPERATION_ABORTED, which is the stop path asking.
+ if (!out || !ReadFile(out, buf, static_cast(sizeof(buf)), &got, nullptr) ||
+ got == 0) return;
+ const size_t n = got;
+#else
+ // Both descriptors, so a stop is noticed even while the encoder sends nothing.
+ if (out < 0) return;
+ fd_set rd;
+ FD_ZERO(&rd);
+ FD_SET(out, &rd);
+ if (wake >= 0) FD_SET(wake, &rd);
+ const int maxFd = (wake > out ? wake : out) + 1;
+ const int ready = ::select(maxFd, &rd, nullptr, nullptr, nullptr);
+ if (ready < 0 && errno == EINTR) continue;
+ if (ready < 0) return;
+ if (wake >= 0 && FD_ISSET(wake, &rd)) return; // asked to stop
+ const ssize_t r = ::read(out, buf, sizeof(buf));
+ if (r < 0 && errno == EINTR) continue;
+ if (r <= 0) return; // the child closed its stdout: the encoder is gone
+ const size_t n = static_cast(r);
+#endif
+ if (esStop_) return;
+ pending.insert(pending.end(), buf, buf + n);
+ publishAccessUnits(pending, frames);
+ }
+ });
+ }
return true;
}
@@ -2764,7 +2921,10 @@ bool encoderStart(const EncoderConfig& cfg) {
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);
+ // A null outDir asks for the elementary stream rather than a playlist: that is RTSP, which takes the frames itself.
+ const bool elementary = (cfg.outDir == nullptr);
+ if (!elementary) std::snprintf(out, sizeof(out), "%s/stream.m3u8", cfg.outDir);
+ esFps_ = cfg.fps ? cfg.fps : 30;
// Assembled by index so the software encoder's tuning flags stay off the hardware ones, which reject them, without duplicated slots.
// The frame slots are sized HERE, off the render tick, since the write path would otherwise allocate on its first lap and must not allocate at all.
@@ -2789,17 +2949,43 @@ bool encoderStart(const EncoderConfig& cfg) {
"-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
+ add("-g"); add(gop); add("-b:v"); add(bv);
+ if (elementary) {
+ // Annex B to stdout; `dump_extra` repeats the parameter sets per keyframe, so a client joining mid-stream decodes at once.
+ for (const char* a : std::initializer_list{
+ "-bsf:v", "dump_extra", "-f", "h264", "-" }) add(a);
+ } else {
+ for (const char* a : std::initializer_list{
+ "-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);
+ return spawnEncoderProcess(argv, elementary);
}
// 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() {}
+// The frame the reader published, copied out under the lock into a buffer that outlives the next read. The sequence is what says whether this caller has seen it.
+bool rtspTakeFrame(EncodedFrame* out) {
+ if (!out) return false;
+ static std::vector taken; // the caller reads this after the lock drops
+ std::lock_guard lk(esMutex_);
+ if (esFrameSeq_ == esTakenSeq_ || esFrame_.empty()) return false;
+ esTakenSeq_ = esFrameSeq_;
+ taken = esFrame_;
+ out->nal = taken.data();
+ out->len = taken.size();
+ out->pts90 = esPts90_;
+ out->keyframe = esKeyframe_;
+ return true;
+}
+
+// The taker already owns its copy here, so there is nothing to hand back.
+void rtspReleaseFrame() {}
+
+// `dump_extra` above repeats the parameter sets on every keyframe, so a client joining at one decodes from it.
+bool rtspParameterSets(EncodedFrame*, EncodedFrame*) { return false; }
int encoderWrite(const uint8_t* data, size_t len) {
std::lock_guard lock(encMutex_);
diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h
index 068c8239..c57b43f4 100644
--- a/src/platform/esp32/platform_config.h
+++ b/src/platform/esp32/platform_config.h
@@ -216,6 +216,8 @@ constexpr bool hasHls = true;
#else
constexpr bool hasHls = false;
#endif
+/// True with the same encoder HLS uses: RTSP ships that encoder's frames without muxing them.
+constexpr bool hasRtsp = hasHls;
/// False: one hardware encoder, so there is nothing to choose and the control stays hidden.
constexpr bool hasEncoderChoice = false;
/// False: segments live in a PSRAM ring, flash wear buying nothing for a file stale within seconds.
diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index 23888abb..2e2b05a3 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -1744,6 +1744,21 @@ int TcpConnection::read(uint8_t* buf, size_t maxLen) {
return 0;
}
+// getpeername rather than a field captured at accept: a copy taken earlier outlives a reconnect on the same slot.
+bool TcpConnection::peerIPv4(uint8_t out[4]) const {
+ if (fd_ < 0 || !out) return false;
+ sockaddr_in addr{};
+ socklen_t len = sizeof(addr);
+ if (::getpeername(fd_, reinterpret_cast(&addr), &len) != 0) return false;
+ if (addr.sin_family != AF_INET) return false;
+ const uint32_t ip = ntohl(addr.sin_addr.s_addr);
+ out[0] = static_cast(ip >> 24);
+ out[1] = static_cast(ip >> 16);
+ out[2] = static_cast(ip >> 8);
+ out[3] = static_cast(ip);
+ return true;
+}
+
bool TcpConnection::write(const uint8_t* data, size_t len) {
if (fd_ < 0) return false;
// Send every byte, retrying on a full buffer, since a response must arrive complete and a healthy interface drains in microseconds.
diff --git a/src/platform/esp32/platform_esp32_h264.cpp b/src/platform/esp32/platform_esp32_h264.cpp
index 4d16a755..d3e01b8f 100644
--- a/src/platform/esp32/platform_esp32_h264.cpp
+++ b/src/platform/esp32/platform_esp32_h264.cpp
@@ -23,7 +23,7 @@
/// The orphan would then resume as a second producer on the one encoder handle and scratch buffer.
/// So each worker captures the generation it was spawned for and exits as soon as it is no longer current.
///
-/// For the same reason the buffers are freed only once the worker has actually returned: a detached one is mid-encode holding raw pointers to them and to the encoder handle.
+/// For the same reason the buffers are freed only once the worker has returned: a detached one is mid-encode holding raw pointers to them and to the encoder handle.
/// Freeing there would be a use-after-free plus a call into a deleted session, so leaking a few megabytes until the next start is the better trade.
///
/// ## The playlist advertises from the oldest plus a margin
@@ -31,13 +31,48 @@
/// Never the oldest itself, since that slot is the next one rotation overwrites and a player fetching it races the encoder and gets nothing.
/// The margin is what a player has left to fetch what it was promised, and the rest of the ring is its buffering budget.
/// On the bench, listing the true oldest failed immediately and listing only the newest few failed within about five seconds, and both spin forever.
+///
+/// ## The encoder task's core and stack
+///
+/// The second core, since the first runs the network stack and starving it stalls the server that serves these segments.
+/// The stack is twice what this started with, the encoder call chain plus our muxer having overflowed the smaller one.
+/// It jumped into the maths library with a corrupted pointer and panicked in a loop.
+/// The vendor's own example runs its encode from a comparable stack, and the muxer's frame loop sits on top of that.
+/// The generation flag is cleared before the task starts rather than inside it.
+/// A worker's first instruction runs only once the scheduler reaches it, so a stop landing in that window would free buffers it is about to encode from.
+///
+/// ## The QP window lets the bitrate govern
+///
+/// A near-fixed window pins quality, so the encoder spends whatever that costs and ignores the configured bitrate entirely.
+/// Opening the window lets the bitrate govern what a frame may cost.
+///
+/// ## A segment claims the duration it holds
+///
+/// Claiming a flat second while delivering fewer frames makes a player run ahead of the stream until it stalls to re-buffer, which shows as segments arriving every 0.7 seconds.
+///
+/// ## The target check lives here, not in Kconfig
+///
+/// `depends on IDF_TARGET_ESP32P4` would hide MM_HLS from the component solver.
+/// The solver reads that symbol to gate the esp_h264 dependency, so every non-P4 build then fails at cmake.
+/// Catching it in the source instead names the cause, where the Kconfig route surfaces as a link error against a missing hardware encoder.
+///
+/// ## The ring's depth is a lifetime, not a cache
+///
+/// A segment survives its kept-seconds after it closes.
+/// That 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.
+///
+/// ## A served segment is reserved
+///
+/// `hlsSegment` hands out a pointer the caller reads AFTER the lock drops, so the encoder must not recycle that slot underneath it.
+/// Serving is far shorter than the time the ring takes to lap, but "usually in time" is not a lifetime guarantee.
#include "platform/platform.h"
#include "sdkconfig.h"
#if defined(CONFIG_MM_HLS)
-// The Kconfig symbol cannot enforce this itself: `depends on IDF_TARGET_ESP32P4` would hide MM_HLS from the component solver, which reads it to gate the esp_h264 dependency, and every non-P4 build then fails at cmake. Catch it here instead, where the message names the cause rather than surfacing as a link error against a missing hardware encoder.
+// Checked here rather than in Kconfig: @xref{the-target-check-lives-here-not-in-kconfig|why the symbol cannot gate itself}.
#include "soc/soc_caps.h"
#if !defined(SOC_H264_ENCODER_SUPPORTED) || !SOC_H264_ENCODER_SUPPORTED
#error "CONFIG_MM_HLS is set on a chip with no hardware H.264 encoder (P4 only)."
@@ -60,7 +95,7 @@ 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.
+// Segments kept in the ring, and so the playlist's depth: @xref{the-rings-depth-is-a-lifetime-not-a-cache|what the number buys a player}.
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;
@@ -86,14 +121,14 @@ 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.
+// The segment a socket is currently reading, kNoSeg for none: @xref{a-served-segment-is-reserved|why it is reserved}.
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.
+// Set by the worker as its LAST act, since a detached one outlives stopPinnedTask: @xref{an-orphaned-worker-must-not-become-a-second-producer|why its return proves nothing}.
std::atomic workerExited_{false};
// Which worker generation is the live one: @xref{an-orphaned-worker-must-not-become-a-second-producer|why a running flag alone cannot gate it}.
@@ -102,9 +137,22 @@ 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;
+// The frame the encoder produced most recently, which RTSP ships without muxing: a sequence rather than a flag, so a reader that misses one sees that it did.
+size_t lastFrameLen_ = 0;
+uint32_t lastFramePts_ = 0;
+bool lastFrameKey_ = false;
+uint32_t lastFrameSeq_ = 0; // bumped per encode
+uint32_t lastTakenSeq_ = 0; // the sequence a reader last took
+
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
+uint8_t* take_ = nullptr; // the copy a reader packetises, safe from the next encode
+size_t takeLen_ = 0;
+uint32_t takeSeq_ = 0; // which encode the copy holds
+uint32_t takePts_ = 0; // ITS timestamp, not the encoder's latest
+bool takeKey_ = false; // and its frame type
+bool takeBusy_ = false; // a reader is packetising it, so the encoder leaves it alone
uint16_t width_ = 0, height_ = 0;
uint8_t fps_ = 30;
uint32_t frameNo_ = 0;
@@ -151,7 +199,7 @@ void rotateSegment() {
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).
+ // Only a slot being SERVED is off limits: an empty slot carries seq 0, which is also kNoSeg, so skipping the busy check stalls the ring.
if (busy == kNoSeg || segments_[segWrite_].seq != busy) break;
}
segments_[segWrite_].len = 0;
@@ -183,11 +231,26 @@ void encodeOne(const uint8_t* rgb, size_t rgbLen) {
const bool keyframe = out.frame_type == ESP_H264_FRAME_TYPE_IDR ||
out.frame_type == ESP_H264_FRAME_TYPE_I;
+
+ // The encoded frame ITSELF, for a reader that ships NALs rather than segments: recorded before the mux so both read one encode.
+ lastFrameLen_ = out.length;
+ lastFrameKey_ = keyframe;
+ lastFrameSeq_++;
const uint32_t pts90 = static_cast(
static_cast(frameNo_) * mm::ts::kClockHz / (fps_ ? fps_ : 30));
frameNo_++;
+ lastFramePts_ = pts90;
Lock lk;
+ // COPIED for the reader, and only between its frames: the render thread packetises long after taking one, so sharing nal_ splices two frames into one.
+ if (take_ && !takeBusy_ && out.length <= kSegmentBytes / 4) {
+ std::memcpy(take_, nal_, out.length);
+ takeLen_ = out.length;
+ takeSeq_ = lastFrameSeq_;
+ // The metadata travels WITH the bytes, since lastFramePts_ moves on while a reader still ships this frame.
+ takePts_ = pts90;
+ takeKey_ = keyframe;
+ }
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) {
@@ -244,6 +307,9 @@ void freeAll() {
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;
+ // The BUSY flag goes with the buffer it guards: a reader that never released would leave every later encode unable to fill a fresh take_.
+ heap_caps_free(take_); take_ = nullptr;
+ takeLen_ = 0; takeSeq_ = 0; takeBusy_ = false; lastTakenSeq_ = 0;
}
void* psram(size_t bytes) {
@@ -253,7 +319,7 @@ void* psram(size_t bytes) {
} // 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.
+ // A detached worker's buffers are overwritten rather than freed: a bounded one-time leak beats freeing memory a live task still writes.
encoderStop();
if (!mutex_) mutex_ = xSemaphoreCreateMutex();
if (!mutex_) return false;
@@ -275,7 +341,7 @@ bool encoderStart(const EncoderConfig& cfg) {
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.
+ // The QP window the rate controller may use: @xref{the-qp-window-lets-the-bitrate-govern|why it is opened}.
hw.rc.qp_min = 10;
hw.rc.qp_max = 40;
@@ -289,9 +355,10 @@ bool encoderStart(const EncoderConfig& cfg) {
const size_t rgbBytes = static_cast(width_) * height_ * 3;
yuv_ = static_cast(psram(rgbBytes / 2));
nal_ = static_cast(psram(kSegmentBytes / 4));
+ take_ = 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; }
+ if (!yuv_ || !nal_ || !take_) { 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; }
@@ -304,12 +371,9 @@ bool encoderStart(const EncoderConfig& cfg) {
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.
+ // Cleared HERE, never in the worker: @xref{the-encoder-tasks-core-and-stack|what a stop in that window would free}.
workerExited_ = false;
- // The second core, since the first runs the network stack and starving it stalls the very server that serves these segments.
- // The stack is twice what this started with: the encoder call chain plus our muxer overflowed the smaller one.
- // It jumped into the maths library with a corrupted pointer, panicking in a loop.
- // The vendor's own example runs its encode from a comparable stack, and the muxer's frame loop sits on top of that.
+ // The second core and a doubled stack: @xref{the-encoder-tasks-core-and-stack|why both}.
if (!spawnPinnedTask(task_, "mmH264", workerFn,
reinterpret_cast(static_cast(myGen)),
16 * 1024, 5, 1)) {
@@ -344,7 +408,7 @@ void encoderStop() {
stopPinnedTask(task_);
}
Lock lk;
- // Free only once the worker has actually returned: @xref{an-orphaned-worker-must-not-become-a-second-producer|why a detached one still holds these pointers}.
+ // Free only once the worker has returned: @xref{an-orphaned-worker-must-not-become-a-second-producer|why a detached one still holds these pointers}.
if (workerExited_) {
freeAll();
head_ = count_ = 0;
@@ -374,7 +438,7 @@ bool hlsSegment(const char* name, const uint8_t** data, size_t* len) {
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.
+ // The segment's REAL duration: @xref{a-segment-claims-the-duration-it-holds|what a flat second costs a player}.
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;
@@ -407,14 +471,39 @@ bool hlsSegment(const char* name, const uint8_t** data, size_t* len) {
void hlsSegmentRelease() { serving_ = kNoSeg; }
+// The encoded frame itself, for RTSP: the COPY, since the encoder task overwrites nal_ while a reader is still packetising. Marks the copy busy until rtspReleaseFrame, so the next encode leaves it alone.
+bool rtspTakeFrame(EncodedFrame* out) {
+ Lock lk;
+ if (!out || !take_ || takeLen_ == 0 || takeSeq_ == lastTakenSeq_) return false;
+ lastTakenSeq_ = takeSeq_;
+ takeBusy_ = true;
+ out->nal = take_;
+ out->len = takeLen_;
+ out->pts90 = takePts_;
+ out->keyframe = takeKey_;
+ return true;
+}
+
+// The reader is done with the copy, so the next encode may fill it again.
+void rtspReleaseFrame() {
+ Lock lk;
+ takeBusy_ = false;
+}
+
+// The parameter sets ride inside every keyframe this encoder emits, so a client joining at one decodes from it.
+bool rtspParameterSets(EncodedFrame*, EncodedFrame*) { return false; }
+
} // 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).
+// Called on every /hls/ request whatever the platform, so a build without the encoder answers too: nothing in RAM, and the filesystem path 404s.
namespace mm::platform {
bool hlsSegment(const char*, const uint8_t**, size_t*) { return false; }
void hlsSegmentRelease() {}
+bool rtspTakeFrame(EncodedFrame*) { return false; }
+void rtspReleaseFrame() {}
+bool rtspParameterSets(EncodedFrame*, EncodedFrame*) { return false; }
// 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; }
diff --git a/src/platform/esp32/platform_esp32_ota.cpp b/src/platform/esp32/platform_esp32_ota.cpp
index a11dc603..086a3e5b 100644
--- a/src/platform/esp32/platform_esp32_ota.cpp
+++ b/src/platform/esp32/platform_esp32_ota.cpp
@@ -33,6 +33,69 @@
/// The padding then reaches flash, the offset advances by the unpadded count, and the next write lands back over it, corrupting the image at every short read.
/// The verification catches the result, but only after the slot is erased, which is the failure this design exists to avoid.
/// At the end the remainder is the image's last bytes, so it is padded to a word with what erased flash reads as; only there is padding correct.
+///
+/// ## The redirect response needs a bigger header buffer
+///
+/// The default is far too small for the host's redirect, whose policy header overflows it and fails the update before the download starts.
+/// Raising both directions covers the longest headers with room to spare, at a few kilobytes of heap freed when the task exits.
+///
+/// ## Progress rides the status string
+///
+/// Both writers report the counts in the same shape, so the interface reads progress identically whichever image is installed.
+/// They used to reach a control that no longer exists, which showed an indeterminate sweep that never resolved on a device working perfectly.
+/// The padded tail is excluded: it is flash the image does not occupy, and counting it would report more written than was sent.
+///
+/// ## Why the install runs on its own task
+///
+/// The HTTP request answers 202 at once, exactly as the application's URL install does.
+/// While a request is open the browser cannot poll for progress, so a synchronous install would leave it blind for the whole write.
+///
+/// ## The application answers before it reboots
+///
+/// The image is committed and the boot pointer flipped, so the caller returns and sends its HTTP 200 BEFORE the restart, the same sequence /api/reboot follows.
+/// That is what lets a browser see a clean result rather than a dropped socket.
+///
+/// ## Reading a header straight out of a partition
+///
+/// The image's length sits in its header, a plain partition read needing no flash mapping.
+///
+/// ## Reporting on the recovery image
+///
+/// Which recovery image a device carries is read from its descriptor at a fixed offset, costing one flash read and no reboot.
+/// The recovery image answers the same question about itself elsewhere, which is a different question rather than a second copy.
+/// That reports what it is executing, and this reports an image it is not.
+/// Both carry the same version string, which is what lets a caller compare them by equality.
+/// Its SIZE comes from the slot rather than the image.
+/// The metadata call reads through a mapping arranged for the running partition, and errored for this one, leaving the row absent.
+/// What a user wants from the row is whether the slot has room, and reading the descriptor already proves an image is there.
+/// The figure counts the segments only, so trailing padding, checksum and hash are excluded and it reads a few dozen bytes under the file on disk.
+/// That is deliberate: the figure answers how full the slot is, and reproducing the bootloader's padding rules would be a second copy to keep in step for no gain.
+///
+/// ## Pointing the bootloader at the recovery image
+///
+/// Setting the boot partition to it erases the selection data rather than writing a sequence number.
+/// Which is what makes a power cut land back in recovery rather than in a half-written application.
+///
+/// ## A URL pull reuses the upload writer
+///
+/// The update interface cannot serve a recovery image from a URL, targeting only its own subtypes and picking the partition itself.
+/// So the download is driven by hand through the same producer callback: one writer, one set of checks, two sources.
+///
+/// ## The single-slot guard
+///
+/// The next-partition call falls back to the first slot it finds, so on a one-slot table it hands back the partition being executed, and erasing that bricks the device mid-write.
+/// The interface refuses it too, but failing early says why and names the fix.
+///
+/// ## The update buffer is heap and owned
+///
+/// Heap rather than static or stack: too large for a task frame, and a static one held internal memory from boot for nothing.
+/// An update is the one moment when spare memory is least scarce, so allocating here gives it back to the network stack for the rest of uptime.
+/// Owned rather than raw, since six exit paths would each leak it.
+///
+/// ## One bounded status writer
+///
+/// Three identical local copies each put the format behind a template, which defeats the compiler's format check.
+/// A plain variadic function takes the format attribute instead, so a non-literal format is a build error rather than a scanner note.
#include "platform/platform.h"
@@ -59,6 +122,8 @@
#include // unique_ptr — frees the upload buffer on every exit path
#include // std::nothrow for the OtaTaskParams alloc below
+#include "core/system/FirmwareUpdateModule.h" // kProjectImageName: whose firmware arrived
+
namespace mm::platform {
// One upload chunk. 4 KB matches the flash page granularity esp_ota_write prefers and is the size the HTTP path already streams in.
@@ -75,13 +140,14 @@ namespace {
// Heap-allocated task parameters. Task owns this and frees it on exit.
struct OtaTaskParams {
char url[512];
+ char fallbackUrl[512] = {}; // empty where the caller named none
char* statusBuf;
size_t statusBufLen;
uint32_t* bytesReadOut; // current bytes downloaded
uint32_t* bytesTotalOut; // image size; 0 until esp_https_ota reports it
};
-// One bounded status writer for the file, replacing three identical local copies that each put the format behind a template. A plain variadic function takes the format attribute, so the compiler checks each format against its arguments and a non-literal one is a build error rather than a scanner note.
+// One bounded status writer for the file: @xref{one-bounded-status-writer|why a plain variadic beats a template}.
__attribute__((format(printf, 3, 4)))
void statusf(char* buf, size_t len, const char* fmt, ...) {
if (!buf || len == 0) return;
@@ -108,7 +174,7 @@ void otaTask(void* arg) {
*p->bytesReadOut = 0;
*p->bytesTotalOut = 0; // unknown until esp_https_ota reports it
- // The bundled trust anchors, the same mechanism a browser uses, with no certificate baked in. Attached unconditionally: a secure URL verifies the server, and on a plain local one it goes unused while still satisfying the begin call's verification check.
+ // The bundled trust anchors, the same mechanism a browser uses, with no certificate baked in. Attached unconditionally: a plain local URL leaves them unused while still satisfying the begin call.
esp_http_client_config_t http_config = {};
http_config.url = p->url;
http_config.crt_bundle_attach = esp_crt_bundle_attach;
@@ -116,7 +182,7 @@ void otaTask(void* arg) {
// GitHub release-asset URLs 302-redirect to objects.githubusercontent.com. Default redirect handling is off in esp_http_client; force-follow.
http_config.disable_auto_redirect = false;
http_config.max_redirection_count = 10;
- // The default header buffer is far too small for the host's redirect response, whose policy header overflows it and fails the update before the download even starts. Raising both directions covers the longest headers with room to spare, at a few kilobytes of heap freed when the task exits.
+ // A bigger header buffer: @xref{the-redirect-response-needs-a-bigger-header-buffer|what overflows the default}.
http_config.buffer_size = 4096;
http_config.buffer_size_tx = 4096;
@@ -126,6 +192,13 @@ void otaTask(void* arg) {
esp_https_ota_handle_t handle = nullptr;
esp_err_t err = esp_https_ota_begin(&ota_config, &handle);
+ // On a failed BEGIN only: once bytes flow the partition holds them, and a second address would restart that.
+ if (err != ESP_OK && p->fallbackUrl[0]) {
+ otaSetStatus(p, "retrying the other address");
+ http_config.url = p->fallbackUrl;
+ handle = nullptr;
+ err = esp_https_ota_begin(&ota_config, &handle);
+ }
if (err != ESP_OK) {
// esp_https_ota_begin collapses ~6 distinct failures (DNS, TLS, HTTP, partition init, header-buffer overflow) into one ESP_FAIL, so the only useful detail is in the IDF log on the serial console. We surface the IDF error name plus a pointer to the log.
otaSetStatus(p, "error: ota begin %s (see serial log)",
@@ -137,15 +210,25 @@ void otaTask(void* arg) {
// Refuse a recovery image here: @xref{writing-the-wrong-image-is-the-unrecoverable-direction|why this direction is the worse one}.
esp_app_desc_t incoming = {};
- if (esp_https_ota_get_img_desc(handle, &incoming) == ESP_OK &&
- std::strncmp(incoming.project_name, "projectMM-moonbase",
- sizeof(incoming.project_name)) == 0) {
+ const bool haveDesc = esp_https_ota_get_img_desc(handle, &incoming) == ESP_OK;
+ if (haveDesc && std::strncmp(incoming.project_name, "projectMM-moonbase",
+ sizeof(incoming.project_name)) == 0) {
otaSetStatus(p, "error: that is a MoonBase image, not an app");
esp_https_ota_abort(handle);
delete p;
vTaskDelete(nullptr);
return;
}
+ // And refuse an image that is not this project: a URL can name another repository's release, and the descriptor is what says whose firmware arrived.
+ if (haveDesc && std::strncmp(incoming.project_name, mm::kProjectImageName,
+ sizeof(incoming.project_name)) != 0) {
+ otaSetStatus(p, "error: that image is %.*s, not this project",
+ static_cast(sizeof(incoming.project_name)), incoming.project_name);
+ esp_https_ota_abort(handle);
+ delete p;
+ vTaskDelete(nullptr);
+ return;
+ }
int total = esp_https_ota_get_image_size(handle);
if (total > 0) {
@@ -157,7 +240,7 @@ void otaTask(void* arg) {
while ((err = esp_https_ota_perform(handle)) == ESP_ERR_HTTPS_OTA_IN_PROGRESS) {
int got = esp_https_ota_get_image_len_read(handle);
if (got >= 0) *p->bytesReadOut = static_cast(got);
- // The counts ride the status in the same shape both writers report, so the interface reads progress identically whichever image is installed. They used to reach a control that no longer exists, which showed an indeterminate sweep that never resolved on a device working perfectly.
+ // The counts ride the status: @xref{progress-rides-the-status-string|why, and what the padding is excluded from}.
if (total > 0) {
otaSetStatus(p, "flashing: %u of %u bytes",
static_cast(got > 0 ? got : 0), static_cast(total));
@@ -201,7 +284,8 @@ void otaTask(void* arg) {
bool http_fetch_to_ota(const char* url,
char* statusBuf, size_t statusBufLen,
- uint32_t* bytesReadOut, uint32_t* bytesTotalOut) {
+ uint32_t* bytesReadOut, uint32_t* bytesTotalOut,
+ const char* fallbackUrl) {
if (!url || !statusBuf || statusBufLen == 0 || !bytesReadOut || !bytesTotalOut) {
return false;
}
@@ -222,6 +306,11 @@ bool http_fetch_to_ota(const char* url,
return false;
}
std::memcpy(p->url, url, urlLen + 1); // includes NUL; size already verified
+ // An over-long fallback is dropped rather than refused: the primary URL is still worth trying.
+ if (fallbackUrl && fallbackUrl[0]) {
+ const size_t fLen = std::strlen(fallbackUrl);
+ if (fLen <= sizeof(p->fallbackUrl) - 1) std::memcpy(p->fallbackUrl, fallbackUrl, fLen + 1);
+ }
p->statusBuf = statusBuf;
p->statusBufLen = statusBufLen;
p->bytesReadOut = bytesReadOut;
@@ -247,9 +336,7 @@ bool otaWriteStream(FsWriteSrc src, void* user, size_t contentLen,
const esp_partition_t* part = esp_ota_get_next_update_partition(nullptr);
if (!part) { setStatus("error: no OTA partition"); return false; }
- // The single-slot guard: the next-partition call falls back to the first slot it finds.
- // So on a one-slot table it hands back the partition being executed, and erasing that bricks the device mid-write.
- // The interface refuses it too, but failing here says why and names the fix; on a two-slot table this never fires.
+ // The single-slot guard: @xref{the-single-slot-guard|what the fallback would erase}.
if (part == esp_ota_get_running_partition()) {
setStatus("error: one app slot, reboot to MoonBase first");
return false;
@@ -267,10 +354,7 @@ bool otaWriteStream(FsWriteSrc src, void* user, size_t contentLen,
esp_err_t err = esp_ota_begin(part, OTA_SIZE_UNKNOWN, &handle);
if (err != ESP_OK) { setStatus("error: ota begin %s", esp_err_to_name(err)); return false; }
- // Pull the body chunk by chunk through the same producer callback the file writer drives; an aborted upload fails the update and discards the partial.
- // The buffer is heap rather than static or stack: too large for a task frame, and a static one held internal memory from boot for nothing.
- // An update is the one moment when spare memory is least scarce, so allocating here gives it back to the network stack for the rest of uptime.
- // Owned rather than raw, since six exit paths below would each leak it.
+ // The body, chunk by chunk through the writer's own producer callback: @xref{the-update-buffer-is-heap-and-owned|why heap, and why owned}.
const std::unique_ptr owned(
static_cast(heap_caps_malloc(kOtaChunkBytes, MALLOC_CAP_8BIT)), &heap_caps_free);
uint8_t* const buf = owned.get();
@@ -293,7 +377,7 @@ bool otaWriteStream(FsWriteSrc src, void* user, size_t contentLen,
// Refuse a recovery image, decided on enough bytes rather than on the first chunk: @xref{writing-the-wrong-image-is-the-unrecoverable-direction|why a short prefix would let it through}.
if (!vetted) {
if (written + n < firmware::kIdentifyBytes) {
- // Not enough yet, and nothing written: keep accumulating in the OTA partition is not an option (a rejected image must leave no bytes), so refuse a body that ends before it can be identified. Any real image is far larger.
+ // A rejected image must leave no bytes, so a body ending before it can be identified is refused rather than kept.
if (contentLen && contentLen < firmware::kIdentifyBytes) {
setStatus("error: too short to be a firmware image");
esp_ota_abort(handle);
@@ -313,6 +397,12 @@ bool otaWriteStream(FsWriteSrc src, void* user, size_t contentLen,
esp_ota_abort(handle);
return false;
}
+ // And whose firmware this is, the same question the URL path asks, before the first write rather than after the slot is spent.
+ if (!info.described || std::strcmp(info.project, mm::kProjectImageName) != 0) {
+ setStatus("error: that image is not this project");
+ esp_ota_abort(handle);
+ return false;
+ }
}
err = esp_ota_write(handle, buf, n);
if (err != ESP_OK) {
@@ -337,14 +427,14 @@ bool otaWriteStream(FsWriteSrc src, void* user, size_t contentLen,
if (err != ESP_OK) { setStatus("error: set boot %s", esp_err_to_name(err)); return false; }
setStatus("rebooting");
- // Image committed + boot pointer flipped. Return to the caller so it can send its HTTP 200 BEFORE the reboot (the caller closes the socket + reboots, same sequence as /api/reboot), that's what lets the browser see a clean "flashed" response instead of an aborted socket.
+ // Committed and flipped: @xref{the-application-answers-before-it-reboots|why the caller returns first}.
return true;
#undef setStatus
}
-// Updating the recovery image itself: @xref{updating-the-recovery-image-itself|why only the application can, and why the checks come first}. Point this at an error page, the wrong chip's image or an application build, and the device still has its recovery image.
+// Updating the recovery image itself: @xref{updating-the-recovery-image-itself|why only the application can}.
-// Does this first chunk begin a MoonBase image for THIS chip? The parsing and the rules live in core/FirmwareImage.h so a host test can drive them: this code erases a device's only recovery image, and "the check was never exercised" is not a risk worth carrying for a header parse.
+// Does this first chunk begin a MoonBase image for THIS chip? The rules live in core/FirmwareImage.h so a host test can drive them.
bool moonBaseImageRejected(const uint8_t* buf, size_t n, char* why, size_t whyLen) {
const auto info = firmware::identify(buf, n);
const char* reason = firmware::moonBaseRejection(
@@ -385,7 +475,7 @@ bool otaWriteMoonBase(FsWriteSrc src, void* user, size_t contentLen,
if (abort || first == 0) { setStatus("error: no image received"); return false; }
if (moonBaseImageRejected(buf, first, statusBuf, statusBufLen)) return false;
- // PAST THIS LINE THE DEVICE HAS NO RECOVERY IMAGE until the write completes. Erase and write in one pass: on a 4 MB board there is nowhere to stage 743 KB first (the app slot has ~520 KB free, the filesystem 548), so a second copy is not an option the hardware offers.
+ // PAST THIS LINE THE DEVICE HAS NO RECOVERY IMAGE until the write completes: @xref{updating-the-recovery-image-itself|why one pass}.
setStatus("erasing");
esp_err_t err = esp_partition_erase_range(part, 0, part->size);
if (err != ESP_OK) { setStatus("error: erase %s", esp_err_to_name(err)); return false; }
@@ -399,17 +489,17 @@ bool otaWriteMoonBase(FsWriteSrc src, void* user, size_t contentLen,
const size_t whole = eof ? ((held + 3u) & ~size_t{3}) : (held & ~size_t{3});
if (whole) {
if (eof && whole > held) std::memset(buf + held, 0xFF, whole - held);
- // Compared on the IMAGE bytes, not the padded write: at EOF `whole` rounds up past the image's end, and rejecting a slot-filling image for its own padding would be refusing something that fits. Unreachable at today's sizes; correct anyway.
+ // Compared on the IMAGE bytes: at EOF `whole` rounds past the end, and refusing a slot-filling image for its own padding would refuse something that fits.
if (written + (eof ? held : whole) > part->size) {
setStatus("error: image overruns the slot");
return false;
}
err = esp_partition_write(part, written, buf, whole);
if (err != ESP_OK) { setStatus("error: write %s", esp_err_to_name(err)); return false; }
- // The IMAGE grew by what it held, not by the padding: a padded tail is flash the image does not occupy, and counting it would report more written than was sent.
+ // The IMAGE grew by what it held, never by the padding.
written += static_cast(eof ? held : whole);
*bytesReadOut = written;
- // The counts ride the STATUS, the way MoonBase's own page reports them: one channel for the UI to read, and the overlay draws its bar from a string it already polls.
+ // The counts ride the STATUS, the way MoonBase's own page reports them.
if (contentLen) {
setStatus("writing MoonBase: %u of %u bytes",
static_cast(written), static_cast(contentLen));
@@ -440,15 +530,13 @@ bool otaWriteMoonBase(FsWriteSrc src, void* user, size_t contentLen,
setStatus("error: MoonBase did not verify, retry before rebooting");
return false;
}
- // No reboot and no boot-partition change: the app keeps running, and the new MoonBase is simply what the device falls back to from now on.
+ // No reboot and no boot-partition change: the app keeps running, and the new MoonBase is what the device falls back to from now on.
setStatus("idle");
return true;
#undef setStatus
}
-// Pull a recovery image from a URL into the same writer the upload path uses.
-// The update interface cannot serve it, targeting only its own subtypes and picking the partition itself.
-// So the download is driven by hand through the same producer callback: one writer, one set of checks, two sources.
+// Pull a recovery image from a URL into the same writer the upload path uses: @xref{a-url-pull-reuses-the-upload-writer|why by hand}.
struct UrlPull {
esp_http_client_handle_t client;
bool failed;
@@ -504,7 +592,7 @@ bool moonBaseFetchUrlSync(const char* url, char* statusBuf, size_t statusBufLen,
#undef setStatus
}
-// The install runs on its own task so the HTTP request can answer 202 immediately, exactly as the app's URL install does. That is not a detail: while the request is open the browser cannot poll for progress, so a synchronous install can only ever report "installing" and then "installed". Same task shape, same status buffer, same byte counters, so ONE progress display serves both.
+// Its own task, so the HTTP request answers 202 at once: @xref{why-the-install-runs-on-its-own-task|what a synchronous install costs}.
void moonBaseUrlTask(void* arg) {
auto* p = static_cast(arg);
moonBaseFetchUrlSync(p->url, p->statusBuf, p->statusBufLen, p->bytesReadOut, p->bytesTotalOut);
@@ -550,10 +638,7 @@ bool otaHasMoonBase() {
return moonBasePartition() != nullptr;
}
-// Which recovery image this device carries, read from its descriptor at a fixed offset in every image, so it costs one flash read and no reboot.
-// The recovery image answers the same question about itself elsewhere, which is a different question rather than a second copy.
-// That reports what it is executing and this reports an image it is not.
-// Both carry the same version string, which is what lets a caller compare them by equality.
+// Which recovery image this device carries: @xref{reporting-on-the-recovery-image|why it is read here rather than asked of it}.
bool otaMoonBaseVersion(char* out, size_t len) {
if (!out || len == 0) return false;
out[0] = 0;
@@ -579,15 +664,13 @@ bool otaMoonBaseBuild(char* out, size_t len) {
return out[0] != 0;
}
-// The recovery slot's size, for the interface to show beside the application's figure.
-// The slot rather than the image, since the metadata call reads through a mapping arranged for the running partition and simply errored for this one, leaving the row absent.
-// What a user wants from the row is whether the slot has room, and the read above already proves an image is there.
+// The recovery SLOT's size, for the interface to show beside the application's figure.
bool otaMoonBaseSize(uint32_t* used, uint32_t* total) {
const esp_partition_t* part = moonBasePartition();
if (!part) return false;
if (total) *total = part->size;
if (used) {
- // Read the image length straight out of the header, which is a plain partition read and needs no flash mapping: the first bytes of a valid image are its header, and its segments follow. esp_partition_read is the same call the vetting path uses.
+ // The length comes straight out of the header: @xref{reading-a-header-straight-out-of-a-partition|why no mapping is needed}.
esp_image_header_t hdr = {};
*used = 0;
if (esp_partition_read(part, 0, &hdr, sizeof(hdr)) == ESP_OK &&
@@ -600,18 +683,14 @@ bool otaMoonBaseSize(uint32_t* used, uint32_t* total) {
if (seg.data_len > part->size) { off = 0; break; } // a corrupt length
off += sizeof(seg) + seg.data_len;
}
- // The segments only, so the trailing padding, checksum and hash are not counted and this reads a few dozen bytes under the file on disk.
- // Deliberate: the figure answers how full the slot is, where that is invisible.
- // And reproducing the bootloader's padding rules would be a second copy to keep in step for no gain.
+ // The segments only: @xref{reporting-on-the-recovery-image|why padding is excluded}.
if (off) *used = off < part->size ? off : part->size;
}
}
return true;
}
-// Point the bootloader at the recovery image and report whether it took; false means the table has no such partition and the device updates in place.
-// Setting it to that partition erases the selection data rather than writing a sequence number.
-// Which is what makes a power cut land back in recovery rather than in a half-written application.
+// Point the bootloader at the recovery image, false where the table has no such partition: @xref{pointing-the-bootloader-at-the-recovery-image|what a power cut then does}.
bool otaBootMoonBase() {
const esp_partition_t* part = moonBasePartition();
if (!part) return false;
diff --git a/src/platform/platform.h b/src/platform/platform.h
index ce813871..5bbaad00 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -387,11 +387,58 @@ bool encoderRunning();
/// Stop the encoder, letting it finalize the playlist first; safe with none running.
void encoderStop();
+/// The one claim slot, since there is one encoder. Shared by every caller through the accessors below.
+inline const void*& encoderOwnerSlot() {
+ static const void* owner = nullptr;
+ return owner;
+}
+
+/// Which module holds the encoder, null where none does. For a status line, never for a decision.
+inline const void* encoderOwner() { return encoderOwnerSlot(); }
+
+/// Claim the one encoder for `owner`, false where another module already holds it.
+inline bool encoderClaim(const void* owner) {
+ if (!owner) return false;
+ const void* held = encoderOwnerSlot();
+ if (held && held != owner) return false; // another module is streaming: refused, not stolen
+ encoderOwnerSlot() = owner;
+ return true;
+}
+
+/// Release the claim where `owner` holds it, and stop the encoder; a non-holder is ignored.
+inline void encoderRelease(const void* owner) {
+ if (!owner || encoderOwnerSlot() != owner) return; // never release another module's claim
+ encoderOwnerSlot() = nullptr;
+ encoderStop();
+}
+
/// Serve an HLS file the platform holds in RAM; false where this platform writes segments to disk.
bool hlsSegment(const char* name, const uint8_t** data, size_t* len);
/// Release what hlsSegment handed out, required after every call that answered true.
void hlsSegmentRelease();
+// --- The encoder is claimed, never shared ------------------------------------------------------
+// One instance per target, so a second encoderStart would silently reconfigure the first driver's stream, and the claim below makes that visible. Unsynchronised: both callers claim from prepare(), on the render thread.
+
+// --- RTSP output, gated by `hasRtsp`: the encoded frame itself, before any muxing ---------------
+
+/// One encoded frame as the encoder produced it, valid until the encoder writes the next.
+struct EncodedFrame {
+ const uint8_t* nal; ///< the frame's NAL units, Annex B, start codes included
+ size_t len; ///< bytes at `nal`
+ uint32_t pts90; ///< presentation time in the RTP clock's 90 kHz units
+ bool keyframe; ///< an IDR, which a joining client decodes from
+};
+
+/// The frame the encoder produced since the last take, valid until the next `encoderWrite`.
+bool rtspTakeFrame(EncodedFrame* out);
+
+/// Release what `rtspTakeFrame` handed out, required after every call that answered true: the frame stays valid until then, and the encoder reuses the buffer after.
+void rtspReleaseFrame();
+
+/// The encoder's parameter sets where it emits them separately, false where each keyframe carries its own.
+bool rtspParameterSets(EncodedFrame* sps, EncodedFrame* pps);
+
#ifndef ESP_PLATFORM
/// Record instead of encoding, or force the not-installed path, since CI has no ffmpeg.
enum class EncoderTestMode : uint8_t { Off, Record, ForceMissing };
@@ -490,10 +537,11 @@ void mdnsShutdown();
/// Store the DHCP hostname the next bring-up advertises; call it before ethInit or wifiStaInit.
void setHostname(const char* name);
-/// Fetch a firmware image from `url` and flash it to the next OTA partition, returning at once.
+/// Fetch a firmware image from `url` and flash it to the next OTA partition, returning at once; `fallbackUrl` is tried where the first cannot be opened.
bool http_fetch_to_ota(const char* url,
char* statusBuf, size_t statusBufLen,
- uint32_t* bytesReadOut, uint32_t* bytesTotalOut);
+ uint32_t* bytesReadOut, uint32_t* bytesTotalOut,
+ const char* fallbackUrl = nullptr);
/// Flash a firmware image streamed from `src`, on fsWriteStream's producer shape; true once the boot pointer flipped.
bool otaWriteStream(FsWriteSrc src, void* user, size_t contentLen,
@@ -625,6 +673,9 @@ class TcpConnection {
bool valid() const { return fd_ >= 0; }
/// Read without blocking: bytes copied, 0 when the peer closed, -1 when nothing is pending.
int read(uint8_t* buf, size_t maxLen);
+
+ /// The connected peer's IPv4 address, which a second channel back to it is addressed by.
+ bool peerIPv4(uint8_t out[4]) const;
/// Write every byte, blocking until it is sent, which an HTTP response needs.
bool write(const uint8_t* data, size_t len);
// The caller advances its own offset and calls again, streaming across ticks without blocking.
diff --git a/src/ui/app.js b/src/ui/app.js
index 1c7cf322..2ca66fec 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -4392,17 +4392,22 @@ function fmtProgressLabel(ctrl) {
// updateModuleControls) have to agree on which values become links: a rule applied in only one
// of them shows a link that the next state push replaces with plain text.
function isUrlValue(v) {
- return typeof v === "string" && (v.startsWith("/") || /^https?:\/\//.test(v));
+ return typeof v === "string" &&
+ (v.startsWith("/") || /^https?:\/\//.test(v) || v.startsWith("rtsp://"));
}
// Point a link at `value` and show it ABSOLUTE. The stored value is device-relative, but a user
// reading the card wants the address they could paste into a player, and `a.href` resolves that
// against the current origin for us.
+// A non-http scheme resolves against nothing, so `rtsp://:554/` names its port and leaves the host
+// to us: the device has several addresses and cannot know which one this reader used, while the
+// page was loaded from exactly that one.
function setUrlDisplay(a, value) {
- const v = value ?? "";
+ let v = value ?? "";
+ if (v.startsWith("rtsp://:")) v = "rtsp://" + location.hostname + v.slice("rtsp://".length);
if (a.getAttribute("href") === v) return; // unchanged: leave the DOM alone
a.setAttribute("href", v);
- a.textContent = a.href;
+ a.textContent = v; // an unknown scheme leaves a.href untouched, so show what we built
}
function fmtDisplayInt(ctrl) {
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 14fc01cf..98765436 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -42,6 +42,7 @@ add_executable(mm_tests
unit/core/unit_FilesystemModule_subtree.cpp
unit/core/unit_FirmwareUpdateModule.cpp
unit/core/unit_FirmwareImage.cpp
+ unit/core/unit_H264Bitstream.cpp
unit/core/unit_AllocTracking.cpp
unit/core/unit_HttpServerModule_apply.cpp
unit/core/unit_ImprovFrame.cpp
@@ -92,6 +93,8 @@ add_executable(mm_tests
unit/light/unit_NdiDriver.cpp
unit/light/unit_HlsDriver.cpp
unit/light/unit_MpegTs.cpp
+ unit/light/unit_RtpH264.cpp
+ unit/light/unit_RtspSession.cpp
unit/light/unit_PanelCardDriver.cpp
unit/light/unit_PanelCardDriver_packet.cpp
unit/light/unit_WledAudioSyncPacket.cpp
diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json
index ad54d90e..fe6b0e02 100644
--- a/test/scenarios/light/scenario_Audio_mutation.json
+++ b/test/scenarios/light/scenario_Audio_mutation.json
@@ -109,9 +109,9 @@
"min": 16,
"max": 39,
"n": 32,
- "samples": [16, 16, 20, 16, 20, 16, 19, 18, 21, 16, 16, 20, 16, 16, 17, 39, 17, 20, 20, 17, 16, 16, 16, 17, 16, 17, 17, 17, 20, 20, 20, 17]
+ "samples": [16, 20, 16, 19, 18, 21, 16, 16, 20, 16, 16, 17, 39, 17, 20, 20, 17, 16, 16, 16, 17, 16, 17, 17, 17, 20, 20, 20, 17, 18, 18, 17]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -206,9 +206,9 @@
"min": 17,
"max": 186,
"n": 32,
- "samples": [25, 19, 19, 21, 18, 20, 21, 18, 20, 17, 43, 41, 27, 25, 23, 26, 36, 17, 20, 186, 21, 33, 21, 18, 26, 20, 18, 17, 39, 26, 20, 41]
+ "samples": [21, 18, 20, 21, 18, 20, 17, 43, 41, 27, 25, 23, 26, 36, 17, 20, 186, 21, 33, 21, 18, 26, 20, 18, 17, 39, 26, 20, 41, 35, 18, 20]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -315,14 +315,14 @@
"observed": {
"desktop-macos": {
"tick_us": {
- "p50": 20,
- "p95": 48,
+ "p50": 19,
+ "p95": 96,
"min": 17,
"max": 109,
"n": 32,
- "samples": [28, 18, 20, 17, 19, 19, 21, 19, 20, 17, 19, 34, 24, 19, 20, 30, 30, 18, 23, 109, 29, 32, 19, 17, 19, 19, 18, 17, 26, 48, 20, 36]
+ "samples": [17, 19, 19, 21, 19, 20, 17, 19, 34, 24, 19, 20, 30, 30, 18, 23, 109, 29, 32, 19, 17, 19, 19, 18, 17, 26, 48, 20, 36, 96, 17, 17]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -413,13 +413,13 @@
"desktop-macos": {
"tick_us": {
"p50": 22,
- "p95": 38,
+ "p95": 41,
"min": 18,
"max": 61,
"n": 32,
- "samples": [38, 22, 20, 18, 24, 24, 22, 23, 21, 22, 20, 61, 21, 21, 23, 34, 28, 22, 21, 21, 27, 28, 20, 19, 20, 21, 23, 18, 26, 29, 23, 25]
+ "samples": [18, 24, 24, 22, 23, 21, 22, 20, 61, 21, 21, 23, 34, 28, 22, 21, 21, 27, 28, 20, 19, 20, 21, 23, 18, 26, 29, 23, 25, 41, 19, 22]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -512,9 +512,9 @@
"min": 18,
"max": 50,
"n": 32,
- "samples": [26, 21, 20, 18, 35, 20, 23, 21, 21, 22, 21, 22, 19, 19, 20, 21, 50, 22, 19, 19, 40, 21, 19, 21, 23, 21, 22, 21, 21, 19, 23, 19]
+ "samples": [18, 35, 20, 23, 21, 21, 22, 21, 22, 19, 19, 20, 21, 50, 22, 19, 19, 40, 21, 19, 21, 23, 21, 22, 21, 21, 19, 23, 19, 19, 22, 24]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -607,9 +607,9 @@
"min": 16,
"max": 23,
"n": 32,
- "samples": [18, 20, 20, 17, 21, 21, 19, 21, 20, 20, 20, 19, 17, 17, 18, 21, 23, 20, 22, 18, 19, 18, 17, 20, 19, 17, 20, 20, 19, 16, 20, 17]
+ "samples": [17, 21, 21, 19, 21, 20, 20, 20, 19, 17, 17, 18, 21, 23, 20, 22, 18, 19, 18, 17, 20, 19, 17, 20, 20, 19, 16, 20, 17, 17, 20, 20]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json
index 70e33235..439c51ce 100644
--- a/test/scenarios/light/scenario_Driver_mutation.json
+++ b/test/scenarios/light/scenario_Driver_mutation.json
@@ -76,14 +76,14 @@
"observed": {
"desktop-macos": {
"tick_us": {
- "p50": 20,
+ "p50": 19,
"p95": 30,
"min": 16,
"max": 46,
"n": 32,
- "samples": [20, 16, 20, 20, 46, 23, 20, 19, 16, 20, 20, 17, 17, 17, 17, 19, 19, 19, 19, 23, 21, 30, 17, 20, 17, 17, 20, 16, 20, 20, 20, 21]
+ "samples": [20, 46, 23, 20, 19, 16, 20, 20, 17, 17, 17, 17, 19, 19, 19, 19, 23, 21, 30, 17, 20, 17, 17, 20, 16, 20, 20, 20, 21, 17, 18, 17]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -174,13 +174,13 @@
"desktop-macos": {
"tick_us": {
"p50": 20,
- "p95": 23,
+ "p95": 38,
"min": 16,
- "max": 38,
+ "max": 41,
"n": 32,
- "samples": [20, 16, 20, 19, 38, 19, 20, 21, 16, 20, 21, 17, 19, 16, 19, 20, 20, 20, 20, 21, 21, 23, 20, 20, 17, 18, 20, 17, 20, 20, 20, 21]
+ "samples": [19, 38, 19, 20, 21, 16, 20, 21, 17, 19, 16, 19, 20, 20, 20, 20, 21, 21, 23, 20, 20, 17, 18, 20, 17, 20, 20, 20, 21, 41, 17, 18]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -271,13 +271,13 @@
"desktop-macos": {
"tick_us": {
"p50": 19,
- "p95": 21,
+ "p95": 28,
"min": 16,
- "max": 22,
+ "max": 40,
"n": 32,
- "samples": [20, 18, 20, 19, 22, 17, 19, 19, 19, 20, 20, 16, 17, 17, 20, 20, 18, 19, 18, 17, 18, 16, 20, 20, 19, 21, 20, 20, 20, 20, 19, 21]
+ "samples": [19, 22, 17, 19, 19, 19, 20, 20, 16, 17, 17, 20, 20, 18, 19, 18, 17, 18, 16, 20, 20, 19, 21, 20, 20, 20, 20, 19, 21, 28, 21, 40]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -366,13 +366,13 @@
"desktop-macos": {
"tick_us": {
"p50": 20,
- "p95": 21,
+ "p95": 22,
"min": 16,
"max": 29,
"n": 32,
- "samples": [20, 20, 20, 19, 19, 29, 20, 20, 19, 20, 21, 16, 17, 17, 20, 20, 16, 19, 19, 18, 21, 17, 20, 20, 21, 19, 20, 20, 19, 20, 20, 21]
+ "samples": [19, 19, 29, 20, 20, 19, 20, 21, 16, 17, 17, 20, 20, 16, 19, 19, 18, 21, 17, 20, 20, 21, 19, 20, 20, 19, 20, 20, 21, 21, 20, 22]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -465,9 +465,9 @@
"min": 16,
"max": 28,
"n": 32,
- "samples": [20, 20, 20, 19, 20, 18, 19, 19, 20, 20, 20, 16, 17, 16, 20, 19, 17, 19, 19, 18, 19, 19, 20, 28, 21, 22, 20, 20, 20, 20, 20, 21]
+ "samples": [19, 20, 18, 19, 19, 20, 20, 20, 16, 17, 16, 20, 19, 17, 19, 19, 18, 19, 19, 20, 28, 21, 22, 20, 20, 20, 20, 20, 21, 21, 19, 21]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Effects_composition.json b/test/scenarios/light/scenario_Effects_composition.json
index 94f7423c..4c798069 100644
--- a/test/scenarios/light/scenario_Effects_composition.json
+++ b/test/scenarios/light/scenario_Effects_composition.json
@@ -106,14 +106,14 @@
"observed": {
"desktop-macos": {
"tick_us": {
- "p50": 144,
- "p95": 160,
+ "p50": 145,
+ "p95": 184,
"min": 141,
"max": 656,
"n": 32,
- "samples": [144, 144, 144, 145, 147, 143, 158, 656, 144, 144, 144, 143, 148, 150, 144, 142, 147, 146, 152, 143, 149, 160, 145, 142, 144, 145, 142, 141, 143, 143, 144, 148]
+ "samples": [145, 147, 143, 158, 656, 144, 144, 144, 143, 148, 150, 144, 142, 147, 146, 152, 143, 149, 160, 145, 142, 144, 145, 142, 141, 143, 143, 144, 148, 184, 145, 147]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Layer_base_pipeline.json b/test/scenarios/light/scenario_Layer_base_pipeline.json
index 1508acc5..5324c41e 100644
--- a/test/scenarios/light/scenario_Layer_base_pipeline.json
+++ b/test/scenarios/light/scenario_Layer_base_pipeline.json
@@ -83,14 +83,14 @@
"observed": {
"desktop-macos": {
"tick_us": {
- "p50": 70,
+ "p50": 69,
"p95": 74,
"min": 63,
"max": 87,
"n": 32,
- "samples": [71, 67, 71, 68, 70, 66, 71, 70, 70, 70, 66, 87, 70, 69, 71, 67, 70, 74, 70, 69, 69, 71, 72, 70, 69, 64, 63, 67, 71, 68, 69, 72]
+ "samples": [68, 70, 66, 71, 70, 70, 70, 66, 87, 70, 69, 71, 67, 70, 74, 70, 69, 69, 71, 72, 70, 69, 64, 63, 67, 71, 68, 69, 72, 68, 69, 68]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json
index ec328863..19d073e1 100644
--- a/test/scenarios/light/scenario_Layouts_mutation.json
+++ b/test/scenarios/light/scenario_Layouts_mutation.json
@@ -83,9 +83,9 @@
"min": 16,
"max": 45,
"n": 32,
- "samples": [17, 17, 18, 16, 16, 16, 17, 19, 17, 16, 45, 37, 16, 16, 17, 20, 16, 17, 16, 18, 16, 16, 17, 16, 16, 17, 16, 16, 16, 16, 16, 18]
+ "samples": [16, 16, 16, 17, 19, 17, 16, 45, 37, 16, 16, 17, 20, 16, 17, 16, 18, 16, 16, 17, 16, 16, 17, 16, 16, 16, 16, 16, 18, 22, 16, 20]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -211,9 +211,9 @@
"min": 44,
"max": 77,
"n": 32,
- "samples": [50, 58, 51, 49, 47, 48, 49, 53, 56, 49, 69, 52, 49, 47, 51, 77, 50, 49, 50, 50, 47, 50, 51, 49, 50, 44, 49, 46, 49, 46, 49, 53]
+ "samples": [49, 47, 48, 49, 53, 56, 49, 69, 52, 49, 47, 51, 77, 50, 49, 50, 50, 47, 50, 51, 49, 50, 44, 49, 46, 49, 46, 49, 53, 56, 50, 52]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -330,13 +330,13 @@
"desktop-macos": {
"tick_us": {
"p50": 93,
- "p95": 100,
+ "p95": 104,
"min": 90,
- "max": 104,
+ "max": 118,
"n": 32,
- "samples": [94, 92, 97, 91, 93, 92, 93, 95, 99, 93, 104, 99, 92, 92, 90, 90, 93, 100, 93, 93, 92, 93, 96, 93, 93, 91, 92, 93, 92, 93, 94, 96]
+ "samples": [91, 93, 92, 93, 95, 99, 93, 104, 99, 92, 92, 90, 90, 93, 100, 93, 93, 92, 93, 96, 93, 93, 91, 92, 93, 92, 93, 94, 96, 118, 94, 94]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -452,13 +452,13 @@
"desktop-macos": {
"tick_us": {
"p50": 20,
- "p95": 21,
+ "p95": 22,
"min": 16,
"max": 23,
"n": 32,
- "samples": [19, 19, 21, 20, 20, 19, 20, 20, 17, 20, 17, 20, 19, 20, 23, 16, 19, 17, 20, 16, 21, 20, 20, 20, 20, 17, 16, 18, 19, 20, 20, 21]
+ "samples": [20, 20, 19, 20, 20, 17, 20, 17, 20, 19, 20, 23, 16, 19, 17, 20, 16, 21, 20, 20, 20, 20, 17, 16, 18, 19, 20, 20, 21, 22, 20, 20]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_MoonLiveEffect_controls.json b/test/scenarios/light/scenario_MoonLiveEffect_controls.json
index 064f6758..f6f39e0a 100644
--- a/test/scenarios/light/scenario_MoonLiveEffect_controls.json
+++ b/test/scenarios/light/scenario_MoonLiveEffect_controls.json
@@ -572,10 +572,10 @@
"p95": 6,
"min": 1,
"max": 10,
- "n": 20,
- "samples": [2, 1, 3, 1, 10, 1, 1, 6, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1]
+ "n": 21,
+ "samples": [2, 1, 3, 1, 10, 1, 1, 6, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1]
},
- "last_updated": "2026-09-07"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -730,10 +730,10 @@
"p95": 5,
"min": 1,
"max": 7,
- "n": 20,
- "samples": [1, 2, 3, 5, 1, 1, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+ "n": 21,
+ "samples": [1, 2, 3, 5, 1, 1, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
},
- "last_updated": "2026-09-07"
+ "last_updated": "2026-09-22"
},
"esp32": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json
index 3f7866f6..ef841993 100644
--- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json
+++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json
@@ -93,9 +93,9 @@
"min": 5,
"max": 6,
"n": 32,
- "samples": [5, 5, 5, 5, 5, 6, 5, 5, 6, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 6, 5, 5, 6, 5, 5]
+ "samples": [5, 5, 6, 5, 5, 6, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -211,9 +211,9 @@
"min": 4,
"max": 6,
"n": 32,
- "samples": [5, 5, 5, 5, 4, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 4, 5, 6, 5, 5, 5, 5, 5]
+ "samples": [5, 4, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 4, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -321,9 +321,9 @@
"min": 4,
"max": 6,
"n": 32,
- "samples": [5, 5, 5, 5, 4, 5, 6, 6, 6, 5, 5, 5, 5, 5, 5, 4, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5]
+ "samples": [5, 4, 5, 6, 6, 6, 5, 5, 5, 5, 5, 5, 4, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -427,13 +427,13 @@
"desktop-macos": {
"tick_us": {
"p50": 5,
- "p95": 6,
+ "p95": 7,
"min": 5,
"max": 7,
"n": 32,
- "samples": [5, 5, 5, 5, 5, 5, 6, 6, 6, 5, 5, 6, 5, 5, 5, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5]
+ "samples": [5, 5, 5, 6, 6, 6, 5, 5, 6, 5, 5, 5, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 7, 5, 6]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -534,9 +534,9 @@
"min": 4,
"max": 6,
"n": 32,
- "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 4, 4, 5, 5]
+ "samples": [5, 5, 5, 5, 5, 5, 5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 4, 4, 5, 5, 5, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -637,9 +637,9 @@
"min": 4,
"max": 10,
"n": 32,
- "samples": [5, 5, 5, 6, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 4, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 5, 5]
+ "samples": [6, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 10, 4, 6, 7, 7, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 5, 5, 6, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -738,11 +738,11 @@
"p50": 5,
"p95": 6,
"min": 4,
- "max": 6,
+ "max": 8,
"n": 32,
- "samples": [5, 5, 5, 5, 4, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 4, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5]
+ "samples": [5, 4, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 4, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 5, 8, 6, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
}
}
},
@@ -760,11 +760,11 @@
"p50": 5,
"p95": 6,
"min": 4,
- "max": 6,
+ "max": 8,
"n": 32,
- "samples": [5, 5, 5, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 6, 5, 5, 5]
+ "samples": [5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 6, 5, 5, 5, 8, 5, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
}
}
},
@@ -784,9 +784,9 @@
"min": 4,
"max": 8,
"n": 32,
- "samples": [5, 5, 5, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 8, 5, 5, 5, 6, 5]
+ "samples": [5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 5, 6, 5, 5, 6, 5, 5, 5, 8, 5, 5, 5, 6, 5, 6, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
}
}
},
@@ -804,9 +804,9 @@
"min": 5,
"max": 6,
"n": 32,
- "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 6, 5, 5, 5, 5, 5]
+ "samples": [5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 6, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 5, 6, 5, 5, 5, 5, 5, 6, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -907,9 +907,9 @@
"min": 4,
"max": 9,
"n": 32,
- "samples": [5, 5, 5, 5, 4, 5, 4, 7, 5, 5, 5, 5, 5, 5, 5, 9, 5, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5]
+ "samples": [5, 4, 5, 4, 7, 5, 5, 5, 5, 5, 5, 5, 9, 5, 5, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -1010,9 +1010,9 @@
"min": 4,
"max": 6,
"n": 32,
- "samples": [5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 5, 5]
+ "samples": [5, 5, 5, 5, 5, 5, 5, 5, 6, 5, 5, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 5, 5, 5, 5, 5]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_modifier_chain.json b/test/scenarios/light/scenario_modifier_chain.json
index 09a74331..074b6e58 100644
--- a/test/scenarios/light/scenario_modifier_chain.json
+++ b/test/scenarios/light/scenario_modifier_chain.json
@@ -103,13 +103,13 @@
"desktop-macos": {
"tick_us": {
"p50": 10,
- "p95": 11,
+ "p95": 14,
"min": 8,
"max": 27,
"n": 32,
- "samples": [8, 10, 10, 9, 10, 8, 10, 9, 11, 9, 10, 10, 10, 9, 10, 8, 10, 27, 9, 10, 10, 8, 10, 10, 10, 8, 8, 10, 10, 10, 10, 10]
+ "samples": [9, 10, 8, 10, 9, 11, 9, 10, 10, 10, 9, 10, 8, 10, 27, 9, 10, 10, 8, 10, 10, 10, 8, 8, 10, 10, 10, 10, 10, 14, 8, 9]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -167,9 +167,9 @@
"min": 6,
"max": 11,
"n": 32,
- "samples": [7, 9, 7, 9, 9, 6, 9, 9, 9, 9, 9, 9, 9, 8, 9, 7, 9, 11, 9, 9, 9, 9, 8, 9, 10, 7, 9, 8, 9, 9, 10, 9]
+ "samples": [9, 9, 6, 9, 9, 9, 9, 9, 9, 9, 8, 9, 7, 9, 11, 9, 9, 9, 9, 8, 9, 10, 7, 9, 8, 9, 9, 10, 9, 9, 7, 7]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -223,11 +223,11 @@
"p50": 24,
"p95": 25,
"min": 21,
- "max": 25,
+ "max": 26,
"n": 32,
- "samples": [22, 24, 21, 25, 25, 21, 24, 25, 24, 24, 24, 24, 24, 24, 24, 21, 24, 24, 25, 25, 25, 24, 24, 24, 25, 21, 23, 24, 24, 24, 24, 25]
+ "samples": [25, 25, 21, 24, 25, 24, 24, 24, 24, 24, 24, 24, 21, 24, 24, 25, 25, 25, 24, 24, 24, 25, 21, 23, 24, 24, 24, 24, 25, 22, 24, 26]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -254,13 +254,13 @@
"desktop-macos": {
"tick_us": {
"p50": 43,
- "p95": 47,
+ "p95": 46,
"min": 35,
"max": 47,
"n": 32,
- "samples": [38, 44, 47, 42, 43, 45, 43, 45, 44, 43, 43, 43, 42, 44, 45, 37, 44, 44, 41, 43, 43, 44, 39, 45, 45, 36, 35, 46, 44, 42, 44, 47]
+ "samples": [42, 43, 45, 43, 45, 44, 43, 43, 43, 42, 44, 45, 37, 44, 44, 41, 43, 43, 44, 39, 45, 45, 36, 35, 46, 44, 42, 44, 47, 37, 44, 44]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json
index daaa3720..5e9550fc 100644
--- a/test/scenarios/light/scenario_modifier_swap.json
+++ b/test/scenarios/light/scenario_modifier_swap.json
@@ -156,9 +156,9 @@
"min": 8,
"max": 11,
"n": 32,
- "samples": [9, 8, 8, 11, 8, 8, 8, 10, 8, 8, 10, 8, 8, 8, 10, 8, 8, 8, 8, 8, 8, 8, 8, 10, 11, 8, 8, 9, 8, 8, 8, 8]
+ "samples": [11, 8, 8, 8, 10, 8, 8, 10, 8, 8, 8, 10, 8, 8, 8, 8, 8, 8, 8, 8, 10, 11, 8, 8, 9, 8, 8, 8, 8, 9, 8, 8]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32-eth": {
"tick_us": {
@@ -300,9 +300,9 @@
"min": 20,
"max": 53,
"n": 32,
- "samples": [24, 21, 21, 24, 21, 21, 24, 24, 25, 24, 24, 24, 21, 23, 25, 20, 23, 22, 22, 23, 24, 21, 53, 24, 25, 21, 21, 24, 24, 22, 20, 22]
+ "samples": [24, 21, 21, 24, 24, 25, 24, 24, 24, 21, 23, 25, 20, 23, 22, 22, 23, 24, 21, 53, 24, 25, 21, 21, 24, 24, 22, 20, 22, 24, 23, 22]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32-eth": {
"tick_us": {
@@ -444,9 +444,9 @@
"min": 8,
"max": 16,
"n": 32,
- "samples": [10, 10, 8, 8, 10, 10, 10, 10, 11, 10, 10, 10, 8, 10, 10, 9, 10, 11, 10, 11, 10, 10, 16, 10, 10, 8, 11, 10, 10, 10, 10, 10]
+ "samples": [8, 10, 10, 10, 10, 11, 10, 10, 10, 8, 10, 10, 9, 10, 11, 10, 11, 10, 10, 16, 10, 10, 8, 11, 10, 10, 10, 10, 10, 8, 10, 10]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32-eth": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json
index a0a104b9..b24563c9 100644
--- a/test/scenarios/light/scenario_perf_full.json
+++ b/test/scenarios/light/scenario_perf_full.json
@@ -90,9 +90,9 @@
"min": 1,
"max": 3,
"n": 32,
- "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+ "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -210,9 +210,9 @@
"min": 1,
"max": 3,
"n": 32,
- "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+ "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -326,13 +326,13 @@
"desktop-macos": {
"tick_us": {
"p50": 1,
- "p95": 1,
+ "p95": 2,
"min": 1,
"max": 3,
"n": 32,
- "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+ "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -573,9 +573,9 @@
"min": 1,
"max": 3,
"n": 32,
- "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+ "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -693,7 +693,7 @@
"n": 32,
"samples": [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]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -822,7 +822,7 @@
"n": 32,
"samples": [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]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -955,7 +955,7 @@
"n": 32,
"samples": [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]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -1062,7 +1062,7 @@
"n": 32,
"samples": [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]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32p4rev1-eth": {
"tick_us": {
@@ -1175,7 +1175,7 @@
"n": 32,
"samples": [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]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -1297,9 +1297,9 @@
"min": 4,
"max": 6,
"n": 32,
- "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -1419,11 +1419,11 @@
"p50": 18,
"p95": 19,
"min": 17,
- "max": 79,
+ "max": 19,
"n": 32,
- "samples": [18, 79, 17, 18, 18, 19, 18, 18, 19, 18, 17, 18, 19, 17, 18, 17, 18, 18, 19, 19, 17, 18, 18, 18, 18, 18, 17, 17, 17, 17, 18, 18]
+ "samples": [18, 18, 19, 18, 18, 19, 18, 17, 18, 19, 17, 18, 17, 18, 18, 19, 19, 17, 18, 18, 18, 18, 18, 17, 17, 17, 17, 18, 18, 19, 18, 18]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -1543,11 +1543,11 @@
"p50": 72,
"p95": 78,
"min": 70,
- "max": 80,
+ "max": 78,
"n": 32,
- "samples": [70, 80, 70, 72, 72, 77, 71, 72, 72, 75, 70, 72, 73, 70, 71, 70, 75, 71, 78, 76, 70, 73, 71, 78, 76, 73, 70, 72, 70, 70, 70, 72]
+ "samples": [72, 72, 77, 71, 72, 72, 75, 70, 72, 73, 70, 71, 70, 75, 71, 78, 76, 70, 73, 71, 78, 76, 73, 70, 72, 70, 70, 70, 72, 77, 70, 74]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -1677,9 +1677,9 @@
"min": 4,
"max": 5,
"n": 32,
- "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -1801,9 +1801,9 @@
"min": 15,
"max": 18,
"n": 32,
- "samples": [15, 16, 15, 15, 17, 18, 15, 15, 16, 17, 15, 16, 16, 15, 16, 15, 17, 15, 18, 16, 16, 16, 16, 17, 15, 16, 15, 16, 16, 15, 16, 16]
+ "samples": [15, 17, 18, 15, 15, 16, 17, 15, 16, 16, 15, 16, 15, 17, 15, 18, 16, 16, 16, 16, 17, 15, 16, 15, 16, 16, 15, 16, 16, 16, 15, 16]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -1920,14 +1920,14 @@
"observed": {
"desktop-macos": {
"tick_us": {
- "p50": 63,
+ "p50": 64,
"p95": 68,
"min": 60,
"max": 68,
"n": 32,
- "samples": [63, 63, 62, 62, 67, 67, 64, 62, 65, 67, 62, 64, 64, 63, 63, 62, 67, 62, 68, 67, 64, 65, 63, 68, 63, 65, 61, 60, 63, 62, 62, 64]
+ "samples": [62, 67, 67, 64, 62, 65, 67, 62, 64, 64, 63, 63, 62, 67, 62, 68, 67, 64, 65, 63, 68, 63, 65, 61, 60, 63, 62, 62, 64, 66, 63, 66]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -2044,14 +2044,14 @@
"observed": {
"desktop-macos": {
"tick_us": {
- "p50": 251,
+ "p50": 252,
"p95": 295,
"min": 243,
"max": 297,
"n": 32,
- "samples": [251, 251, 250, 252, 265, 297, 250, 250, 266, 295, 249, 257, 261, 251, 251, 251, 265, 251, 269, 272, 252, 262, 251, 263, 248, 265, 251, 243, 250, 250, 249, 256]
+ "samples": [252, 265, 297, 250, 250, 266, 295, 249, 257, 261, 251, 251, 251, 265, 251, 269, 272, 252, 262, 251, 263, 248, 265, 251, 243, 250, 250, 249, 256, 264, 251, 272]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -2210,7 +2210,7 @@
"n": 32,
"samples": [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]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32": {
"tick_us": {
@@ -2330,11 +2330,11 @@
"p50": 4,
"p95": 4,
"min": 4,
- "max": 4,
+ "max": 5,
"n": 32,
- "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32": {
"tick_us": {
@@ -2456,9 +2456,9 @@
"min": 15,
"max": 22,
"n": 32,
- "samples": [17, 15, 15, 15, 17, 18, 15, 15, 16, 17, 15, 16, 16, 15, 15, 15, 16, 15, 16, 22, 17, 17, 15, 15, 15, 16, 17, 15, 15, 15, 15, 16]
+ "samples": [15, 17, 18, 15, 15, 16, 17, 15, 16, 16, 15, 15, 15, 16, 15, 16, 22, 17, 17, 15, 15, 15, 16, 17, 15, 15, 15, 15, 16, 16, 15, 17]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32": {
"tick_us": {
@@ -2580,9 +2580,9 @@
"min": 60,
"max": 82,
"n": 32,
- "samples": [62, 62, 62, 63, 68, 71, 62, 62, 82, 75, 62, 64, 66, 62, 63, 62, 65, 64, 63, 70, 63, 65, 62, 62, 62, 65, 64, 60, 62, 62, 63, 64]
+ "samples": [63, 68, 71, 62, 62, 82, 75, 62, 64, 66, 62, 63, 62, 65, 64, 63, 70, 63, 65, 62, 62, 62, 65, 64, 60, 62, 62, 63, 64, 67, 66, 66]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json
index 0c87d9a1..7c2d5422 100644
--- a/test/scenarios/light/scenario_perf_light.json
+++ b/test/scenarios/light/scenario_perf_light.json
@@ -108,7 +108,7 @@
"n": 32,
"samples": [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]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -456,7 +456,7 @@
"n": 32,
"samples": [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]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -578,9 +578,9 @@
"min": 4,
"max": 6,
"n": 32,
- "samples": [4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
@@ -697,14 +697,14 @@
"observed": {
"desktop-macos": {
"tick_us": {
- "p50": 15,
+ "p50": 16,
"p95": 21,
"min": 15,
"max": 27,
"n": 32,
- "samples": [15, 15, 15, 15, 15, 16, 17, 27, 15, 15, 16, 18, 15, 16, 16, 15, 16, 15, 16, 17, 16, 16, 15, 15, 21, 17, 16, 15, 15, 15, 15, 16]
+ "samples": [15, 15, 16, 17, 27, 15, 15, 16, 18, 15, 16, 16, 15, 16, 15, 16, 17, 16, 16, 15, 15, 21, 17, 16, 15, 15, 15, 15, 16, 16, 15, 16]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32s3-n16r8": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json
index f5e2ea2a..b6a630a7 100644
--- a/test/scenarios/light/scenario_peripheral_grid_sweep.json
+++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json
@@ -176,11 +176,11 @@
"p50": 4,
"p95": 5,
"min": 4,
- "max": 15,
+ "max": 5,
"n": 32,
- "samples": [4, 4, 15, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -301,13 +301,13 @@
"desktop-macos": {
"tick_us": {
"p50": 16,
- "p95": 19,
+ "p95": 18,
"min": 15,
- "max": 122,
+ "max": 19,
"n": 32,
- "samples": [15, 16, 122, 17, 15, 16, 15, 18, 16, 16, 16, 16, 16, 16, 16, 17, 16, 19, 17, 16, 17, 16, 16, 16, 16, 16, 16, 15, 16, 16, 16, 16]
+ "samples": [17, 15, 16, 15, 18, 16, 16, 16, 16, 16, 16, 16, 17, 16, 19, 17, 16, 17, 16, 16, 16, 16, 16, 16, 15, 16, 16, 16, 16, 18, 16, 16]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -428,13 +428,13 @@
"desktop-macos": {
"tick_us": {
"p50": 64,
- "p95": 71,
+ "p95": 70,
"min": 61,
"max": 72,
"n": 32,
- "samples": [62, 64, 71, 69, 63, 62, 64, 67, 62, 64, 67, 63, 63, 63, 64, 70, 68, 72, 69, 66, 67, 63, 65, 63, 62, 69, 65, 61, 62, 69, 63, 63]
+ "samples": [69, 63, 62, 64, 67, 62, 64, 67, 63, 63, 63, 64, 70, 68, 72, 69, 66, 67, 63, 65, 63, 62, 69, 65, 61, 62, 69, 63, 63, 66, 64, 65]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -554,14 +554,14 @@
},
"desktop-macos": {
"tick_us": {
- "p50": 252,
+ "p50": 253,
"p95": 317,
"min": 242,
"max": 325,
"n": 32,
- "samples": [250, 249, 286, 278, 249, 250, 252, 253, 249, 256, 268, 250, 254, 250, 263, 252, 273, 291, 275, 325, 317, 249, 260, 248, 249, 260, 260, 242, 251, 251, 250, 261]
+ "samples": [278, 249, 250, 252, 253, 249, 256, 268, 250, 254, 250, 263, 252, 273, 291, 275, 325, 317, 249, 260, 248, 249, 260, 260, 242, 251, 251, 250, 261, 267, 249, 255]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -707,9 +707,9 @@
"min": 4,
"max": 15,
"n": 32,
- "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 15, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 15, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -834,9 +834,9 @@
"min": 15,
"max": 19,
"n": 32,
- "samples": [15, 17, 17, 17, 15, 15, 16, 15, 15, 16, 16, 15, 16, 15, 16, 16, 16, 18, 17, 19, 16, 16, 15, 15, 15, 17, 16, 15, 15, 16, 15, 16]
+ "samples": [17, 15, 15, 16, 15, 15, 16, 16, 15, 16, 15, 16, 16, 16, 18, 17, 19, 16, 16, 15, 15, 15, 17, 16, 15, 15, 16, 15, 16, 16, 15, 15]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -961,9 +961,9 @@
"min": 60,
"max": 81,
"n": 32,
- "samples": [62, 62, 67, 67, 62, 62, 62, 63, 62, 64, 67, 63, 63, 62, 68, 64, 66, 73, 67, 81, 68, 62, 62, 61, 63, 65, 63, 60, 62, 63, 62, 64]
+ "samples": [67, 62, 62, 62, 63, 62, 64, 67, 63, 63, 62, 68, 64, 66, 73, 67, 81, 68, 62, 62, 61, 63, 65, 63, 60, 62, 63, 62, 64, 65, 62, 63]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -1088,9 +1088,9 @@
"min": 243,
"max": 286,
"n": 32,
- "samples": [249, 249, 268, 267, 252, 251, 252, 251, 248, 259, 269, 251, 252, 250, 263, 250, 277, 285, 270, 286, 264, 249, 253, 249, 253, 255, 250, 243, 250, 251, 250, 267]
+ "samples": [267, 252, 251, 252, 251, 248, 259, 269, 251, 252, 250, 263, 250, 277, 285, 270, 286, 264, 249, 253, 249, 253, 255, 250, 243, 250, 251, 250, 267, 263, 252, 252]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -1238,7 +1238,7 @@
"n": 32,
"samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -1363,9 +1363,9 @@
"min": 15,
"max": 17,
"n": 32,
- "samples": [15, 16, 17, 16, 15, 15, 16, 15, 16, 16, 16, 15, 15, 15, 16, 15, 17, 17, 16, 17, 16, 16, 16, 15, 15, 16, 15, 15, 15, 15, 15, 16]
+ "samples": [16, 15, 15, 16, 15, 16, 16, 16, 15, 15, 15, 16, 15, 17, 17, 16, 17, 16, 16, 16, 15, 15, 16, 15, 15, 15, 15, 15, 16, 17, 16, 16]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -1490,9 +1490,9 @@
"min": 60,
"max": 70,
"n": 32,
- "samples": [64, 62, 66, 65, 63, 62, 63, 62, 62, 63, 66, 62, 63, 62, 67, 62, 69, 68, 65, 70, 65, 62, 62, 61, 62, 64, 61, 60, 62, 65, 62, 64]
+ "samples": [65, 63, 62, 63, 62, 62, 63, 66, 62, 63, 62, 67, 62, 69, 68, 65, 70, 65, 62, 62, 61, 62, 64, 61, 60, 62, 65, 62, 64, 67, 62, 64]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -1612,14 +1612,14 @@
},
"desktop-macos": {
"tick_us": {
- "p50": 251,
+ "p50": 252,
"p95": 278,
"min": 245,
"max": 395,
"n": 32,
- "samples": [249, 249, 254, 263, 251, 250, 251, 267, 253, 261, 265, 250, 254, 250, 265, 252, 275, 278, 255, 258, 395, 250, 248, 246, 250, 256, 245, 251, 250, 250, 249, 254]
+ "samples": [263, 251, 250, 251, 267, 253, 261, 265, 250, 254, 250, 265, 252, 275, 278, 255, 258, 395, 250, 248, 246, 250, 256, 245, 251, 250, 250, 249, 254, 256, 250, 253]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -1765,9 +1765,9 @@
"min": 4,
"max": 10,
"n": 32,
- "samples": [4, 4, 4, 4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 10, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 6, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 10, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -1892,9 +1892,9 @@
"min": 15,
"max": 32,
"n": 32,
- "samples": [15, 15, 16, 16, 15, 15, 15, 17, 15, 17, 16, 16, 15, 16, 16, 15, 17, 17, 16, 16, 17, 15, 15, 16, 15, 16, 15, 32, 15, 15, 15, 16]
+ "samples": [16, 15, 15, 15, 17, 15, 17, 16, 16, 15, 16, 16, 15, 17, 17, 16, 16, 17, 15, 15, 16, 15, 16, 15, 32, 15, 15, 15, 16, 15, 16, 16]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -2019,9 +2019,9 @@
"min": 60,
"max": 114,
"n": 32,
- "samples": [62, 62, 63, 68, 62, 64, 62, 68, 62, 65, 65, 62, 63, 62, 64, 62, 69, 67, 63, 66, 72, 61, 62, 63, 62, 64, 60, 114, 62, 62, 77, 63]
+ "samples": [68, 62, 64, 62, 68, 62, 65, 65, 62, 63, 62, 64, 62, 69, 67, 63, 66, 72, 61, 62, 63, 62, 64, 60, 114, 62, 62, 77, 63, 64, 62, 63]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
@@ -2146,9 +2146,9 @@
"min": 243,
"max": 349,
"n": 32,
- "samples": [247, 250, 264, 260, 251, 349, 250, 268, 248, 257, 266, 250, 254, 248, 262, 246, 273, 264, 254, 263, 284, 258, 248, 254, 249, 260, 243, 309, 250, 249, 324, 255]
+ "samples": [260, 251, 349, 250, 268, 248, 257, 266, 250, 254, 248, 262, 246, 273, 264, 254, 263, 284, 258, 248, 254, 249, 260, 243, 309, 250, 249, 324, 255, 250, 250, 254]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"desktop-windows": {
"tick_us": {
diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json
index fac351d0..f9fe68ca 100644
--- a/test/scenarios/light/scenario_peripheral_switch.json
+++ b/test/scenarios/light/scenario_peripheral_switch.json
@@ -179,7 +179,7 @@
"n": 32,
"samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32p4rev1-eth": {
"tick_us": {
@@ -300,7 +300,7 @@
"n": 32,
"samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32p4rev1-eth": {
"tick_us": {
@@ -421,7 +421,7 @@
"n": 32,
"samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32p4rev1-eth": {
"tick_us": {
@@ -541,7 +541,7 @@
"n": 32,
"samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32p4rev1-eth": {
"tick_us": {
@@ -660,9 +660,9 @@
"min": 4,
"max": 5,
"n": 32,
- "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32p4rev1-eth": {
"tick_us": {
@@ -797,9 +797,9 @@
"min": 4,
"max": 5,
"n": 32,
- "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
+ "samples": [4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4]
},
- "last_updated": "2026-09-21"
+ "last_updated": "2026-09-22"
},
"esp32p4rev1-eth": {
"tick_us": {
diff --git a/test/unit/core/unit_H264Bitstream.cpp b/test/unit/core/unit_H264Bitstream.cpp
new file mode 100644
index 00000000..6fce022c
--- /dev/null
+++ b/test/unit/core/unit_H264Bitstream.cpp
@@ -0,0 +1,131 @@
+/// @module H264Bitstream
+/// @also RtspDriver
+
+/// Where one encoded frame ends and the next begins. An Annex B stream carries no frame delimiter, so a reader that gets this wrong ships half a picture, which a decoder shows as a tear rather than reporting.
+
+#include "doctest.h"
+#include "core/util/H264Bitstream.h"
+
+#include
+
+namespace {
+
+/// A NAL with a 4-byte start code: `type` in the header, `firstMb` set for a picture's first slice.
+std::vector nal(uint8_t type, bool firstMb = true, size_t payload = 2) {
+ std::vector out{0, 0, 0, 1, static_cast(0x60 | type)};
+ out.push_back(firstMb ? 0x80 : 0x20); // first_mb_in_slice zero, or a later macroblock
+ for (size_t i = 0; i < payload; i++) out.push_back(0x11);
+ return out;
+}
+
+void append(std::vector& dst, const std::vector& src) {
+ dst.insert(dst.end(), src.begin(), src.end());
+}
+
+} // namespace
+
+// A VCL slice at macroblock zero opens a frame; the same type past macroblock zero continues one.
+TEST_CASE("H264Bitstream opens an access unit on a first slice alone") {
+ const auto first = nal(1, true);
+ const auto later = nal(1, false);
+ CHECK(mm::h264::opensAccessUnit(first.data() + 4, first.size() - 4));
+ CHECK_FALSE(mm::h264::opensAccessUnit(later.data() + 4, later.size() - 4));
+}
+
+// Parameter sets and SEI describe the frame that FOLLOWS, so a boundary taken at an SPS would cut that frame away from the sets that decode it.
+TEST_CASE("H264Bitstream opens no access unit on a parameter set or SEI") {
+ for (uint8_t type : {uint8_t{6}, uint8_t{7}, uint8_t{8}}) {
+ const auto n = nal(type, true);
+ CHECK_FALSE(mm::h264::opensAccessUnit(n.data() + 4, n.size() - 4));
+ }
+}
+
+// An access unit starts at the SPS rather than the slice: an IDR delivered without its parameter sets makes a decoder report a missing PPS and show nothing.
+TEST_CASE("H264Bitstream keeps the parameter sets with the frame they describe") {
+ std::vector buf;
+ append(buf, nal(7)); // SPS
+ append(buf, nal(8)); // PPS
+ append(buf, nal(6)); // SEI
+ append(buf, nal(5)); // the IDR those three describe
+ const size_t second = buf.size();
+ append(buf, nal(1)); // the next frame, which ends the first
+
+ std::vector starts;
+ mm::h264::findAccessUnits(buf.data(), buf.size(), &starts);
+ REQUIRE(starts.size() == 2);
+ CHECK(starts[0] == 0); // the SPS, never the IDR that follows it
+ CHECK(starts[1] == second);
+
+ // The frame carries all four NALs, which is what a decoder needs to show the picture.
+ CHECK(mm::h264::hasKeyframe(buf.data(), second));
+}
+
+// The boundaries of a two-frame buffer, each frame preceded by the sets that describe it.
+TEST_CASE("H264Bitstream finds each frame start in a multi-frame buffer") {
+ std::vector buf;
+ append(buf, nal(7)); // SPS, which opens frame one: it describes it
+ append(buf, nal(8)); // PPS
+ append(buf, nal(5)); // IDR: frame one
+ const size_t secondFrame = buf.size();
+ append(buf, nal(1)); // frame two
+
+ std::vector starts;
+ mm::h264::findAccessUnits(buf.data(), buf.size(), &starts);
+ REQUIRE(starts.size() == 2);
+ CHECK(starts[0] == 0);
+ CHECK(starts[1] == secondFrame);
+}
+
+// A multi-slice picture is ONE frame: only its first slice sits at macroblock zero, so the rest must not each be read as a new one.
+TEST_CASE("H264Bitstream reads a multi-slice picture as one access unit") {
+ std::vector buf;
+ append(buf, nal(1, true)); // the picture's first slice
+ append(buf, nal(1, false)); // three more slices of the SAME picture
+ append(buf, nal(1, false));
+ append(buf, nal(1, false));
+
+ std::vector starts;
+ mm::h264::findAccessUnits(buf.data(), buf.size(), &starts);
+ CHECK(starts.size() == 1);
+}
+
+// A buffer cut mid-NAL reports only what is whole: the trailing bytes are a frame still arriving, and publishing them would ship a torn picture.
+TEST_CASE("H264Bitstream ignores a trailing partial NAL") {
+ std::vector buf;
+ append(buf, nal(5));
+ buf.insert(buf.end(), {0, 0, 0, 1}); // a start code whose header byte has yet to arrive
+
+ std::vector starts;
+ mm::h264::findAccessUnits(buf.data(), buf.size(), &starts);
+ CHECK(starts.size() == 1); // the complete frame, never the truncated one
+}
+
+// An IDR is what a joining client decodes from, so a frame is reported as a keyframe on that alone.
+TEST_CASE("H264Bitstream reports a keyframe by its IDR NAL") {
+ const auto idr = nal(5);
+ const auto inter = nal(1);
+ CHECK(mm::h264::hasKeyframe(idr.data(), idr.size()));
+ CHECK_FALSE(mm::h264::hasKeyframe(inter.data(), inter.size()));
+}
+
+// Annex B allows a 3-byte start code as well as a 4-byte one, and one encoder emits both.
+TEST_CASE("H264Bitstream reads three-byte and four-byte start codes alike") {
+ std::vector buf{0, 0, 1, 0x65, 0x80, 0x11, // a 3-byte coded IDR
+ 0, 0, 0, 1, 0x61, 0x80, 0x11}; // then a 4-byte coded slice
+ std::vector starts;
+ mm::h264::findAccessUnits(buf.data(), buf.size(), &starts);
+ REQUIRE(starts.size() == 2);
+ CHECK(starts[0] == 0);
+ CHECK(starts[1] == 6);
+}
+
+// An empty or undersized buffer answers rather than reading past its end.
+TEST_CASE("H264Bitstream answers an empty buffer without reading past it") {
+ std::vector starts;
+ mm::h264::findAccessUnits(nullptr, 0, &starts);
+ const uint8_t two[2] = {0, 0};
+ mm::h264::findAccessUnits(two, sizeof(two), &starts);
+ CHECK(starts.empty());
+ CHECK_FALSE(mm::h264::opensAccessUnit(two, 1));
+ CHECK_FALSE(mm::h264::hasKeyframe(nullptr, 10));
+}
diff --git a/test/unit/light/unit_HlsDriver.cpp b/test/unit/light/unit_HlsDriver.cpp
index c57737a0..3cb7c334 100644
--- a/test/unit/light/unit_HlsDriver.cpp
+++ b/test/unit/light/unit_HlsDriver.cpp
@@ -417,3 +417,26 @@ TEST_CASE("HlsDriver resyncs after a stall without sending a duplicate frame") {
for (size_t i = 1; i < sentAt.size(); i++)
CHECK(sentAt[i] - sentAt[i - 1] >= 100u);
}
+
+// One encoder instance, so a second claimant is refused rather than silently taking the first driver's stream.
+TEST_CASE("platform encoder is claimed by one module at a time") {
+ const int driverA = 1, driverB = 2; // stand-ins for two modules' addresses
+ REQUIRE(mm::platform::encoderOwner() == nullptr);
+
+ CHECK(mm::platform::encoderClaim(&driverA));
+ CHECK(mm::platform::encoderOwner() == &driverA);
+ CHECK_FALSE(mm::platform::encoderClaim(&driverB)); // refused while A holds it
+ CHECK(mm::platform::encoderOwner() == &driverA); // and A keeps it
+
+ // A re-claims what it already owns, which is what a rebuild does.
+ CHECK(mm::platform::encoderClaim(&driverA));
+
+ // A non-holder's release is ignored, so B cannot free A's encoder.
+ mm::platform::encoderRelease(&driverB);
+ CHECK(mm::platform::encoderOwner() == &driverA);
+
+ mm::platform::encoderRelease(&driverA);
+ CHECK(mm::platform::encoderOwner() == nullptr);
+ CHECK(mm::platform::encoderClaim(&driverB)); // now B may have it
+ mm::platform::encoderRelease(&driverB);
+}
diff --git a/test/unit/light/unit_RtpH264.cpp b/test/unit/light/unit_RtpH264.cpp
new file mode 100644
index 00000000..61c69325
--- /dev/null
+++ b/test/unit/light/unit_RtpH264.cpp
@@ -0,0 +1,156 @@
+/// @module RtpH264
+/// @also RtspDriver
+
+/// The RTP packetisation an RTSP session ships H.264 over. A decoder accepts or rejects a packet on its header bits alone, so that shape is pinned here rather than by watching a player fail.
+
+#include "doctest.h"
+#include "light/util/RtpH264.h"
+
+#include
+#include
+
+namespace {
+
+/// One captured datagram, as the sink saw it.
+struct Packet {
+ std::vector bytes;
+ bool marker() const { return (bytes[1] & 0x80) != 0; }
+ uint16_t seq() const { return static_cast((bytes[2] << 8) | bytes[3]); }
+ uint32_t ts() const {
+ return (static_cast(bytes[4]) << 24) | (static_cast(bytes[5]) << 16) |
+ (static_cast(bytes[6]) << 8) | bytes[7];
+ }
+ const uint8_t* payload() const { return bytes.data() + mm::rtp::kHeaderBytes; }
+ size_t payloadLen() const { return bytes.size() - mm::rtp::kHeaderBytes; }
+};
+
+struct Capture {
+ std::vector packets;
+ size_t refuseAfter = SIZE_MAX; // the sink starts refusing once this many are written
+};
+
+bool capture(void* ctx, const uint8_t* p, size_t len) {
+ auto* c = static_cast(ctx);
+ if (c->packets.size() >= c->refuseAfter) return false;
+ c->packets.push_back(Packet{std::vector(p, p + len)});
+ return true;
+}
+
+/// An Annex B access unit: each NAL prefixed by a 4-byte start code.
+std::vector annexB(const std::vector>& nals) {
+ std::vector out;
+ for (const auto& n : nals) {
+ out.insert(out.end(), {0, 0, 0, 1});
+ out.insert(out.end(), n.begin(), n.end());
+ }
+ return out;
+}
+
+} // namespace
+
+// A NAL that fits the MTU rides alone, payload byte-for-byte: the commonest case, and the one a decoder handles without reassembly.
+TEST_CASE("RtpH264 sends a small NAL as one packet, payload unchanged") {
+ std::vector nal{0x65, 0xAA, 0xBB, 0xCC}; // an IDR slice, four bytes
+ const auto au = annexB({nal});
+ uint8_t scratch[mm::rtp::kMaxPacketBytes];
+ Capture cap;
+ mm::rtp::Packetiser p(0x1234ABCD, 7);
+
+ const size_t n = p.writeAccessUnit(au.data(), au.size(), 900, scratch, sizeof(scratch), capture, &cap);
+ REQUIRE(n == 1);
+ REQUIRE(cap.packets.size() == 1);
+ CHECK(cap.packets[0].payloadLen() == nal.size());
+ CHECK(std::memcmp(cap.packets[0].payload(), nal.data(), nal.size()) == 0);
+ CHECK(cap.packets[0].seq() == 7);
+ CHECK(cap.packets[0].ts() == 900);
+}
+
+// The marker names the END of an access unit, and a timestamp says when a frame is DISPLAYED, so every packet of one frame shares one.
+TEST_CASE("RtpH264 marks the last packet of an access unit and no other") {
+ const auto au = annexB({{0x67, 0x01}, {0x68, 0x02}, {0x65, 0x03, 0x04}}); // SPS, PPS, IDR
+ uint8_t scratch[mm::rtp::kMaxPacketBytes];
+ Capture cap;
+ mm::rtp::Packetiser p(1, 0);
+
+ const size_t n = p.writeAccessUnit(au.data(), au.size(), 4500, scratch, sizeof(scratch), capture, &cap);
+ REQUIRE(n == 3);
+ CHECK_FALSE(cap.packets[0].marker());
+ CHECK_FALSE(cap.packets[1].marker());
+ CHECK(cap.packets[2].marker());
+ for (const auto& q : cap.packets) CHECK(q.ts() == 4500);
+}
+
+// An oversized NAL fragments into FU-A, and a decoder rebuilds it from those alone, so the pieces must reassemble byte for byte.
+TEST_CASE("RtpH264 fragments an oversized NAL and the pieces reassemble") {
+ std::vector nal(4000);
+ nal[0] = 0x65; // an IDR, so nri and type are both non-zero
+ for (size_t i = 1; i < nal.size(); i++) nal[i] = static_cast(i & 0xFF);
+ const auto au = annexB({nal});
+ uint8_t scratch[mm::rtp::kMaxPacketBytes];
+ Capture cap;
+ mm::rtp::Packetiser p(2, 100);
+
+ const size_t n = p.writeAccessUnit(au.data(), au.size(), 90, scratch, sizeof(scratch), capture, &cap);
+ REQUIRE(n > 1); // it did fragment
+
+ // Start and end bits land on the first and last fragment, and nowhere else.
+ CHECK((cap.packets.front().payload()[1] & 0x80) != 0);
+ CHECK((cap.packets.back().payload()[1] & 0x40) != 0);
+ for (size_t i = 0; i < cap.packets.size(); i++) {
+ const uint8_t ind = cap.packets[i].payload()[0];
+ CHECK((ind & 0x1F) == 28); // every fragment is FU-A
+ CHECK((ind & 0xE0) == (nal[0] & 0xE0)); // nri survives
+ CHECK((cap.packets[i].payload()[1] & 0x1F) == (nal[0] & 0x1F)); // so does the type
+ }
+
+ // Reassembly: the original header byte, then every fragment's payload past its two FU bytes.
+ std::vector rebuilt{nal[0]};
+ for (const auto& q : cap.packets)
+ rebuilt.insert(rebuilt.end(), q.payload() + 2, q.payload() + q.payloadLen());
+ CHECK(rebuilt.size() == nal.size());
+ CHECK(std::memcmp(rebuilt.data(), nal.data(), nal.size()) == 0);
+}
+
+// The sequence advances by one per PACKET across frames, which is how a receiver detects loss. A per-frame counter would read every fragmented frame as a gap.
+TEST_CASE("RtpH264 advances the sequence per packet across access units") {
+ const auto au = annexB({{0x41, 0x01}});
+ uint8_t scratch[mm::rtp::kMaxPacketBytes];
+ Capture cap;
+ mm::rtp::Packetiser p(3, 65534); // starts near the wrap
+
+ p.writeAccessUnit(au.data(), au.size(), 0, scratch, sizeof(scratch), capture, &cap);
+ p.writeAccessUnit(au.data(), au.size(), 3000, scratch, sizeof(scratch), capture, &cap);
+ p.writeAccessUnit(au.data(), au.size(), 6000, scratch, sizeof(scratch), capture, &cap);
+ REQUIRE(cap.packets.size() == 3);
+ CHECK(cap.packets[0].seq() == 65534);
+ CHECK(cap.packets[1].seq() == 65535);
+ CHECK(cap.packets[2].seq() == 0); // wraps, as a 16-bit counter does
+}
+
+// A refused sink abandons the whole frame: a torn access unit costs a decoder more than a missing one, which it skips.
+TEST_CASE("RtpH264 abandons an access unit whose sink refuses") {
+ std::vector big(4000, 0x11);
+ big[0] = 0x65;
+ const auto au = annexB({big});
+ uint8_t scratch[mm::rtp::kMaxPacketBytes];
+ Capture cap;
+ cap.refuseAfter = 1; // the second packet is refused
+ mm::rtp::Packetiser p(4, 0);
+
+ const size_t n = p.writeAccessUnit(au.data(), au.size(), 0, scratch, sizeof(scratch), capture, &cap);
+ CHECK(n == 0); // reported as written-nothing
+}
+
+// Annex B allows a 3-byte start code as well as a 4-byte one, and a hardware encoder emits both.
+TEST_CASE("RtpH264 reads three-byte and four-byte start codes alike") {
+ std::vector au{0, 0, 1, 0x67, 0xAA, 0, 0, 0, 1, 0x65, 0xBB};
+ uint8_t scratch[mm::rtp::kMaxPacketBytes];
+ Capture cap;
+ mm::rtp::Packetiser p(5, 0);
+
+ const size_t n = p.writeAccessUnit(au.data(), au.size(), 0, scratch, sizeof(scratch), capture, &cap);
+ REQUIRE(n == 2);
+ CHECK(cap.packets[0].payload()[0] == 0x67);
+ CHECK(cap.packets[1].payload()[0] == 0x65);
+ CHECK(cap.packets[1].marker());
+}
diff --git a/test/unit/light/unit_RtspSession.cpp b/test/unit/light/unit_RtspSession.cpp
new file mode 100644
index 00000000..f8f82f04
--- /dev/null
+++ b/test/unit/light/unit_RtspSession.cpp
@@ -0,0 +1,171 @@
+/// @module RtspSession
+/// @also RtspDriver
+
+/// The RTSP control conversation an H.264 stream is negotiated over. A player rejects a session on a header it cannot parse, and says so only by disconnecting. The response shape is therefore pinned here rather than by watching VLC give up.
+
+#include "doctest.h"
+#include "light/util/RtspSession.h"
+
+#include
+#include
+
+namespace {
+
+/// The response as text, which is what a client reads.
+std::string answer(mm::rtsp::Session& s, const char* request,
+ const char* sdp = nullptr, const char* url = "rtsp://d/") {
+ mm::rtsp::Request req;
+ if (!mm::rtsp::parseRequest(request, std::strlen(request), &req)) return {};
+ char out[1024] = {};
+ const size_t n = s.respond(req, sdp, url, out, sizeof(out));
+ return std::string(out, n);
+}
+
+bool has(const std::string& s, const char* needle) { return s.find(needle) != std::string::npos; }
+
+} // namespace
+
+// Every response echoes the request's CSeq: a client pairs the two by that number alone, and treats a mismatch as a lost message.
+TEST_CASE("RtspSession echoes the request's CSeq") {
+ mm::rtsp::Session s(42);
+ const auto r = answer(s, "OPTIONS rtsp://d/ RTSP/1.0\r\nCSeq: 7\r\n\r\n");
+ CHECK(has(r, "RTSP/1.0 200 OK"));
+ CHECK(has(r, "CSeq: 7"));
+}
+
+// OPTIONS advertises what this server answers, which is how a client decides what to send next.
+TEST_CASE("RtspSession lists the verbs it implements") {
+ mm::rtsp::Session s(1);
+ const auto r = answer(s, "OPTIONS rtsp://d/ RTSP/1.0\r\nCSeq: 1\r\n\r\n");
+ for (const char* v : {"OPTIONS", "DESCRIBE", "SETUP", "PLAY", "TEARDOWN"}) CHECK(has(r, v));
+}
+
+// DESCRIBE carries the SDP as a body, with a Content-Length a client reads before the body arrives.
+TEST_CASE("RtspSession answers DESCRIBE with the SDP and its length") {
+ mm::rtsp::Session s(1);
+ const char* sdp = "v=0\r\nm=video 0 RTP/AVP 96\r\n";
+ const auto r = answer(s, "DESCRIBE rtsp://d/ RTSP/1.0\r\nCSeq: 2\r\n\r\n", sdp);
+ CHECK(has(r, "Content-Type: application/sdp"));
+ CHECK(has(r, "Content-Length: 27")); // the SDP above, byte for byte
+ CHECK(has(r, "m=video 0 RTP/AVP 96"));
+}
+
+// SETUP agrees the transport: the client names its RTP port, the server names its own, and the session id appears in every later response.
+TEST_CASE("RtspSession agrees a UDP transport and remembers the client's port") {
+ mm::rtsp::Session s(99);
+ CHECK(s.state() == mm::rtsp::State::Init);
+ const auto r = answer(s, "SETUP rtsp://d/ RTSP/1.0\r\nCSeq: 3\r\n"
+ "Transport: RTP/AVP;unicast;client_port=6000-6001\r\n\r\n");
+ CHECK(has(r, "RTSP/1.0 200 OK"));
+ CHECK(has(r, "client_port=6000-6001"));
+ CHECK(has(r, "Session: 99"));
+ CHECK(s.state() == mm::rtsp::State::Ready);
+ CHECK(s.rtpPort() == 6000);
+}
+
+// This server speaks UDP, and a client asking for interleaved TCP is told so in the code RFC 2326 assigns rather than by a silent failure.
+TEST_CASE("RtspSession refuses a transport it does not speak") {
+ mm::rtsp::Session s(1);
+ const auto r = answer(s, "SETUP rtsp://d/ RTSP/1.0\r\nCSeq: 3\r\n"
+ "Transport: RTP/AVP/TCP;unicast;interleaved=0-1\r\n\r\n");
+ CHECK(has(r, "461 Unsupported Transport"));
+ CHECK(s.state() == mm::rtsp::State::Init);
+}
+
+// PLAY starts a stream the transport agreed at SETUP carries, so a PLAY arriving first has nowhere to send and says so.
+TEST_CASE("RtspSession refuses PLAY before SETUP") {
+ mm::rtsp::Session s(1);
+ const auto r = answer(s, "PLAY rtsp://d/ RTSP/1.0\r\nCSeq: 4\r\n\r\n");
+ CHECK(has(r, "455 Method Not Valid In This State"));
+ CHECK(s.state() == mm::rtsp::State::Init);
+}
+
+// The whole conversation in order, which is what a player sends.
+TEST_CASE("RtspSession walks OPTIONS, DESCRIBE, SETUP, PLAY, TEARDOWN") {
+ mm::rtsp::Session s(7);
+ answer(s, "OPTIONS rtsp://d/ RTSP/1.0\r\nCSeq: 1\r\n\r\n");
+ answer(s, "DESCRIBE rtsp://d/ RTSP/1.0\r\nCSeq: 2\r\n\r\n", "v=0\r\n");
+ answer(s, "SETUP rtsp://d/ RTSP/1.0\r\nCSeq: 3\r\n"
+ "Transport: RTP/AVP;unicast;client_port=5000-5001\r\n\r\n");
+ REQUIRE(s.state() == mm::rtsp::State::Ready);
+
+ const auto play = answer(s, "PLAY rtsp://d/ RTSP/1.0\r\nCSeq: 4\r\n\r\n");
+ CHECK(has(play, "RTSP/1.0 200 OK"));
+ CHECK(has(play, "Range: npt=0.000-"));
+ CHECK(s.state() == mm::rtsp::State::Playing);
+
+ const auto down = answer(s, "TEARDOWN rtsp://d/ RTSP/1.0\r\nCSeq: 5\r\n\r\n");
+ CHECK(has(down, "RTSP/1.0 200 OK"));
+ CHECK(s.state() == mm::rtsp::State::Init);
+ CHECK(s.rtpPort() == 0); // the agreed transport is released with the session
+}
+
+// Judging a Transport header as one string accepts a combination nobody offered: one alternative's protocol paired with another's port.
+TEST_CASE("RtspSession judges each Transport alternative whole") {
+ mm::rtsp::Session s(1);
+
+ SUBCASE("the interleaved alternative's protocol never pairs with another's port") {
+ const auto r = answer(s, "SETUP rtsp://d/ RTSP/1.0\r\nCSeq: 3\r\n"
+ "Transport: RTP/AVP/TCP;interleaved=0-1,RTP/AVP;multicast;client_port=6000-6001\r\n\r\n");
+ CHECK(has(r, "461 Unsupported Transport")); // TCP, then multicast: neither is usable
+ CHECK(s.state() == mm::rtsp::State::Init);
+ }
+
+ SUBCASE("multicast is refused, where a unicast server would send to one address") {
+ const auto r = answer(s, "SETUP rtsp://d/ RTSP/1.0\r\nCSeq: 3\r\n"
+ "Transport: RTP/AVP;multicast;client_port=6000-6001\r\n\r\n");
+ CHECK(has(r, "461 Unsupported Transport"));
+ }
+
+ SUBCASE("a usable alternative after an unusable one is taken") {
+ const auto r = answer(s, "SETUP rtsp://d/ RTSP/1.0\r\nCSeq: 3\r\n"
+ "Transport: RTP/AVP/TCP;interleaved=0-1,RTP/AVP;unicast;client_port=7000-7001\r\n\r\n");
+ CHECK(has(r, "RTSP/1.0 200 OK"));
+ CHECK(s.rtpPort() == 7000); // the second alternative's OWN port
+ }
+
+ SUBCASE("an alternative naming no port is refused rather than half-accepted") {
+ const auto r = answer(s, "SETUP rtsp://d/ RTSP/1.0\r\nCSeq: 3\r\n"
+ "Transport: RTP/AVP;unicast\r\n\r\n");
+ CHECK(has(r, "461 Unsupported Transport"));
+ }
+}
+
+// A number long enough to wrap the accumulator would land back under the limit and read as a small one.
+TEST_CASE("RtspSession refuses a number that would overflow rather than wrapping it") {
+ mm::rtsp::Request req;
+ const char* r = "OPTIONS rtsp://d/ RTSP/1.0\r\nCSeq: 4294967296\r\n\r\n";
+ REQUIRE(mm::rtsp::parseRequest(r, std::strlen(r), &req));
+ CHECK(req.cseq == 0); // refused, so the field keeps its default rather than wrapping to 0
+}
+
+// A header's casing is the client's choice, so CSeq is matched without regard to it.
+TEST_CASE("RtspSession reads CSeq whatever its casing") {
+ mm::rtsp::Session s(1);
+ const auto r = answer(s, "OPTIONS rtsp://d/ RTSP/1.0\r\ncseq: 12\r\n\r\n");
+ CHECK(has(r, "CSeq: 12"));
+}
+
+// A verb this server has no answer for is refused BY CODE, since a silent drop leaves a client waiting on a response that never comes.
+TEST_CASE("RtspSession answers a verb it does not implement with 501") {
+ mm::rtsp::Session s(1);
+ mm::rtsp::Request req; // an unparsed verb stays Unknown, which respond() answers
+ const char* pause = "PAUSE rtsp://d/ RTSP/1.0\r\nCSeq: 9\r\n\r\n";
+ CHECK_FALSE(mm::rtsp::parseRequest(pause, std::strlen(pause), &req));
+ char out[512] = {};
+ const size_t n = s.respond(req, nullptr, "rtsp://d/", out, sizeof(out));
+ CHECK(has(std::string(out, n), "501 Not Implemented"));
+}
+
+// The SDP names H.264 at the payload type the packets carry, and the geometry the encoder produces: a player reads the codec here before a single packet arrives.
+TEST_CASE("RtspSession builds an SDP naming H.264 and the stream's geometry") {
+ char sdp[512];
+ const size_t n = mm::rtsp::buildSdp(sdp, sizeof(sdp), "192.168.1.50", 320, 240, 30, 96);
+ REQUIRE(n > 0);
+ const std::string s(sdp, n);
+ CHECK(has(s, "m=video 0 RTP/AVP 96"));
+ CHECK(has(s, "a=rtpmap:96 H264/90000"));
+ CHECK(has(s, "a=framesize:96 320-240"));
+ CHECK(has(s, "a=framerate:30"));
+ CHECK(has(s, "192.168.1.50"));
+}