diff --git a/CLAUDE.md b/CLAUDE.md index 01a3405e3..dae233686 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,8 @@ flowchart TB Keep each commit under ~100 changed files: CodeRabbit reviews the files a pushed commit changes, and past that the commit silently loses a review layer. A mechanical sweep (em-dashes, a metrics refresh) is a commit of its own, apart from behavioral fixes, so both stay reviewable. +**A contributed pull request is one topic, about 400 changed lines and ten files at most**, tests included. Generated files (metrics, images, scenario observations) are a category of their own, outside the count. Past a few hundred lines a review stops catching defects (the SmartBear and Cisco review study; Google's guide on small changes), and the product owner reviews every line. Every pull request is based on main. The one exception is a step of a larger topic, which lands as its own pull request and may build on the step below it. A stacked pull request from a fork shows its parents, so it is reviewed once the one below it merges. + ### Build and test Implement against [the architecture](docs/explanation/architecture/index.md) and [coding-standards.md](docs/contributing/coding-standards.md). Everything build, flash, run and monitor: [building.md](docs/how-to/building.md). Per-script reference: [MoonDeck.md](moondeck/MoonDeck.md). @@ -179,7 +181,7 @@ On "run pre-commit": run the checks whose trigger the diff matches, report one l **A failure is something to report, not to fix and re-run.** A second run needs the words again, as much after a failure or a fix as at any other time. -**The gates run once per commit.** A run costs minutes, so scope freezes at "run pre-commit": a request made during a run goes to the next commit. A change made after a run re-checks only itself, on the product owner's go. A fixed failure reruns that one check; any other change runs the checks its own diff triggers. A full rerun needs the word "restart". A cosmetic change can ride on the run it followed, since the next commit's gates check it again. +**Each check runs once per commit.** A run costs minutes, so scope freezes at "run pre-commit": a request made during a run goes to the next commit. A change made after a run re-checks only itself, on the product owner's go. A fixed failure reruns that one check; any other change runs the checks its own diff triggers. A full rerun needs the word "restart". A cosmetic change can ride on the run it followed, since the next commit's gates check it again. ```mermaid flowchart LR diff --git a/docs/assets/core/PictureService.png b/docs/assets/core/PictureService.png new file mode 100644 index 000000000..b4cf26477 Binary files /dev/null and b/docs/assets/core/PictureService.png differ diff --git a/docs/assets/hardware/sensors/dw3000.webp b/docs/assets/hardware/sensors/dw3000.webp new file mode 100644 index 000000000..5c5ae1dd4 Binary files /dev/null and b/docs/assets/hardware/sensors/dw3000.webp differ diff --git a/docs/assets/hardware/sensors/hc-sr501.webp b/docs/assets/hardware/sensors/hc-sr501.webp new file mode 100644 index 000000000..a587d50f4 Binary files /dev/null and b/docs/assets/hardware/sensors/hc-sr501.webp differ diff --git a/docs/assets/hardware/sensors/hlk-ld2450.webp b/docs/assets/hardware/sensors/hlk-ld2450.webp new file mode 100644 index 000000000..46f1ccad0 Binary files /dev/null and b/docs/assets/hardware/sensors/hlk-ld2450.webp differ diff --git a/docs/assets/hardware/sensors/mpu6050.webp b/docs/assets/hardware/sensors/mpu6050.webp new file mode 100644 index 000000000..55bd7855f Binary files /dev/null and b/docs/assets/hardware/sensors/mpu6050.webp differ diff --git a/docs/assets/hardware/sensors/p4-csi-camera.webp b/docs/assets/hardware/sensors/p4-csi-camera.webp new file mode 100644 index 000000000..56b0c26a6 Binary files /dev/null and b/docs/assets/hardware/sensors/p4-csi-camera.webp differ diff --git a/docs/assets/hardware/sensors/vl53l0x.webp b/docs/assets/hardware/sensors/vl53l0x.webp new file mode 100644 index 000000000..8644f8879 Binary files /dev/null and b/docs/assets/hardware/sensors/vl53l0x.webp differ diff --git a/docs/assets/hardware/sensors/vl53l8cx.webp b/docs/assets/hardware/sensors/vl53l8cx.webp new file mode 100644 index 000000000..116c9715f Binary files /dev/null and b/docs/assets/hardware/sensors/vl53l8cx.webp differ diff --git a/docs/assets/light/effects/FseqPlayerEffect.gif b/docs/assets/light/effects/FseqPlayerEffect.gif new file mode 100644 index 000000000..35ccfaf22 Binary files /dev/null and b/docs/assets/light/effects/FseqPlayerEffect.gif differ diff --git a/docs/assets/light/effects/FseqPlayerEffect.png b/docs/assets/light/effects/FseqPlayerEffect.png new file mode 100644 index 000000000..9a4e9878f Binary files /dev/null and b/docs/assets/light/effects/FseqPlayerEffect.png differ diff --git a/docs/assets/light/effects/NetworkReceiveEffect.png b/docs/assets/light/effects/NetworkReceiveEffect.png index 5fb8c56fa..8d0251321 100644 Binary files a/docs/assets/light/effects/NetworkReceiveEffect.png and b/docs/assets/light/effects/NetworkReceiveEffect.png differ diff --git a/docs/assets/light/effects/PictureEffect.gif b/docs/assets/light/effects/PictureEffect.gif new file mode 100644 index 000000000..b66eeab54 Binary files /dev/null and b/docs/assets/light/effects/PictureEffect.gif differ diff --git a/docs/assets/light/effects/PictureEffect.png b/docs/assets/light/effects/PictureEffect.png new file mode 100644 index 000000000..d55d94a10 Binary files /dev/null and b/docs/assets/light/effects/PictureEffect.png differ diff --git a/docs/how-to/adding-a-stream-source.md b/docs/how-to/adding-a-stream-source.md new file mode 100644 index 000000000..2969bffc2 --- /dev/null +++ b/docs/how-to/adding-a-stream-source.md @@ -0,0 +1,95 @@ +# Adding a picture or channel-data source + +A new way for frames to reach the lights, from a capture device, a file format or a network protocol. +Every source is one of two families, and each family has one place where its frames land. +So a source is small: it fetches frames and hands them over, and everything after that is already written and tested. + +> This assumes the desktop build and tests run: see [Building, running, flashing](building.md). A source is proven on the desktop before a board sees it. + +--- + +## Which family + +| Your frames are | The family | Where they land | Examples | +|---|---|---|---| +| width × height pixels, any size | picture | [Picture service](../moonmodules/core/services.md#picture) | USB capture, a camera, a GIF, a browser tab | +| one value per light, in wiring order or row by row | channel data | the channel-data sink | Art-Net, E1.31, DDP, an FSEQ file | + +A picture knows nothing of the lights: the [Picture effect](../moonmodules/light/effects.md#picture) samples it onto whatever layout the layer has, fitted, filled or stretched. +Channel data is already per light: it is placed through the layer's mapping, or straight to the output when the sender mapped it. + +--- + +## A picture source + +A picture source is an option of the Picture service's `source`, in `src/core/services/PictureService.h`. + +1. **Add the option.** Append it to the `Source` enum and its name to the `source` select, and say whether it runs in a task of its own in `kRunsInTask`. +2. **A still picture** draws once in `prepare()`, as the test pattern does: write into the picture `prepareBuffers` sized, and set the frame. +3. **A moving picture** comes from a task of its own, a capture or a receiver, so the render loop never waits on a device. Start the task in `prepare()` and stop it in `release()`. For each picture: + +```cpp +uint8_t* px = beginWrite(); // the back picture, width x height x 3 bytes, top row first +decodeInto(px, width, height); // your source: a decoder writes here directly, nothing is copied +endWrite(); // swap it to the front and bump the generation +``` + +That is the whole contract. +With room for two pictures, `beginWrite` hands out the back one and `endWrite` swaps the pointers, so effects read the front picture undisturbed and never see half of one. +Short of memory, the service keeps one picture and locks it while you write: frames may be skipped, never torn, and its status says why. +Effects read through `PictureService::Reader`, which holds the picture still for one gather; you never touch them. + +**What a picture source brings:** + +- its option and its row on the Picture service's spec card; +- a unit test that drives it on the desktop with a stand-in for the device; +- the platform seam for the device itself, with stubs on every other platform. + +The thread-safety of the swap is already pinned by the service's own tests, a writer task racing a reader. + +Sources are options of one module, so a source edits that file. When there are more than a handful, a source per file behind a small registry is the next step. + +--- + +## A channel-data source + +A channel-data source is an effect of its own, with its own settings, that owns a `ChannelDataSink` and writes its frames into it. +`src/light/effects/FseqPlayerEffect.h` is the template, and `NetworkReceiveEffect.h` the complete example. + +```cpp +class MyFormatEffect : public EffectBase { +public: + void prepare() override { + sink_.prepare(/*senderMapped=*/true); // wiring order: one value per physical light + // open your file or socket, check its header, set a status for what you refuse + } + void tick() MM_NONBLOCKING override { + // read this frame and hand it over: sink_.write(offset, data, length); + sink_.tick(); // the sink places a changed frame + } + bool deliversWiringOrder() const override { return sink_.wiringOrder(); } + void copyWiringFrame(uint8_t* out, size_t bytes, bool force) override { sink_.copyWiringFrame(out, bytes, force); } +private: + ChannelDataSink sink_{*this}; +}; +``` + +The sink does the rest: it holds the last frame so the lights keep it between frames, skips a frame that did not change, and places it. +A frame mapped by the receiver is copied into the layer, which applies this board's wiring. +A frame mapped by the sender, alone on its layer, goes to the output as it is, one copy on any layout. +Beside another effect or under a modifier it is placed on the grid by the layout's positions, so a modifier transforms it. +A source writing from a task of its own calls `write` there, which waits out at most one render copy; one that reads in `tick()` calls `write`. + +**What a channel-data source brings:** + +- its effect, its settings and its spec card; +- unit tests that write frames and check each light against what was sent, with `moondeck/run/fseq_play.py` writing reference FSEQ files and streaming them over DDP. + +The sink's placement on serpentine, 3D, mirrored and transposed layouts is pinned by Network Receive's tests. + +--- + +## Before the pull request + +One topic, based on main, about 400 changed lines and ten files at most, as [the process](../contributing/principles-and-process.md) describes. +The desktop tests pass, the source works on the board it is for, and a board without the device builds and shows a status rather than failing. diff --git a/docs/moonmodules/core/services.md b/docs/moonmodules/core/services.md index 162f52c04..d277e2201 100644 --- a/docs/moonmodules/core/services.md +++ b/docs/moonmodules/core/services.md @@ -24,8 +24,8 @@ A user-added Service: the audio source the audio-reactive effects consume. `mode - `micMode`: (Local, I²S targets) `I2S` for a three-wire part, `PDM` for a two-wire one. - `sckPin` / `wsPin` / `sdPin`: (Local, I²S targets) the bus GPIOs, unset until entered. - `mclkPin`: (Local, I²S targets) the master clock an ADC or codec needs; unset for a plain mic. -- `codec`: (Local, I²S targets) `ES8311` for a mic behind that codec, else `none`. -- `codecAddr`: (with a codec) its address on the [I2C bus](system.md#i2c-bus), in hex (`0x18`). +- `codec`: (Local, I²S targets) `ES8311` or `ES7210` in front of the mic, else `none`. +- `codecAddr`: its [I2C](system.md#i2c-bus) address: `0x18` (ES8311), `0x40` to `0x43` (ES7210). - `device`: (Local, desktop) the OS capture input, `default` following the system setting. - `sampleRate`: (Local) mic/ADC sample rate. - `levels`: (Local) who sets the display window: `manual`'s two sliders, or `automatic`, a learner. @@ -42,6 +42,21 @@ Detail: [technical](moxygen/AudioService.md) · [the sync packet](moxygen/WLEDAu [Tests](../../reference/tests/unit-tests.md#audioservice) + + +### Picture + +A user-added Service: the picture the [Picture effect](../light/effects.md#picture) samples onto the lights. One is active at a time, as with Audio. + +Picture module controls + +- `source`: `test pattern`, a test card in the layout of Philips' PM5544, at any size. +- `width` / `height`: the picture in pixels, up to 512 a side; 64 × 48 by default. + +Detail: [technical](moxygen/PictureService.md) + +[Tests](../../reference/tests/unit-tests.md#pictureservice) + ### OSC diff --git a/docs/moonmodules/core/supporting.md b/docs/moonmodules/core/supporting.md index 4a1306c84..f936082d9 100644 --- a/docs/moonmodules/core/supporting.md +++ b/docs/moonmodules/core/supporting.md @@ -43,7 +43,7 @@ A flat parser returns zero for a missing key, which is indistinguishable from a | [`ImprovOpReassembler`](moxygen/ImprovOpReassembler.md) | The state machine that reassembles a chunked operation into one buffer, with the duplicate and out-of-order guard. The platform layer owns the serial side around it. | | [`FirmwareImage`](moxygen/FirmwareImage.md) | Reads an image's header without the vendor framework. The layout is a fixed on-disk format, so the same bytes parse on a device and in a test. | | [`PinList`](moxygen/util_PinList.md) · [`HostList`](moxygen/HostList.md) | The two list parsers a board configuration needs: a pin list and a host list of addresses and names, both typed by a human into one text control. | -| [`Addressing`](moxygen/Addressing.md) · [`HostResolver`](moxygen/HostResolver.md) | One rule for sending to many receivers, unicast, multicast or broadcast, and the cache that turns a host name into an address off the render thread. | +| [`Addressing`](moxygen/Addressing.md) · [`HostResolver`](moxygen/HostResolver.md) | One rule for sending to many receivers, unicast, multicast or broadcast, and the cache that turns a host name into an address without blocking the render thread. | | [`ConfigScrape`](moxygen/ConfigScrape.md) · [`Ipv4`](moxygen/Ipv4.md) | The keys MoonBase reads out of the app's saved config without a parser, and the IPv4 parse and static-address rule both images check by. | | [`sha256`](moxygen/sha256.md) · [`crc`](moxygen/crc.md) | A cryptographic digest for update verification and identity, and a cheap checksum used as a fingerprint of a block of state. | | [`Sha1`](moxygen/Sha1.md) · [`Base64`](moxygen/Base64.md) | The two codecs the WebSocket handshake needs, the digest that standard mandates and the encoding that carries it. | diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 11ba76a8e..41742145a 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -18,7 +18,7 @@ The block every driver card opens with, shown here on RMT LED. Added once by [`D The shared block at the top of a driver card, here on RMT LED -- `localBrightness`: this driver's dim (0–255), multiplied with the global brightness. +- `localBrightness`: this driver's dim (0 to 255), multiplied with the global brightness. - `curve`: brightness to output: `CIE 1931`, `gamma 2.2`, `gamma 2.8`, or `linear`. - `fixture`: the [fixture profile](supporting.md) applied per light, for order and white. - `whiteMode`: how W is derived, shown when the profile carries a W channel. @@ -125,14 +125,15 @@ Detail: [technical](moxygen/PwmLightDriver.md) NetworkSend controls -Streams the buffer over UDP as **Art-Net**, **E1.31 / sACN** or **DDP**, one burst per frame, to Falcon/Advatek controllers, xLights and LedFx. Feeds several receivers from one driver, each its own slice. Mixed DMX fixtures and the addressing rules are in [the details below](#network-send-details). +Streams the buffer over UDP as **Art-Net**, **E1.31 / sACN** or **DDP**, its packets paced so receivers keep up, to Falcon/Advatek controllers, xLights and LedFx. Feeds several receivers from one driver, each its own slice. Mixed DMX fixtures and the addressing rules are in [the details below](#network-send-details). - `protocol`: Art-Net / E1.31 / DDP (default Art-Net); the port follows. - `addressing`: unicast for all three, multicast for E1.31, broadcast for Art-Net. - `hosts`: the receivers, such as `192.168.1.70-74, panel-01.local`. **Blank idles.** - `lightsPerHost`: lights per receiver. Blank splits evenly, one number to all, a list each. - `universe_start`: first universe for Art-Net / E1.31, restarting per receiver. DDP ignores it. -- `fps`: frame-rate limit (default 50, 1–120). +- `fps`: frame-rate limit (default 50, 1 to 120). +- `packet_gap`: µs between a frame's packets (default 500); 0 sends the frame as one burst. [Tests](../../reference/tests/unit-tests.md#networksenddriver) @@ -187,7 +188,7 @@ Streams a true-shape 3D preview to the web UI as a **point list**, only the real It streams on its own WebSocket channel so a large frame never delays the control plane, and only while a viewer is watching. Moving heads show their beam. How the rate and detail trade off is in [the details below](#preview-details). -- `targetFps`: the rate to aim for (default 24, 1–25). **Lower for detail, raise for smoothness.** +- `targetFps`: the rate to aim for (default 24, 1 to 25). **Lower for detail, raise for smoothness.** [Tests](../../reference/tests/unit-tests.md#previewdriver) @@ -204,7 +205,7 @@ Publishes the layer as an **NDI video source**, so OBS, Resolume, TouchDesigner The grid becomes the frame, one light per pixel, output correction applied, so a receiver sees what the wall sees. **Desktop only, and you install the NDI runtime yourself**; without it the driver says so and nothing else changes. See [the details below](#ndi-details). - `sourceName`: the name a receiver lists. Blank uses the device's own name. -- `fps`: frame-rate ceiling (default 30, 1–120), declared in every frame. +- `fps`: frame-rate ceiling (default 30, 1 to 120), declared in every frame. Detail: [technical](moxygen/NdiDriver.md) @@ -218,7 +219,7 @@ Streams the layer as **H.264 over HLS** from the device's own HTTP server. Open The grid becomes the frame, output correction applied. Latency is HLS's own, **2-5 seconds**, so this is for watching rather than live-control feedback. Runs on desktop (you install ffmpeg) and on the **ESP32-P4**, which encodes in hardware. See [the details below](#hls-details). -- `targetFps`: encode-rate ceiling (default 30, 1–120). Also the bandwidth knob: bitrate is derived. +- `targetFps`: encode-rate ceiling (default 30, 1 to 120), and the bandwidth knob: bitrate follows. - `scale`: video pixels per light (0 = auto). Each light is a solid block, never a blur. - `encoder`: which ffmpeg encoder (desktop only). - read-only: `url` to play. The card's status line carries state, drops, or why the encoder stopped. @@ -235,7 +236,7 @@ Streams the layer as **H.264 over RTSP**, which a player pulls rather than fetch 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. +- `targetFps`: encode-rate ceiling (default 30, 1 to 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. diff --git a/docs/moonmodules/light/effects.md b/docs/moonmodules/light/effects.md index 0be273c3e..c35a8a974 100644 --- a/docs/moonmodules/light/effects.md +++ b/docs/moonmodules/light/effects.md @@ -1331,16 +1331,37 @@ Detail: [technical](moxygen/DemoReelEffect.md) [Tests](../../reference/tests/unit-tests.md#demoreeleffect) + + +### FseqPlayer 🎞️ + +The reference sweep fseq_play writes, the show an FSEQ player plays + +An FSEQ show, xLights' and Falcon Player's frame-by-frame channel data, played from a file. It lands where a sender-mapped [Network Receive](#networkreceive) stream does, so alone on its layer it reaches the lights in one copy on any layout. It reads no file yet, and its status says so: playback is the next step. + +- `file`: the show's path, an `.fseq` file on flash or under `/sd`. +- `loop`: start again at the end. +- `speed`: the playback speed, in percent of the file's step time, up to 255. + +Origin: MoonLight original (FSEQ v2, as Falcon Player documents it) + +Detail: [technical](moxygen/FseqPlayerEffect.md) + +[Tests](../../reference/tests/unit-tests.md#fseqplayereffect) + ### NetworkReceive 📡🌙 NetworkReceive effect preview -Receives lights over UDP and writes them into the layer: the receive side for Resolume, Madrix, xLights and LedFx. +Receives lights over UDP into the layer, from Resolume, Madrix, xLights and LedFx. Sender mapping is one copy alone on its layer, else two mappings, as the status shows. Behind a correcting sender, keep the profile in byte order, brightness 255 and the linear curve. - `universe_start`: the first incoming universe to map, mirroring the sender. - `channels_per_universe`: bytes each universe maps to; 510 or 512. +- `mapped_by`: **receiver** (sent row by row) or **sender** (in wiring order). +- `packets_per_s`: packets received in the last second, read-only. +- `frames_per_s`: frames received in the last second, read-only. Origin: MoonLight original (E1.31 / Art-Net receive) @@ -1350,6 +1371,23 @@ Detail: [technical](moxygen/NetworkReceiveEffect.md) Listens for Art-Net, E1.31 and DDP at once. The end-to-end pair with [Network Send](drivers.md). + + +### Picture 🖼️ · 2D + +Picture effect preview + +The active [Picture service](../core/services.md#picture)'s picture, sampled onto the layer, so a picture of any size shows on a layout of any size. A modifier rotates, mirrors or tiles it like any effect. + +- `fit`: keep the aspect with bands (**fit**), cropped (**fill**), or **stretch**. +- `sampling`: **point**, one pixel per light, or **area**, a linear-light mean. + +Origin: MoonLight original + +Detail: [technical](moxygen/PictureEffect.md) + +[Tests](../../reference/tests/unit-tests.md#pictureeffect) + ### Sine 💫 · 3D diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 7faa16982..153009eef 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,20 +4,20 @@ Generated by [`moondeck/check/check_code.py`](../../../moondeck/check/check_code Every function, block and file over a limit the [coding standards](../../contributing/coding-standards.md#static-checks) set. Current state only: the trend is this file's git history. The list only shrinks, per rule and in total, and a touched file clears its own rows. -**1634 finding(s)** across 324 file(s). 2.68% of all lines are duplicated, 3656 lines. +**1629 finding(s)** across 324 file(s). 2.62% of all lines are duplicated, 3654 lines. ## By rule | Rule | Findings | |---|---:| | duplicated block | 754 | -| complex function | 302 | -| blocking call on the render path | 193 | -| deeply nested | 115 | +| complex function | 301 | +| blocking call on the render path | 191 | +| deeply nested | 114 | | module type in the UI | 77 | | long function | 49 | | long parameter list | 35 | -| large stack frame | 34 | +| large stack frame | 33 | | raw allocation in light | 28 | | large file | 25 | | name spread | 22 | @@ -45,9 +45,9 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | Area | Findings | |---|---:| | `test` | 560 | -| `light` | 417 | -| `core` | 371 | -| `platform` | 173 | +| `light` | 415 | +| `core` | 367 | +| `platform` | 174 | | `ui` | 80 | | `moondeck` | 24 | | `src` | 7 | @@ -59,11 +59,11 @@ The next function worth simplifying is at the top of each file's list. | File | Findings | Worst | |---|---:|---| -| `src/core/system/HttpServerModule.cpp` | 47 | `HttpServerModule::handleConnection`, complex function 123 | +| `src/core/system/HttpServerModule.cpp` | 46 | `HttpServerModule::handleConnection`, complex function 122 | | `src/ui/migrate.js` | 43 | `line 29: 'I2cBusModule'`, module type in the UI 1 | | `src/core/moonlive/MoonLiveCompiler.cpp` | 41 | `moonlive::Parser::parseDecl`, complex function 54 | | `src/platform/desktop/platform_desktop.cpp` | 40 | `platform::spawnEncoderProcess`, complex function 58 | -| `src/ui/app.js` | 35 | `app.js`, large file 9196 | +| `src/ui/app.js` | 35 | `app.js`, large file 9199 | | `src/core/system/MqttModule.cpp` | 31 | `MqttModule::routePublish`, complex function 29 | | `src/light/drivers/ParallelLedDriver.h` | 27 | `ParallelLedDriver::reinit`, complex function 36 | | `src/light/drivers/PreviewDriver.h` | 27 | `PreviewDriver::buildCoordTable`, complex function 37 | @@ -80,22 +80,22 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_I80Peripheral.cpp` | 19 | `lines 132-151, also in test/unit/light/unit_ParlioPeripheral.cpp:197`, duplicated block 20 | | `src/platform/esp32/platform_esp32_moon_i80.cpp` | 18 | `platform::createRingState`, complex function 32 | | `test/unit/core/unit_PinsModule.cpp` | 18 | `lines 46-57, also in test/unit/light/unit_Hub75Driver_pins.cpp:66`, duplicated block 12 | -| `src/core/services/AudioService.h` | 17 | `AudioService in 51 files`, name spread 51 | +| `src/core/services/AudioService.h` | 16 | `AudioService in 52 files`, name spread 52 | | `src/light/effects/PacmanEffect.h` | 16 | `lines 243-257, also in src/light/effects/FishTankEffect.h:244`, duplicated block 15 | | `src/platform/esp32/platform_esp32_ota.cpp` | 15 | `platform::otaWriteMoonBase`, complex function 33 | | `test/unit/light/unit_PaintBrushEffect.cpp` | 15 | `lines 44-61, also in test/unit/light/unit_GEQ3DEffect.cpp:58`, duplicated block 18 | | `test/unit/light/unit_SolidEffect.cpp` | 15 | `lines 9-22, also in test/unit/light/unit_FixedRectangleEffect.cpp:90`, duplicated block 14 | | `src/core/module/Control.cpp` | 13 | `applyControlValue`, complex function 74 | -| `src/light/layers/Layer.h` | 13 | `Layer in 128 files`, name spread 128 | +| `src/light/layers/Layer.h` | 13 | `Layer in 130 files`, name spread 130 | | `src/light/moonlive/MoonLiveBuiltins_light.h` | 13 | `moonlive::mm_light_escape`, complex function 13 | | `test/unit/light/unit_HlsDriver.cpp` | 13 | `lines 30-47, also in test/unit/light/unit_PanelCardDriver.cpp:22`, duplicated block 18 | | `test/unit/light/unit_RandomMapModifier.cpp` | 13 | `lines 116-132, also in test/unit/light/unit_BlurzEffect.cpp:49`, duplicated block 17 | | `src/core/system/DevicesModule.h` | 12 | `lines 73-80, also in src/core/system/DevicesModule.h:59`, duplicated block 8 | | `src/light/drivers/HlsDriver.h` | 12 | `lines 105-117, also in src/light/drivers/RtspDriver.h:92`, duplicated block 13 | | `test/unit/light/unit_RubiksCubeEffect.cpp` | 12 | `lines 60-78, also in test/unit/light/unit_RubiksCubeEffect.cpp:28`, duplicated block 19 | +| `src/light/drivers/Drivers.h` | 11 | `Drivers in 71 files`, name spread 71 | | `test/unit/light/unit_ParlioPeripheral.cpp` | 11 | `lines 197-216, also in test/unit/light/unit_I80Peripheral.cpp:132`, duplicated block 20 | | `test/unit/light/unit_StarFieldEffect.cpp` | 11 | `lines 88-103, also in test/unit/light/unit_StarFieldEffect.cpp:13`, duplicated block 16 | -| `src/light/drivers/Drivers.h` | 10 | `Drivers in 69 files`, name spread 69 | | `src/light/effects/GEQ3DEffect.h` | 10 | `lines 140-150, also in src/light/effects/GEQ3DEffect.h:113`, duplicated block 11 | | `src/light/effects/GameOfLifeEffect.h` | 10 | `GameOfLifeEffect::evolveAutomaton`, complex function 67 | | `test/unit/light/unit_BlendMap.cpp` | 10 | `lines 285-291, also in test/unit/light/unit_BlendMap.cpp:214`, duplicated block 7 | @@ -104,7 +104,7 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/OscModule.h` | 9 | `OscModule::handle`, deeply nested 5 | | `src/core/system/ControlModule.h` | 9 | `ControlModule::moveListRow`, complex function 15 | | `src/core/system/MoonStatsModule.h` | 9 | `buildMoonStatsReport`, long parameter list 11 | -| `src/light/drivers/PanelCardDriver.h` | 9 | `lines 187-195, also in src/light/drivers/NetworkSendDriver.h:210`, duplicated block 9 | +| `src/light/drivers/PanelCardDriver.h` | 9 | `lines 187-195, also in src/light/drivers/NetworkSendDriver.h:225`, duplicated block 9 | | `src/platform/esp32/platform_esp32_i80.cpp` | 9 | `platform::createState`, complex function 27 | | `test/unit/light/unit_ParallelLedDriver_swap.cpp` | 9 | `lines 176-184, also in test/unit/light/unit_ParallelLedDriver_swap.cpp:112`, duplicated block 9 | | `src/core/system/WiFiModule.h` | 8 | `lines 352-359, also in src/light/drivers/FixtureProfilesModule.h:245`, duplicated block 8 | @@ -123,12 +123,12 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_esp32_hub75.cpp` | 7 | `platform::hub75Init`, complex function 32 | | `src/platform/esp32/platform_esp32_rmt.cpp` | 7 | `platform::rmtWs2812LoopbackFrame`, complex function 31 | | `test/js/ui-ws-scheme.test.mjs` | 7 | `lines 10-22, also in test/js/ui-render-guard.test.mjs:13`, duplicated block 13 | -| `test/unit/core/unit_NetworkModule.cpp` | 7 | `lines 556-564, also in test/unit/core/unit_NetworkModule.cpp:190`, duplicated block 9 | +| `test/unit/core/unit_NetworkModule.cpp` | 7 | `lines 595-603, also in test/unit/core/unit_NetworkModule.cpp:211`, duplicated block 9 | | `test/unit/light/unit_NdiDriver.cpp` | 7 | `lines 29-46, also in test/unit/light/unit_PanelCardDriver.cpp:22`, duplicated block 18 | | `test/unit/light/unit_PlasmaEffect.cpp` | 7 | `lines 24-39, also in test/unit/light/unit_MetaballsEffect.cpp:22`, duplicated block 16 | | `src/core/system/FirmwareUpdateModule.h` | 6 | `FirmwareUpdateModule in 13 files`, name spread 13 | | `src/light/drivers/FixtureProfilesModule.h` | 6 | `lines 245-252, also in src/core/system/WiFiModule.h:352`, duplicated block 8 | -| `src/light/drivers/NetworkSendDriver.h` | 6 | `NetworkSendDriver::tick, line 190`, large stack frame 1760 | +| `src/light/drivers/NetworkSendDriver.h` | 6 | `lines 225-233, also in src/light/drivers/PanelCardDriver.h:187`, duplicated block 9 | | `src/platform/desktop/moonlive_asm_x86_64.cpp` | 6 | `lines 557-564, also in src/platform/desktop/moonlive_asm_x86_64.cpp:515`, duplicated block 8 | | `src/platform/esp32/platform_esp32_improv.cpp` | 6 | `platform::improvTask`, complex function 22 | | `src/platform/esp32/platform_esp32_parlio.cpp` | 6 | `lines 278-287, also in src/platform/esp32/platform_esp32_i80.cpp:426`, duplicated block 10 | @@ -172,7 +172,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/drivers/ParallelSlots.h` | 4 | `encodeWs2812ShiftData`, complex function 19 | | `src/light/drivers/RmtLedDriver.h` | 4 | `line 382: if (!frame_) frame_ = static_cast(platform::alloc(`, raw allocation in light 1 | | `src/light/effects/NebulaEffect.h` | 4 | `NebulaEffect::data`, complex function 27 | -| `src/light/effects/NetworkReceiveEffect.h` | 4 | `platform::UdpSocket::recvFrom in tick`, blocking call on the render path 1 | | `src/light/effects/PolarNoiseEffect.h` | 4 | `lines 100-110, also in src/light/effects/TunnelEffect.h:96`, duplicated block 11 | | `src/light/effects/SpriteCast.h` | 4 | `lines 59-67, also in src/light/effects/SpaceInvadersEffect.h:436`, duplicated block 9 | | `src/light/effects/TrailsEffect.h` | 4 | `TrailsEffect::writeWide`, deeply nested 4 | @@ -188,7 +187,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_Layer_extrude.cpp` | 4 | `lines 155-168, also in test/unit/light/unit_Layer_extrude.cpp:124`, duplicated block 14 | | `test/unit/light/unit_Layer_live_modifier.cpp` | 4 | `lines 96-105, also in test/unit/light/unit_RainbowEffect.cpp:69`, duplicated block 10 | | `test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp` | 4 | `lines 49-59, also in test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp:18`, duplicated block 11 | -| `test/unit/light/unit_NetworkSendDriver_packet.cpp` | 4 | `lines 344-352, also in test/unit/light/unit_NetworkSendDriver_packet.cpp:290`, duplicated block 9 | +| `test/unit/light/unit_NetworkSendDriver_packet.cpp` | 4 | `lines 346-354, also in test/unit/light/unit_NetworkSendDriver_packet.cpp:292`, duplicated block 9 | | `test/unit/light/unit_ParallelSlots.cpp` | 4 | `lines 522-532, also in test/unit/light/unit_ParallelSlots.cpp:493`, duplicated block 11 | | `test/unit/light/unit_PraxisEffect.cpp` | 4 | `lines 9-21, also in test/unit/light/unit_RandomMapModifier.cpp:115`, duplicated block 13 | | `test/unit/light/unit_fluid.cpp` | 4 | `lines 160-169, also in test/unit/light/unit_TrailsEffect.cpp:54`, duplicated block 10 | @@ -196,17 +195,16 @@ The next function worth simplifying is at the top of each file's list. | `src/core/moonlive/moonlive_lower.h` | 3 | `moonlive::lowerWith`, complex function 85 | | `src/core/services/GamepadService.h` | 3 | `lines 108-130, also in src/core/services/InfraredService.h:113`, duplicated block 23 | | `src/core/system/AccessPointModule.h` | 3 | `platform::UdpSocket::recvFrom in tick20ms`, blocking call on the render path 1 | -| `src/core/system/HttpServerModule.h` | 3 | `HttpServerModule in 18 files`, name spread 18 | +| `src/core/system/HttpServerModule.h` | 3 | `HttpServerModule in 19 files`, name spread 19 | | `src/core/system/PinsModule.h` | 3 | `PinsModule::PinListSource::collect`, deeply nested 5 | | `src/core/util/HostList.h` | 3 | `parseHostList`, complex function 27 | -| `src/core/util/HostResolver.h` | 3 | `HostResolver::pass`, deeply nested 4 | | `src/core/util/math16.h` | 3 | `lines 373-378, also in src/core/util/math8.h:94`, duplicated block 6 | | `src/light/drivers/Hub75Driver.h` | 3 | `Hub75Driver::prepare`, complex function 11 | | `src/light/effects/AuroraEffect.h` | 3 | `lines 132-140, also in src/light/effects/TunnelEffect.h:92`, duplicated block 9 | | `src/light/effects/PongEffect.h` | 3 | `PongEffect::render`, complex function 15 | | `src/light/effects/SolidEffect.h` | 3 | `SolidEffect::static_cast`, complex function 36 | -| `src/light/layouts/GridLayout.h` | 3 | `GridLayout in 85 files`, name spread 85 | -| `src/module_types.cpp` | 3 | `registerModuleTypes`, long function 150 | +| `src/light/layouts/GridLayout.h` | 3 | `GridLayout in 87 files`, name spread 87 | +| `src/module_types.cpp` | 3 | `registerModuleTypes`, long function 153 | | `src/platform/desktop/moonlive_asm_arm64.cpp` | 3 | `lines 35-40, also in src/platform/esp32/moonlive_asm_riscv.cpp:34`, duplicated block 6 | | `src/platform/esp32/moonlive_asm_xtensa.cpp` | 3 | `lines 483-489, also in src/platform/esp32/moonlive_asm_riscv.cpp:297`, duplicated block 7 | | `src/platform/teensy/platform_teensy_ota.cpp` | 3 | `platform::otaWriteStream`, complex function 16 | @@ -237,11 +235,11 @@ The next function worth simplifying is at the top of each file's list. | `src/light/drivers/DriverBase.h` | 2 | `line 167: if (!wire_) wire_ = static_cast(platform::alloc(by`, raw allocation in light 1 | | `src/light/drivers/Hub75Slots.h` | 2 | `hub75Encode`, complex function 20 | | `src/light/drivers/MoonI80Peripheral.h` | 2 | `MoonI80Peripheral::busInitRing`, complex function 12 | -| `src/light/effects/BeatRipplesEffect.h` | 2 | `BeatRipplesEffect::renderSurface`, complex function 12 | | `src/light/effects/BreakoutEffect.h` | 2 | `BreakoutEffect::move`, complex function 20 | | `src/light/effects/FreqSawsEffect.h` | 2 | `FreqSawsEffect::for`, deeply nested 5 | | `src/light/effects/LinesEffect.h` | 2 | `LinesEffect::if`, deeply nested 5 | | `src/light/effects/MetaballsEffect.h` | 2 | `lines 73-84, also in src/light/effects/SpiralEffect.h:79`, duplicated block 12 | +| `src/light/effects/NetworkReceiveEffect.h` | 2 | `NetworkReceiveEffect::drainSockets`, complex function 11 | | `src/light/effects/NoiseEffect.h` | 2 | `NoiseEffect in 23 files`, name spread 23 | | `src/light/effects/PlasmaEffect.h` | 2 | `lines 82-92, also in src/light/effects/SpiralEffect.h:78`, duplicated block 11 | | `src/light/effects/PulseEffect.h` | 2 | `PulseEffect::for`, deeply nested 5 | @@ -259,10 +257,11 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/moonlive_asm_riscv.h` | 2 | `lines 74-91, also in src/platform/esp32/moonlive_asm_xtensa.h:79`, duplicated block 18 | | `src/platform/esp32/moonlive_emit_riscv.cpp` | 2 | `lines 52-57, also in src/platform/esp32/moonlive_emit_riscv.cpp:25`, duplicated block 6 | | `src/platform/teensy/moonlive_asm_thumb2.cpp` | 2 | `lines 67-72, also in src/platform/esp32/moonlive_asm_xtensa.cpp:190`, duplicated block 6 | +| `src/platform/teensy/platform_teensy.cpp` | 2 | `lines 769-777, also in src/platform/esp32/platform_esp32_usb_midi.cpp:254`, duplicated block 9 | | `test/js/ui-midi-bridge.test.mjs` | 2 | `lines 4-16, also in test/js/ui-gamepad-bridge.test.mjs:4`, duplicated block 13 | | `test/unit/core/unit_AudioService_sync.cpp` | 2 | `lines 313-322, also in test/unit/core/unit_AudioService_sync.cpp:269`, duplicated block 10 | | `test/unit/core/unit_DevicesModule_hue.cpp` | 2 | `lines 63-69, also in test/unit/core/unit_DevicesModule_hue.cpp:42`, duplicated block 7 | -| `test/unit/core/unit_HttpServerModule_sdcard.cpp` | 2 | `lines 421-426, also in test/unit/core/unit_HttpServerModule_sdcard.cpp:411`, duplicated block 6 | +| `test/unit/core/unit_HttpServerModule_sdcard.cpp` | 2 | `lines 376-381, also in test/unit/core/unit_HttpServerModule_sdcard.cpp:366`, duplicated block 6 | | `test/unit/core/unit_OscPacket.cpp` | 2 | `lines 179-186, also in test/unit/core/unit_OscPacket.cpp:14`, duplicated block 8 | | `test/unit/core/unit_SystemModule.cpp` | 2 | `lines 117-123, also in test/unit/core/unit_SystemModule.cpp:51`, duplicated block 7 | | `test/unit/core/unit_moonlive_spill.cpp` | 2 | `lines 162-172, also in test/unit/core/unit_moonlive_spill.cpp:136`, duplicated block 11 | @@ -277,7 +276,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_MoonLiveMotion.cpp` | 2 | `lines 209-214, also in test/unit/light/unit_MoonLiveMotion.cpp:118`, duplicated block 6 | | `test/unit/light/unit_MoonLiveParticles.cpp` | 2 | `lines 158-163, also in test/unit/light/unit_MoonLiveParticles.cpp:142`, duplicated block 6 | | `test/unit/light/unit_MoonLiveScriptResolve.cpp` | 2 | `lines 90-97, also in test/unit/light/unit_MoonLiveScriptResolve.cpp:78`, duplicated block 8 | -| `test/unit/light/unit_NetworkReceiveEffect_protocols.cpp` | 2 | `lines 23-44, also in test/unit/light/unit_NetworkReceiveEffect.cpp:18`, duplicated block 22 | +| `test/unit/light/unit_NetworkReceiveEffect_protocols.cpp` | 2 | `lines 23-44, also in test/unit/light/unit_NetworkReceiveEffect.cpp:23`, duplicated block 22 | | `test/unit/light/unit_NoiseEffect.cpp` | 2 | `lines 33-48, also in test/unit/light/unit_PolarLut_equivalence.cpp:40`, duplicated block 16 | | `test/unit/light/unit_Scroll.cpp` | 2 | `lines 6-20, also in test/unit/light/unit_Splat.cpp:6`, duplicated block 15 | | `test/unit/light/unit_SineEffect.cpp` | 2 | `lines 22-30, also in test/unit/light/unit_DistortionWavesEffect.cpp:26`, duplicated block 9 | @@ -288,14 +287,14 @@ The next function worth simplifying is at the top of each file's list. | `moondeck/check/check_nonblocking.py` | 1 | `lines 30-40, also in moondeck/check/check_footprint.py:31`, duplicated block 11 | | `moondeck/ci/verify_version.py` | 1 | `lines 39-45, also in moondeck/scenario/run_scenario.py:33`, duplicated block 7 | | `moondeck/docs/build_docs.py` | 1 | `lines 43-49, also in moondeck/scenario/run_scenario.py:33`, duplicated block 7 | -| `moondeck/docs/screenshot_modules.py` | 1 | `screenshot_modules.py`, large file 1227 | +| `moondeck/docs/screenshot_modules.py` | 1 | `screenshot_modules.py`, large file 1248 | | `moondeck/moondeck.py` | 1 | `moondeck.py`, large file 2696 | | `moondeck/moontube/mtmeasure.py` | 1 | `lines 42-49, also in moondeck/moontube/mtnarrate.py:91`, duplicated block 8 | | `moondeck/moontube/mtnarrate.py` | 1 | `lines 91-98, also in moondeck/moontube/mtmeasure.py:42`, duplicated block 8 | | `moondeck/moontube/mtrun.py` | 1 | `mtrun.py`, large file 1711 | -| `moondeck/scenario/run_live_scenario.py` | 1 | `run_live_scenario.py`, large file 1677 | -| `mooninstaller/install-orchestrator.js` | 1 | `install-orchestrator.js`, large file 1386 | -| `mooninstaller/install.js` | 1 | `install.js`, large file 1553 | +| `moondeck/scenario/run_live_scenario.py` | 1 | `run_live_scenario.py`, large file 1789 | +| `mooninstaller/install-orchestrator.js` | 1 | `install-orchestrator.js`, large file 1473 | +| `mooninstaller/install.js` | 1 | `install.js`, large file 1610 | | `src/core/module/Control.h` | 1 | `sanitizeHostname`, complex function 16 | | `src/core/moonlive/MoonLive.h` | 1 | `moonlive::MoonLive::addDeclaredControl`, complex function 16 | | `src/core/moonlive/MoonLiveIr.h` | 1 | `moonlive::IrProgram::push`, complex function 12 | @@ -303,11 +302,12 @@ The next function worth simplifying is at the top of each file's list. | `src/core/services/AudioLevel.h` | 1 | `computeLevel`, complex function 14 | | `src/core/services/MoonLiveService.h` | 1 | `moonlive::MoonLive::runValue in tick20ms`, blocking call on the render path 1 | | `src/core/services/OscPacket.h` | 1 | `osc::parse`, complex function 32 | -| `src/core/services/Services.h` | 1 | `Services in 14 files`, name spread 14 | +| `src/core/services/Services.h` | 1 | `Services in 16 files`, name spread 16 | | `src/core/system/FileManagerModule.cpp` | 1 | `platform::filesystemUsed in tick1s`, blocking call on the render path 1 | | `src/core/system/FilesystemModule.h` | 1 | `FilesystemModule in 25 files`, name spread 25 | | `src/core/util/Addressing.h` | 1 | `sendAddressed`, complex function 11 | | `src/core/util/CaptivePortal.h` | 1 | `captive::dnsReply`, complex function 21 | +| `src/core/util/HostResolver.h` | 1 | `HostResolver::lookup`, complex function 13 | | `src/core/util/ImprovFrame.h` | 1 | `ImprovFrameParser::feed`, complex function 22 | | `src/core/util/RtpMidi.h` | 1 | `rtpmidi::findList`, complex function 11 | | `src/core/util/color.h` | 1 | `rgbToHueSat`, complex function 12 | @@ -316,6 +316,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/drivers/I80Peripheral.h` | 1 | `I80Peripheral::validateBusFatal`, complex function 14 | | `src/light/drivers/NdiDriver.h` | 1 | `platform::ndiSendFrame in tick`, blocking call on the render path 1 | | `src/light/effects/BallpitEffect.h` | 1 | `lines 80-85, also in src/light/effects/FireworksEffect.h:83`, duplicated block 6 | +| `src/light/effects/BeatRipplesEffect.h` | 1 | `BeatRipplesEffect::splash`, complex function 12 | | `src/light/effects/DemoReelEffect.h` | 1 | `DemoReelEffect::advance in tick`, blocking call on the render path 1 | | `src/light/effects/DistortionWavesEffect.h` | 1 | `lines 65-75, also in src/light/effects/SpiralEffect.h:79`, duplicated block 11 | | `src/light/effects/FireworksEffect.h` | 1 | `lines 83-88, also in src/light/effects/BallpitEffect.h:80`, duplicated block 6 | @@ -332,7 +333,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/layers/Effects.h` | 1 | `Effects in 77 files`, name spread 77 | | `src/light/layouts/CubeLayout.h` | 1 | `lines 81-88, also in src/light/layouts/GridLayout.h:43`, duplicated block 8 | | `src/light/layouts/HumanSizedCubeLayout.h` | 1 | `HumanSizedCubeLayout::placeLights`, complex function 21 | -| `src/light/layouts/Layouts.h` | 1 | `Layouts in 109 files`, name spread 109 | +| `src/light/layouts/Layouts.h` | 1 | `Layouts in 111 files`, name spread 111 | | `src/light/layouts/PanelLayout.h` | 1 | `lines 54-69, also in src/light/layouts/PanelsLayout.h:64`, duplicated block 16 | | `src/light/layouts/PanelsLayout.h` | 1 | `lines 64-79, also in src/light/layouts/PanelLayout.h:54`, duplicated block 16 | | `src/light/layouts/SphereLayout.h` | 1 | `SphereLayout::forEachShellPoint`, deeply nested 4 | @@ -348,9 +349,8 @@ The next function worth simplifying is at the top of each file's list. | `src/platform/esp32/platform_esp32_i2s.cpp` | 1 | `platform::audioMicInit`, complex function 11 | | `src/platform/esp32/platform_esp32_sdmmc.cpp` | 1 | `platform::mountCard`, complex function 13 | | `src/platform/esp32/platform_esp32_tasks.cpp` | 1 | `platform::taskSnapshot`, complex function 12 | -| `src/platform/esp32/platform_esp32_usb_midi.cpp` | 1 | `lines 254-262, also in src/platform/teensy/platform_teensy.cpp:723`, duplicated block 9 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1441 | -| `src/platform/teensy/platform_teensy.cpp` | 1 | `lines 723-731, also in src/platform/esp32/platform_esp32_usb_midi.cpp:254`, duplicated block 9 | +| `src/platform/esp32/platform_esp32_usb_midi.cpp` | 1 | `lines 254-262, also in src/platform/teensy/platform_teensy.cpp:769`, duplicated block 9 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1453 | | `src/platform/teensy/platform_teensy_gpio_dma.cpp` | 1 | `lines 311-316, also in src/platform/esp32/platform_esp32_parlio.cpp:287`, duplicated block 6 | | `src/platform/teensy/platform_teensy_improv.cpp` | 1 | `platform::improvProvisioningInit`, long parameter list 9 | | `src/ui/install-picker.js` | 1 | `install-picker.js`, large file 1048 | @@ -376,7 +376,7 @@ The next function worth simplifying is at the top of each file's list. | `test/unit/light/unit_GridLayout.cpp` | 1 | `lines 8-21, also in test/unit/light/unit_SingleColumnLayout.cpp:12`, duplicated block 14 | | `test/unit/light/unit_Layer_persistence.cpp` | 1 | `lines 45-51, also in test/unit/light/unit_TextEffect.cpp:18`, duplicated block 7 | | `test/unit/light/unit_Layer_phase_animation.cpp` | 1 | `lines 77-85, also in test/unit/light/unit_RandomMapModifier.cpp:115`, duplicated block 9 | -| `test/unit/light/unit_NetworkReceiveEffect.cpp` | 1 | `lines 18-39, also in test/unit/light/unit_NetworkReceiveEffect_protocols.cpp:23`, duplicated block 22 | +| `test/unit/light/unit_NetworkReceiveEffect.cpp` | 1 | `lines 23-44, also in test/unit/light/unit_NetworkReceiveEffect_protocols.cpp:23`, duplicated block 22 | | `test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp` | 1 | `lines 93-104, also in test/unit/light/unit_ParallelLedDriver_pinexpander.cpp:94`, duplicated block 12 | | `test/unit/light/unit_Rings241Layout.cpp` | 1 | `lines 18-30, also in test/unit/light/unit_SingleColumnLayout.cpp:19`, duplicated block 13 | | `test/unit/light/unit_SpaceInvadersEffect.cpp` | 1 | `lines 13-20, also in test/unit/light/unit_SpritePopulation.cpp:23`, duplicated block 8 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index 06ec75cbb..0488a0655 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,9 +4,9 @@ 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 **3736 warning(s)** across 434 page(s). +**0 error(s)** and **3711 warning(s)** across 429 page(s). -**5511 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. +**5496 appendix line(s)** after `@moreinfo`. This only falls as well: a cleanup cuts first, and moves only what neither the code, the test name nor the spec says. 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 worth fixing without being worth stopping a commit for: any finding in an implementation file, which publishes nothing, and the rules still staged as counts in a header too, the over-wide line and the appendix section nothing refers to. Both are counted here, because a warning nobody sees is a warning nobody fixes. @@ -16,12 +16,12 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| -| over-wide comment lines | 0 | 1568 | -| split paragraphs | 0 | 1278 | -| appendix sections nothing refers to | 0 | 355 | +| over-wide comment lines | 0 | 1566 | +| split paragraphs | 0 | 1267 | +| appendix sections nothing refers to | 0 | 348 | | multi-line comment blocks | 0 | 311 | -| over-long sentences | 0 | 81 | -| over-wide doc lines | 0 | 76 | +| over-long sentences | 0 | 79 | +| over-wide doc lines | 0 | 73 | | member deep dives | 0 | 67 | ## By documentation area @@ -30,16 +30,16 @@ 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 | 2030 | +| `(tests, no card)` | 0 | 2025 | | `platform/index.md` | 0 | 658 | -| `light/effects.md` | 0 | 251 | -| `core/system.md` | 0 | 138 | -| `core/supporting.md` | 0 | 132 | +| `light/effects.md` | 0 | 242 | +| `core/system.md` | 0 | 134 | +| `core/supporting.md` | 0 | 131 | | `light/power-functions.md` | 0 | 122 | -| `light/drivers.md` | 0 | 97 | +| `light/drivers.md` | 0 | 98 | | `core/services.md` | 0 | 88 | -| `light/supporting.md` | 0 | 64 | | `light/layouts.md` | 0 | 59 | +| `light/supporting.md` | 0 | 57 | | `light/moonlive.md` | 0 | 49 | | `light/modifiers.md` | 0 | 47 | | `(unassigned)` | 0 | 1 | @@ -50,7 +50,7 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul ### (tests, no card) -**0 error(s)** and **2030 warning(s)** across 162 file(s). +**0 error(s)** and **2025 warning(s)** across 163 file(s). | Findings | File | | |---:|---|---| @@ -64,9 +64,9 @@ Per area, since that is the unit a sweep runs in: the files ranked, then the rul | 48 | `test/unit/light/unit_Effects_gridsweep.cpp` | warning | | 46 | `test/unit/light/unit_ParallelSlots.cpp` | warning | | 45 | `test/unit/core/unit_FilesystemModule_persistence.cpp` | warning | -| 1-44 each | *152 more warning files, 1281 findings* | | +| 1-44 each | *153 more warning files, 1276 findings* | | -By rule, warnings: 1350 over-wide comment lines, 253 split paragraphs, 196 multi-line comment blocks, 80 over-long sentences, 76 over-wide doc lines, 67 member deep dives, 8 appendix sections nothing refers to. +By rule, warnings: 1350 over-wide comment lines, 253 split paragraphs, 196 multi-line comment blocks, 78 over-long sentences, 73 over-wide doc lines, 67 member deep dives, 8 appendix sections nothing refers to. ### platform/index.md @@ -90,7 +90,7 @@ By rule, warnings: 321 split paragraphs, 214 over-wide comment lines, 114 multi- ### light/effects.md -**0 error(s)** and **251 warning(s)** across 69 file(s). +**0 error(s)** and **242 warning(s)** across 67 file(s). | Findings | File | | |---:|---|---| @@ -104,13 +104,13 @@ By rule, warnings: 321 split paragraphs, 214 over-wide comment lines, 114 multi- | 5 | `src/light/effects/DemoReelEffect.h` | warning | | 5 | `src/light/effects/FireworksEffect.h` | warning | | 5 | `src/light/effects/FreqMatrixEffect.h` | warning | -| 1-5 each | *59 more warning files, 188 findings* | | +| 1-5 each | *57 more warning files, 179 findings* | | -By rule, warnings: 170 split paragraphs, 81 appendix sections nothing refers to. +By rule, warnings: 164 split paragraphs, 78 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **138 warning(s)** across 30 file(s). +**0 error(s)** and **134 warning(s)** across 28 file(s). | Findings | File | | |---:|---|---| @@ -124,13 +124,13 @@ By rule, warnings: 170 split paragraphs, 81 appendix sections nothing refers to. | 7 | `src/core/system/DevicesModule.h` | warning | | 7 | `src/core/system/ImprovProvisioningModule.h` | warning | | 7 | `src/core/system/MqttModule.h` | warning | -| 1-6 each | *20 more warning files, 49 findings* | | +| 1-6 each | *18 more warning files, 45 findings* | | -By rule, warnings: 99 split paragraphs, 34 appendix sections nothing refers to, 4 over-wide comment lines, 1 multi-line comment blocks. +By rule, warnings: 97 split paragraphs, 34 appendix sections nothing refers to, 2 over-wide comment lines, 1 multi-line comment blocks. ### core/supporting.md -**0 error(s)** and **132 warning(s)** across 31 file(s). +**0 error(s)** and **131 warning(s)** across 30 file(s). | Findings | File | | |---:|---|---| @@ -144,9 +144,9 @@ By rule, warnings: 99 split paragraphs, 34 appendix sections nothing refers to, | 6 | `src/core/util/FirmwareImage.h` | warning | | 6 | `src/core/util/LightOutput.h` | warning | | 6 | `src/core/util/PinList.h` | warning | -| 1-5 each | *21 more warning files, 59 findings* | | +| 1-5 each | *20 more warning files, 58 findings* | | -By rule, warnings: 91 split paragraphs, 41 appendix sections nothing refers to. +By rule, warnings: 90 split paragraphs, 41 appendix sections nothing refers to. ### light/power-functions.md @@ -170,7 +170,7 @@ By rule, warnings: 78 split paragraphs, 44 appendix sections nothing refers to. ### light/drivers.md -**0 error(s)** and **97 warning(s)** across 16 file(s). +**0 error(s)** and **98 warning(s)** across 16 file(s). | Findings | File | | |---:|---|---| @@ -183,10 +183,10 @@ By rule, warnings: 78 split paragraphs, 44 appendix sections nothing refers to. | 4 | `src/light/drivers/FixtureProfilesModule.h` | warning | | 3 | `src/light/drivers/HlsDriver.h` | warning | | 3 | `src/light/drivers/MoonI80Peripheral.h` | warning | -| 3 | `src/light/drivers/RmtLedDriver.h` | warning | -| 1-3 each | *6 more warning files, 12 findings* | | +| 3 | `src/light/drivers/NetworkSendDriver.h` | warning | +| 1-3 each | *6 more warning files, 13 findings* | | -By rule, warnings: 49 split paragraphs, 48 appendix sections nothing refers to. +By rule, warnings: 51 split paragraphs, 47 appendix sections nothing refers to. ### core/services.md @@ -208,26 +208,6 @@ By rule, warnings: 49 split paragraphs, 48 appendix sections nothing refers to. By rule, warnings: 64 split paragraphs, 24 appendix sections nothing refers to. -### light/supporting.md - -**0 error(s)** and **64 warning(s)** across 14 file(s). - -| Findings | File | | -|---:|---|---| -| 9 | `src/light/layers/BlendMap.h` | warning | -| 7 | `src/light/layers/Layer.h` | warning | -| 7 | `src/light/layers/MappingLUT.h` | warning | -| 7 | `src/light/util/FixtureChannels.h` | warning | -| 6 | `src/light/util/DdpPacket.h` | warning | -| 5 | `src/light/layers/Buffer.h` | warning | -| 5 | `src/light/layers/Effects.h` | warning | -| 4 | `src/light/util/ArtNetPacket.h` | warning | -| 4 | `src/light/util/RtpH264.h` | warning | -| 3 | `src/light/util/light_types.h` | warning | -| 1-2 each | *4 more warning files, 7 findings* | | - -By rule, warnings: 35 split paragraphs, 29 appendix sections nothing refers to. - ### light/layouts.md **0 error(s)** and **59 warning(s)** across 16 file(s). @@ -248,6 +228,26 @@ By rule, warnings: 35 split paragraphs, 29 appendix sections nothing refers to. By rule, warnings: 53 split paragraphs, 6 appendix sections nothing refers to. +### light/supporting.md + +**0 error(s)** and **57 warning(s)** across 13 file(s). + +| Findings | File | | +|---:|---|---| +| 9 | `src/light/layers/BlendMap.h` | warning | +| 7 | `src/light/layers/Layer.h` | warning | +| 7 | `src/light/util/FixtureChannels.h` | warning | +| 6 | `src/light/util/DdpPacket.h` | warning | +| 5 | `src/light/layers/Buffer.h` | warning | +| 5 | `src/light/layers/Effects.h` | warning | +| 4 | `src/light/util/ArtNetPacket.h` | warning | +| 4 | `src/light/util/RtpH264.h` | warning | +| 3 | `src/light/util/light_types.h` | warning | +| 2 | `src/light/util/E131Packet.h` | warning | +| 1-2 each | *3 more warning files, 5 findings* | | + +By rule, warnings: 31 split paragraphs, 26 appendix sections nothing refers to. + ### light/moonlive.md **0 error(s)** and **49 warning(s)** across 19 file(s). diff --git a/docs/reference/metrics/prose.md b/docs/reference/metrics/prose.md index f53799ae5..d0a3201c2 100644 --- a/docs/reference/metrics/prose.md +++ b/docs/reference/metrics/prose.md @@ -9,14 +9,14 @@ This number is the debt behind that gate, and it RATCHETS: a rise fails the chec docgen.md does. A comment is prose wherever it lives, so a `//` in the web interface and a `#` in a script are counted beside a `///` in a header. -**2859 finding(s)** across 365 file(s). +**2847 finding(s)** across 361 file(s). | rule | findings | |---|---| -| `MoonLight.EmDash` | 1461 | -| `MoonLight.Weasel` | 676 | +| `MoonLight.EmDash` | 1455 | +| `MoonLight.Weasel` | 671 | | `MoonLight.SentenceLength` | 426 | -| `MoonLight.Temporal` | 211 | +| `MoonLight.Temporal` | 210 | | `MoonLight.Spelling` | 42 | | `MoonLight.SelfReference` | 30 | | `MoonLight.NegatedHeading` | 13 | @@ -28,13 +28,13 @@ docgen.md does. A comment is prose wherever it lives, so a `//` in the web inter | `moondeck/MoonDeck.md` | 239 | | `moondeck/moondeck.py` | 156 | | `docs/how-to/home-automation.md` | 117 | -| `mooninstaller/install-orchestrator.js` | 93 | | `docs/tutorials/build-your-own-moonmodules.md` | 92 | | `moondeck/moondeck_ui/app.js` | 92 | +| `mooninstaller/install-orchestrator.js` | 91 | | `docs/how-to/building.md` | 78 | | `moondeck/check/check_clang_query.py` | 73 | -| `mooninstaller/install.js` | 69 | | `src/ui/app.js` | 69 | +| `mooninstaller/install.js` | 68 | | `src/ui/install-picker.js` | 55 | | `moondeck/docs/gen_api.py` | 53 | | `src/ui/preview3d.js` | 49 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index d416e3fee..b17e60763 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,44 +1,44 @@ { - "commit": "ce37ca46", + "commit": "f487420a", "flash": { - "esp32s3-n16r8": 2438784, - "esp32": 2353056, - "esp32p4rev1-eth": 2364160, - "esp32p4rev1-eth-wifi": 2523840, - "esp32s3-n8r8": 2438784, - "esp32s31": 2694736, - "esp32-16mb": 2353248, + "esp32s3-n16r8": 2452928, + "esp32": 2258576, + "esp32p4rev1-eth": 2396368, + "esp32p4rev1-eth-wifi": 2623840, + "esp32s3-n8r8": 2437792, + "esp32s31": 2744032, + "esp32-16mb": 2356048, "esp32-eth": 1681040, "esp32-wrover": 1843760, "qemu": 1383648, "esp32p4rev3-eth": 2231792, - "esp32s3-zero": 2225840, + "esp32s3-zero": 2224816, "esp32-pico": 2107168, - "desktop-macos": 2356920, + "desktop-macos": 2382040, "desktop-windows": 1925120, "esp32c3": 2335072, "desktop-linux": 2548000, - "esp32s3-n32r16": 2438000, - "teensy41": 1266668 + "esp32s3-n32r16": 2475808, + "teensy41": 1271788 }, "measured": { - "esp32p4rev1-eth": "2026-10-13", + "esp32p4rev1-eth": "2026-10-14", "esp32s31": "2026-10-13", - "esp32": "2026-10-13", + "esp32": "2026-10-14", "esp32-pico": "2026-09-09", - "esp32s3-n16r8": "2026-10-13", + "esp32s3-n16r8": "2026-10-14", "esp32s3-n8r8": "2026-10-13", "esp32s3-zero": "2026-10-13", "esp32-16mb": "2026-10-13", - "esp32p4rev1-eth-wifi": "2026-10-13", + "esp32p4rev1-eth-wifi": "2026-10-14", "esp32-eth": "2026-09-11", "esp32p4rev3-eth": "2026-10-13", - "desktop-macos": "2026-10-13", + "desktop-macos": "2026-10-14", "desktop-windows": "2026-09-30", "esp32c3": "2026-10-10", "desktop-linux": "2026-10-12", - "esp32s3-n32r16": "2026-10-13", - "teensy41": "2026-10-13" + "esp32s3-n32r16": "2026-10-14", + "teensy41": "2026-10-14" }, "perf": { "esp32": { @@ -50,9 +50,9 @@ "Control_a_palette_preset_changes_only_the_palette": { "desktop-macos": { "p50": 8, - "p95": 324, - "n": 31, - "last": "2026-10-12" + "p95": 14, + "n": 32, + "last": "2026-10-14" }, "esp32s3-n16r8": { "p50": 2073, @@ -127,10 +127,10 @@ }, "State_one_document_adds_an_effect_with_its_controls": { "desktop-macos": { - "p50": 4, - "p95": 387, - "n": 30, - "last": "2026-10-12" + "p50": 7, + "p95": 8, + "n": 32, + "last": "2026-10-14" }, "esp32s3-n16r8": { "p50": 2505, @@ -319,6 +319,52 @@ "last": "2026-10-11" } }, + "NetworkReceive_two_boards": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "p50": 30612, + "p95": 33261, + "n": 2, + "last": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "p50": 11930, + "p95": 21562, + "n": 2, + "last": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "p50": 37919, + "p95": 38723, + "n": 3, + "last": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "p50": 15741, + "p95": 22861, + "n": 6, + "last": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "p50": 3402, + "p95": 3402, + "n": 1, + "last": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "p50": 5022, + "p95": 6680, + "n": 2, + "last": "2026-10-14" + } + }, + "NetworkSend_one_sender_many_receivers": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "p50": 3847, + "p95": 3980, + "n": 2, + "last": "2026-10-14" + } + }, "perf_full": { "desktop-macos": { "p50": 256, @@ -546,9 +592,9 @@ "Drivers_output_and_brightness": { "desktop-macos": { "p50": 7, - "p95": 15, + "p95": 9, "n": 32, - "last": "2026-10-12" + "last": "2026-10-14" }, "desktop-windows": { "p50": 4, @@ -580,7 +626,7 @@ "p50": 7, "p95": 10, "n": 32, - "last": "2026-10-12" + "last": "2026-10-14" }, "desktop-windows": { "p50": 4, @@ -592,9 +638,9 @@ "Effects_swap_while_running": { "desktop-macos": { "p50": 1, - "p95": 2, + "p95": 1, "n": 32, - "last": "2026-10-12" + "last": "2026-10-14" }, "desktop-windows": { "p50": 4, @@ -623,10 +669,10 @@ }, "Effects_teardown_under_a_running_pipeline": { "desktop-macos": { - "p50": 7, - "p95": 18, + "p50": 8, + "p95": 15, "n": 32, - "last": "2026-10-12" + "last": "2026-10-14" }, "esp32s3-n16r8": { "p50": 9080, @@ -656,9 +702,9 @@ "Layers_stack_and_blend_live": { "desktop-macos": { "p50": 267, - "p95": 355, + "p95": 309, "n": 32, - "last": "2026-10-12" + "last": "2026-10-14" } }, "Layouts_resize_reallocates_live": { @@ -666,7 +712,7 @@ "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "desktop-windows": { "p50": 93, @@ -696,9 +742,9 @@ "Modifiers_reshape_the_mapping": { "desktop-macos": { "p50": 7, - "p95": 11, + "p95": 10, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "esp32s3-n16r8": { "p50": 10350, @@ -728,9 +774,9 @@ "Palettes_a_script_drives_the_effects": { "desktop-macos": { "p50": 7, - "p95": 74, + "p95": 11, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" } } }, @@ -742,13 +788,13 @@ "p50": 7, "p95": 10, "n": 32, - "last": "2026-10-12" + "last": "2026-10-14" }, "Layouts_resize_reallocates_live": { "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" } } }, @@ -776,54 +822,54 @@ } }, "loc": { - "core": 30056, - "light": 31265, - "platform": 21118, - "ui": 12470, - "test": 67258, - "moondeck": 31290 + "core": 30403, + "light": 31876, + "platform": 21635, + "ui": 12473, + "test": 68328, + "moondeck": 31610 }, "comments": { "core": { - "lines": 6425, - "ratio": 0.237 + "lines": 6486, + "ratio": 0.236 }, "light": { - "lines": 7512, - "ratio": 0.27 + "lines": 7597, + "ratio": 0.268 }, "platform": { - "lines": 4133, - "ratio": 0.219 + "lines": 4172, + "ratio": 0.216 }, "ui": { - "lines": 3470, + "lines": 3471, "ratio": 0.295 }, "test": { - "lines": 7114, + "lines": 7193, "ratio": 0.122 }, "moondeck": { - "lines": 4933, - "ratio": 0.18 + "lines": 4942, + "ratio": 0.178 } }, "tests": { - "cases": 2515, - "scenarios": 30 + "cases": 2555, + "scenarios": 32 }, "docs": { - "md_files": 156, - "md_lines": 33332, - "plans_files": 48, - "backlog_lines": 3399, + "md_files": 161, + "md_lines": 34209, + "plans_files": 50, + "backlog_lines": 3393, "lessons_lines": 532, - "claude_md_lines": 285 + "claude_md_lines": 287 }, "complexity": { - "functions": 4989, - "over_threshold": 354, + "functions": 5087, + "over_threshold": 353, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 0e4206943..11986bfc0 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `ce37ca46`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `f487420a`. 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. @@ -9,29 +9,29 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | Metric | v6.0.0 | Now | Change | |---|---:|---:|---:| | flash desktop-linux | - | 2,488 KB | new | -| flash desktop-macos | 1,963 KB | 2,302 KB | +338.7 KB (+17.3%) | -| flash esp32 | 2,061 KB | 2,298 KB | +236.7 KB (+11.5%) | -| flash esp32-16mb | 2,012 KB | 2,298 KB | +286.0 KB (+14.2%) | +| flash desktop-macos | 1,963 KB | 2,326 KB | +363.2 KB (+18.5%) | +| flash esp32 | 2,061 KB | 2,206 KB | +144.5 KB (+7.0%) | +| flash esp32-16mb | 2,012 KB | 2,301 KB | +288.8 KB (+14.4%) | | flash esp32c3 | - | 2,280 KB | new | -| flash esp32p4rev1-eth | 2,022 KB | 2,309 KB | +287.0 KB (+14.2%) | -| flash esp32p4rev1-eth-wifi | 2,304 KB | 2,465 KB | +161.1 KB (+7.0%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,340 KB | +318.5 KB (+15.8%) | +| flash esp32p4rev1-eth-wifi | 2,304 KB | 2,562 KB | +258.7 KB (+11.2%) | | flash esp32p4rev3-eth | 2,025 KB | 2,179 KB | +154.2 KB (+7.6%) | -| flash esp32s3-n16r8 | 2,111 KB | 2,382 KB | +270.1 KB (+12.8%) | -| flash esp32s3-n32r16 | - | 2,381 KB | new | -| flash esp32s3-n8r8 | 2,038 KB | 2,382 KB | +343.4 KB (+16.8%) | -| flash esp32s3-zero | 1,977 KB | 2,174 KB | +196.9 KB (+10.0%) | -| flash esp32s31 | 2,395 KB | 2,632 KB | +236.2 KB (+9.9%) | -| flash teensy41 | - | 1,237 KB | new | +| flash esp32s3-n16r8 | 2,111 KB | 2,395 KB | +284.0 KB (+13.4%) | +| flash esp32s3-n32r16 | - | 2,418 KB | new | +| flash esp32s3-n8r8 | 2,038 KB | 2,381 KB | +342.4 KB (+16.8%) | +| flash esp32s3-zero | 1,977 KB | 2,173 KB | +195.9 KB (+9.9%) | +| flash esp32s31 | 2,395 KB | 2,680 KB | +284.3 KB (+11.9%) | +| flash teensy41 | - | 1,242 KB | new | | tick Effects_pipeline_builds_and_renders (desktop-macos) | 7 µs | 7 µs | +0 (+0.0%) | | tick Layouts_resize_reallocates_live (desktop-macos) | 12 µs | 10 µs | -2 (-16.7%) | -| lines core | 22,299 | 30,056 | +7,757 (+34.8%) | -| lines light | 31,054 | 31,265 | +211 (+0.7%) | -| lines platform | 16,798 | 21,118 | +4,320 (+25.7%) | -| lines ui | 11,394 | 12,470 | +1,076 (+9.4%) | -| lines test | 57,759 | 67,258 | +9,499 (+16.4%) | -| lines moondeck | 29,325 | 31,290 | +1,965 (+6.7%) | -| unit tests | 2,120 | 2,515 | +395 (+18.6%) | -| scenarios | 21 | 30 | +9 (+42.9%) | +| lines core | 22,299 | 30,403 | +8,104 (+36.3%) | +| lines light | 31,054 | 31,876 | +822 (+2.6%) | +| lines platform | 16,798 | 21,635 | +4,837 (+28.8%) | +| lines ui | 11,394 | 12,473 | +1,079 (+9.5%) | +| lines test | 57,759 | 68,328 | +10,569 (+18.3%) | +| lines moondeck | 29,325 | 31,610 | +2,285 (+7.8%) | +| unit tests | 2,120 | 2,555 | +435 (+20.5%) | +| scenarios | 21 | 32 | +11 (+52.4%) | Against the release `v6.0.0`, the fixed point a user compares with; the tables below compare against the last commit. A firmware not rebuilt since `v6.0.0` is left out, and a measure the release did not record yet, such as the code report, joins from the next one. @@ -39,25 +39,25 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Flash | Capacity | Used | Built | |---|---:|---:|---:|:--:| -| desktop-linux | 2,488 KB | - | - | carried 1d | -| desktop-macos | 2,302 KB (+448 B) ⚠ | - | - | yes | -| desktop-windows | 1,880 KB | - | - | **STALE 13d** | -| esp32 | 2,298 KB | 2,496 KB | 92% | carried 0d | -| esp32-16mb | 2,298 KB | 4,096 KB | 56% | carried 0d | -| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 32d** | -| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 34d** | +| desktop-linux | 2,488 KB | - | - | carried 2d | +| desktop-macos | 2,326 KB (−144 B) ✓ | - | - | yes | +| desktop-windows | 1,880 KB | - | - | **STALE 14d** | +| esp32 | 2,206 KB (−192 B) ✓ | 2,496 KB | 88% | yes | +| esp32-16mb | 2,301 KB | 4,096 KB | 56% | carried 1d | +| esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 33d** | +| esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 35d** | | esp32-wrover | 1,801 KB | 2,496 KB | 72% | carried (age?) | -| esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 3d | -| esp32p4rev1-eth | 2,309 KB | 4,096 KB | 56% | carried 0d | -| esp32p4rev1-eth-wifi | 2,465 KB | 4,096 KB | 60% | carried 0d | -| esp32p4rev3-eth | 2,179 KB | 4,096 KB | 53% | carried 0d | -| esp32s3-n16r8 | 2,382 KB | 4,096 KB | 58% | yes | -| esp32s3-n32r16 | 2,381 KB | 4,096 KB | 58% | yes | -| esp32s3-n8r8 | 2,382 KB | 3,072 KB | 78% | yes | -| esp32s3-zero | 2,174 KB | 2,496 KB | 87% | yes | -| esp32s31 | 2,632 KB | 4,096 KB | 64% | carried 0d | +| esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 4d | +| esp32p4rev1-eth | 2,340 KB (−368 B) ✓ | 4,096 KB | 57% | yes | +| esp32p4rev1-eth-wifi | 2,562 KB | 4,096 KB | 63% | yes | +| esp32p4rev3-eth | 2,179 KB | 4,096 KB | 53% | carried 1d | +| esp32s3-n16r8 | 2,395 KB (−192 B) ✓ | 4,096 KB | 58% | yes | +| esp32s3-n32r16 | 2,418 KB (+1,008 B) ⚠ | 4,096 KB | 59% | yes | +| esp32s3-n8r8 | 2,381 KB | 3,072 KB | 77% | carried 1d | +| esp32s3-zero | 2,173 KB | 2,496 KB | 87% | carried 1d | +| esp32s31 | 2,680 KB | 4,096 KB | 65% | carried 1d | | qemu | 1,351 KB | - | - | carried (age?) | -| teensy41 | 1,237 KB (−1 KB) ✓ | 3,456 KB | 36% | yes | +| teensy41 | 1,242 KB | 3,456 KB | 36% | yes | `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. @@ -65,17 +65,19 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32s3-n16r8 | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `ce37ca46` | -| esp32s3-n32r16 | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `ce37ca46` | -| esp32s3-n8r8 | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `ce37ca46` | -| esp32s3-zero | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `ce37ca46` | +| esp32 | -0.2 KB | +0.4 KB | -0.8 KB | +0.2 KB | -0.1 KB | +0.1 KB | `f487420a` | +| esp32p4rev1-eth | -0.4 KB | +0.4 KB | -0.8 KB | +0.2 KB | -0.2 KB | +0.1 KB | `f487420a` | +| esp32p4rev1-eth-wifi | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `f487420a` | +| esp32s3-n16r8 | -0.2 KB | +0.2 KB | -0.5 KB | +0.2 KB | -0.1 KB | +0.1 KB | `f487420a` | +| esp32s3-n32r16 | +1.0 KB | +2.5 KB | -1.7 KB | +0.5 KB | -0.7 KB | +0.3 KB | `f487420a` | Largest moves: -- **esp32s3-n16r8**: none -- **esp32s3-n32r16**: none -- **esp32s3-n8r8**: none -- **esp32s3-zero**: none +- **esp32**: `mm::ChannelDataSink::copyIntoLayer(mm::Layer const*, bool)` -471 B, `mm::ChannelDataSink::copyIntoLayer(mm::Layer const*)` +439 B, `mm::MappingLUT::gatherFromPhysical(unsigned char*, unsigned ` -262 B, `mm::ControlModule::defineControls()` -48 B, `mm::platform::sleepUs(mm::platform::WorkerTask&, unsigned lo` +48 B +- **esp32p4rev1-eth**: `mm::ChannelDataSink::copyIntoLayer(mm::Layer const*, bool)` -454 B, `mm::ChannelDataSink::copyIntoLayer(mm::Layer const*)` +398 B, `mm::MappingLUT::gatherFromPhysical(unsigned char*, unsigned ` -386 B, `mm::platform::sleepUs(mm::platform::WorkerTask&, unsigned lo` +98 B, `mm::TetrixEffect::defineControls()` -80 B +- **esp32p4rev1-eth-wifi**: none +- **esp32s3-n16r8**: `mm::ChannelDataSink::copyIntoLayer(mm::Layer const*, bool)` -278 B, `mm::MappingLUT::gatherFromPhysical(unsigned char*, unsigned ` -254 B, `mm::ChannelDataSink::copyIntoLayer(mm::Layer const*)` +251 B, `mm::PanelCardDriver::release()` -63 B, `void mm::NetworkReceiveEffect::drainSockets()` +51 B +- **esp32s3-n32r16**: `mm::PictureService::cardCircle(float, float)` -965 B, `mm::NetworkReceiveEffect::tick()` -364 B, `_ZN2mm13ModuleFactory12registerTypeINS_13PreviewDriverEEEbPK` +330 B, `mm::PreviewDriver::PreviewDriver()` -316 B, `mm::MappingLUT::gatherFromPhysical(unsigned char*, unsigned ` -254 B What this commit's flash went to, symbol by symbol against the measurement at `Since`. New is new functionality, removed and shrunk are what a cleanup saved, and other is what no symbol owns, such as merged strings and alignment. A rename reads as removed plus new, which the largest moves expose. @@ -84,37 +86,39 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | Target | Tick | FPS | |---|---:|---:| | desktop-linux | 5 µs | 200,000 | -| desktop-macos | 7 µs | 142,857 | +| desktop-macos | 7 µs (+1 µs) ⚠ | 142,857 (−23,809) ⚠ | | desktop-windows | 4 µs | 250,000 | | esp32 | 1,716 µs | 582 | ### Scenario tick by target (p50 of each sample window) -| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | teensy41 | desktop-docker-arm64 | esp32p4rev1-eth-wifi | -|---|---|---|---|---|---|---|---|---|---|---|---| -| Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 33,192 ? | 7,532 ? | 1,942,284 ? | -| Control_a_palette_preset_changes_only_the_palette | 8 | - | - | 2,073 | - | - | - | - | - | - | - | -| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 2,144 ? | 218 ? | - | -| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | - | - | -| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 2,167 | 287 ? | - | -| Effects_teardown_under_a_running_pipeline | 7 | 17 ? | - | 9,080 | - | - | - | - | 2,159 ? | 482 ? | - | -| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 2,142 ? | 215 ? | - | -| Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 14,853 ? | 3,371 ? | 761,768 ? | -| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 1,521 ? | 2,005 ? | 6,735,719 ? | -| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | - | - | -| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 7,788 ? | 579 ? | - | -| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 2,165 ? | 273 ? | - | -| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 2,147 ? | 237 ? | - | -| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | - | - | -| State_one_document_adds_an_effect_with_its_controls | 4 | - | - | 2,505 ? | - | - | - | - | - | - | - | -| perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 6,797 ? | 2,438 ? | 5,469,660 ? | -| perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 662 ? | 780 ? | 5,385,763 ? | -| peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 6,618 ? | 2,577 ? | 1,673,544 ? | -| peripheral_switch | 4 | 9 ? | 425 | 2,307 | 2,570 ? | 2,990 ? | - | - | 111 ? | 341 ? | 773,191 ? | +| Scenario | desktop-macos | desktop-windows | esp32 | esp32s3-n16r8 | esp32p4rev1-eth | esp32s31 | esp32-eth | esp32-eth-wifi | teensy41 | desktop-docker-arm64 | esp32p4rev1-eth-wifi | host=esp32p4rev1-eth sender=esp32p4rev1-eth | host=esp32p4rev1-eth sender=esp32s3-n16r8 | host=esp32s3-n16r8 sender=esp32p4rev1-eth | host=esp32s3-n16r8 sender=esp32s3-n16r8 | host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth | host=teensy41 sender=esp32p4rev1-eth | host=teensy41 sender=esp32s3-n16r8 | +|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---| +| Aurora_fps | 1,549 | 6,015 ? | 217,295 ? | 188,782 ? | 83,632 ? | 87,613 ? | - | - | 33,192 ? | 7,532 ? | 1,942,284 ? | - | - | - | - | - | - | - | +| Control_a_palette_preset_changes_only_the_palette | 8 | - | - | 2,073 | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| Drivers_output_and_brightness | 7 | 4 ? | - | 8,690 ? | - | - | - | - | 2,144 ? | 218 ? | - | - | - | - | - | - | - | - | +| Effects_pipeline_builds_and_renders | 7 | 4 ? | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| Effects_swap_while_running | 1 | 4 ? | - | 10,000 ? | - | - | - | - | 2,167 | 287 ? | - | - | - | - | - | - | - | - | +| Effects_teardown_under_a_running_pipeline | 8 | 17 ? | - | 9,080 | - | - | - | - | 2,159 ? | 482 ? | - | - | - | - | - | - | - | - | +| Firmware_reports_what_is_running | 29 | 1,827 ? | - | 8,659 ? | - | - | - | - | 2,142 ? | 215 ? | - | - | - | - | - | - | - | - | +| Fluid_solver | 218 | 685,902 ? | 45,974 ? | 102,058 ? | 71,150 ? | 66,874 ? | - | - | 14,853 ? | 3,371 ? | 761,768 ? | - | - | - | - | - | - | - | +| GridLayout_resize | 120 | 219 ? | 1,352 ? | 18,414 ? | 7,218 ? | 8,919 ? | 95,771 ? | 82,231 ? | 1,521 ? | 2,005 ? | 6,735,719 ? | - | - | - | - | - | - | - | +| Layers_stack_and_blend_live | 267 | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| Layouts_resize_reallocates_live | 10 | 93 ? | - | 32,399 ? | - | - | - | - | 7,788 ? | 579 ? | - | - | - | - | - | - | - | - | +| Modifiers_reshape_the_mapping | 7 | 29 ? | - | 10,350 ? | - | - | - | - | 2,165 ? | 273 ? | - | - | - | - | - | - | - | - | +| NetworkReceive_two_boards | - | - | - | - | - | - | - | - | - | - | - | 15,741 | 11,930 ? | 37,919 ? | 30,612 ? | - | 3,402 ? | 5,022 ? | +| NetworkSend_one_sender_many_receivers | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | 3,847 ? | - | - | +| Network_hardware_reconfigures_live | 21 ? | 2,834 ? | - | 8,692 ? | - | - | - | - | 2,147 ? | 237 ? | - | - | - | - | - | - | - | - | +| Palettes_a_script_drives_the_effects | 7 | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| State_one_document_adds_an_effect_with_its_controls | 7 | - | - | 2,505 ? | - | - | - | - | - | - | - | - | - | - | - | - | - | - | +| perf_full | 256 | 592 ? | 5,313 | 25,340 ? | 11,711 ? | 13,373 ? | - | - | 6,797 ? | 2,438 ? | 5,469,660 ? | - | - | - | - | - | - | - | +| perf_light | 16 | 35 ? | 2,510 | 3,044 | 1,117 ? | 1,378 ? | - | - | 662 ? | 780 ? | 5,385,763 ? | - | - | - | - | - | - | - | +| peripheral_grid_sweep | 257 | 649 ? | 5,766 | 70,668 ? | 11,495 ? | 12,273 ? | - | - | 6,618 ? | 2,577 ? | 1,673,544 ? | - | - | - | - | - | - | - | +| peripheral_switch | 4 | 9 ? | 425 | 2,307 | 2,570 ? | 2,990 ? | - | - | 111 ? | 341 ? | 773,191 ? | - | - | - | - | - | - | - | Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first impression rather than a percentile; several are months old and were captured during a network reconfigure, so they read as whole milliseconds. `-` means that target has never run that scenario. -**Coverage: 108/209 cells measured (51%), 27 of them with 4+ samples (12%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. +**Coverage: 115/378 cells measured (30%), 28 of them with 4+ samples (7%).** The blanks are the point: a regression on a target that has never run a scenario cannot be DETECTED in it, and the target cannot be compared against the others. Filling the matrix means running the scenario suite on each bench board, which is a standing task rather than a one-off. ### desktop-macos: isolated scenarios (p50 of the sample window) @@ -138,36 +142,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 30,056 (+19) ⚠ | 6,425 | 23.7 % | -| light | 31,265 (−35) ✓ | 7,512 | 27.0 % (+0.1 %) ⚠ | -| platform | 21,118 (−357) ✓ | 4,133 | 21.9 % (+0.3 %) ⚠ | -| ui | 12,470 (−9) ✓ | 3,470 | 29.5 % | -| test | 67,258 (+11) ⚠ | 7,114 | 12.2 % | -| moondeck | 31,290 (+3) ⚠ | 4,933 | 18.0 % | +| core | 30,403 | 6,486 | 23.6 % | +| light | 31,876 (−39) ✓ | 7,597 | 26.8 % (+0.1 %) ⚠ | +| platform | 21,635 (+14) ⚠ | 4,172 | 21.6 % | +| ui | 12,473 | 3,471 | 29.5 % | +| test | 68,328 (+18) ⚠ | 7,193 | 12.2 % | +| moondeck | 31,610 (+15) ⚠ | 4,942 | 17.8 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,515 | -| scenarios | 30 | +| unit cases | 2,555 (+1) ✓ | +| scenarios | 32 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,989 (+4) ✓ | -| over threshold | 354 | +| functions | 5,087 (−3) ⚠ | +| over threshold | 353 | | worst CCN | 128 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 156 (−1) ✓ | -| markdown lines | 33,332 (−42) ✓ | -| plan files | 48 | -| backlog lines | 3,399 (+6) ⚠ | +| markdown files | 161 | +| markdown lines | 34,209 (+8) ⚠ | +| plan files | 50 | +| backlog lines | 3,393 | | lessons lines | 532 | -| CLAUDE.md lines | 285 | +| CLAUDE.md lines | 287 | diff --git a/docs/reference/testing.md b/docs/reference/testing.md index 21752995c..3707e6698 100644 --- a/docs/reference/testing.md +++ b/docs/reference/testing.md @@ -271,6 +271,16 @@ The runner puts the computer back on that network whatever happens. uv run moondeck/scenario/run_live_scenario.py --host MM-Bench.local --network Home --name scenario_AccessPoint_first_setup_from_its_own_network ``` +#### Several boards + +A scenario naming `"boards": ["sender", ...]` drives those boards too, each mapped to a device by `--board NAME=HOST`; it is skipped while one is missing. A step with `"device": ""` runs on that board, as an `apply_state`, a `set_control` or a `measure`. Every step's strings fill `{host}` and `{}` with the boards' addresses, so a sender's `hosts` points at its receivers. Each board is restored from its own snapshot, before the device under test. The observations are keyed by the whole combination, such as `host=esp32s3-n16r8 sender=esp32p4rev1-eth`, so a WiFi pair and an Ethernet pair keep apart. + +A `measure` step's `read` list names extra values to report: `"Module.control"` from the state, `"Module.tick"` for a module's tick time, or `"fps"`; `":"` in front reads another board, such as `"sender:fps"`. The run ends with one row per such step, which is what a sweep is read from. + +```bash +uv run moondeck/scenario/run_live_scenario.py --host 192.168.1.158 --board sender=192.168.1.156 --name scenario_NetworkReceive_two_boards +``` + ### Scenario modes (construct vs mutate) Every scenario carries a top-level `mode` field that says what shape the scenario expects the world to be in. Two values: diff --git a/docs/tutorials/how-moonlight-works.md b/docs/tutorials/how-moonlight-works.md index 6c99bd1ca..0231f3d6b 100644 --- a/docs/tutorials/how-moonlight-works.md +++ b/docs/tutorials/how-moonlight-works.md @@ -156,6 +156,8 @@ The rest the module declares about itself: | 🦅 | a named contributor, credited on the module | | 🎵 volume · 🎶 frequency | it listens: one note reacts to how LOUD the room is, two to WHICH notes are playing | | 📡 | it takes its picture from the network | +| 🖼️ | it samples the Picture service's picture | +| 🎞️ | it plays a recorded show from a file | | 🎯 | it aims moving heads | | 👾 | pixel art: the games and sprites | | 🧬 | a simulation: the picture emerges from cells evolving off their own last frame, rather than being drawn | diff --git a/docs/work/future/backlog-core.md b/docs/work/future/backlog-core.md index be92f428f..06bca1a73 100644 --- a/docs/work/future/backlog-core.md +++ b/docs/work/future/backlog-core.md @@ -492,18 +492,6 @@ Both surfaced while bringing up MoonBase Ethernet on the migrated Olimex; neithe ## ESP32 performance and memory -### Uploads to a Teensy run at about 20 KB/s (2026-10-13) - -An upload to the Teensy 4.1 moves about 20 KB/s whether it lands on internal flash or the SD card, against 300 KB/s to a classic ESP32 over Ethernet, and a 100 KB upload to internal flash once failed after 19 s. -Downloads run at 354 KB/s, so the receive side is the bottleneck: most likely lwIP's TCP receive window in QNEthernet's configuration, which caps the bytes in flight per round trip. -Measure the window first, then raise it within the Teensy's RAM, which the Layer buffers also need. - -### Host names on the Teensy (2026-10-13) - -HostResolver looks names up on a task of its own, and the Teensy has none: `spawnPinnedTask` refuses, so a host given by name in NetworkSend, OSC, Audio or MIDI waits forever, and its `resolveHost` blocks for its whole timeout. -On a board with one loop the textbook shape is a query that does not block: QNEthernet's `DNSClient::getHostByName` takes a callback, so a new platform seam starts a lookup and a tick collects the answer. -Until then an address works, and a name should say it cannot resolve rather than wait. - ### LEDs on the Teensy flash white, then the board resets (2026-10-13) Five times in one session, the 64-light strip on the Teensy 4.1 bench board went full white, and the board restarted with reset reason `UNKNOWN`, no fault mark and no watchdog: a supply dip, which a full-white strip on USB power explains. diff --git a/docs/work/future/backlog-light.md b/docs/work/future/backlog-light.md index f0099bbbe..92440bcb4 100644 --- a/docs/work/future/backlog-light.md +++ b/docs/work/future/backlog-light.md @@ -155,6 +155,12 @@ Record before and after in performance.md per target. ## Drivers +### An identity correction is one copy (2026-10-14) + +Every driver runs `Correction::apply` on every light, even when the correction changes nothing: brightness 255 (global and local), the linear curve, a fixture profile whose channels match the layer's, and no dimmer, white or motion channels. +The hottest-path rule says that case is a copy: `Correction` knows at rebuild whether it is the identity, and a driver that sends its buffer whole (Network Send, the panel cards) copies the layer's bytes instead of looking each one up. +The default CIE curve is not the identity at 255, so the skip applies only with the linear curve. It applies to every driver once the sender already sends the output's byte order; network and panel drivers then send one copy, while LED strips still encode bits but drop the per-byte lookup and reordering. + ### Drivers' palette and fixture-profile lists, sent once rather than in every view (2026-10-13) Drivers is the largest root in the state, 35 KB of the Teensy's view where Services is 10 KB, and two lists are 25 KB of it, mostly repetition. diff --git a/docs/work/present/Plan-20261010 - Teensy 4.1.md b/docs/work/present/Plan-20261010 - Teensy 4.1.md index d3b455369..8783a9be5 100644 --- a/docs/work/present/Plan-20261010 - Teensy 4.1.md +++ b/docs/work/present/Plan-20261010 - Teensy 4.1.md @@ -113,8 +113,8 @@ The three risks, all settled on the board: the fifth argument (`ctrls`) arrives 1. ✅ **A flat image beside the HEX.** The build also writes `MoonLight.bin` (`objcopy -O binary --gap-fill 0xff`, since erased flash reads 0xFF where objcopy fills 0x00), and the release ships `firmware--v.bin` beside the `.hex`. The HEX stays for `teensy_loader_cli` and PJRC's loader, which read only Intel HEX; both new writers take raw bytes at an offset. Verified on the desktop: every byte of the `.bin` matches the HEX and the 40 gap bytes read 0xFF; CI runs on the next push. 2. ✅ **One firmware list for every platform.** `firmwares.json` rows gain a `platform` field (`esp32`, `teensy`, `desktop`, and the [small boards](../../reference/hardware/small-boards.md)' families as their ports arrive), generated from one registry, `moondeck/build/firmwares.py`, that `build_esp32.py`, `build_teensy.py`, `repo_health.py` and the workflows read rather than restating it; that removes four of today's five copies of the Teensy list. The release matrix and the manifest test select their platform from it, and CMake takes the Ethernet driver as an option instead of restating the names. Verified: `check_firmwares` (16 variants), `test_host --python` and `--js`, and both Teensy images built with the right driver; CI runs on the next push. 3. ✅ **Updates by upload on the Teensy** (`platform_teensy_ota.cpp`). `otaWriteStream` stages the image in the free program flash above the running one, from the next 64 KB boundary up to the LittleFS area, so it fits while the two images together fit 6.75 MB. It refuses a HEX file, an image without the boot header and image vector table, and an image whose variant mark (`MoonLight image for ;`) names another firmware or is missing. `reboot()` then copies the staged image over the program from ITCM, every NVIC source and SysTick off and WDOG1 fed, in 64 KB blocks, skipping the blocks the two images share, and resets. The core's flash routines are the primitives; FlasherX was not needed, and its 16 KB reserve would have let the copy erase LittleFS. `http_fetch_to_ota` refuses with "no TLS on this board", since GitHub serves release assets over HTTPS only. Verified on the bench (W5500 build): a HEX, the other variant and a text file each refused with no reboot; four updates by `curl` with the settings intact, one changing 16 of 19 blocks including the boot header, the new build stamp showing after each; 1.2 MB staged in about 33 s, back up 8 to 12 s later; an upload from the Firmware card in the UI, verified by the PO, whose overlay says "Writing to flash…" with a pulsing bar through each erase pause, since the board reads nothing from the network while it erases; and by the PO, the cable pulled within seconds of "Rebooting…" on an image differing in 16 of 19 blocks: the board stayed dark, and the web installer's program-button install brought it back with its settings and effect intact. -4. ✅ **The installer and the device UI see the Teensy.** `generate_manifest.py` reads the firmware registry and writes a Teensy manifest as one part, the `.bin` at offset 0, under the family `Teensy 4.1`, which `build_teensy.py` adds to the registry's one family vocabulary; the release generates a manifest for every shipping firmware, and the installer preview stages the built Teensy images. `deviceModels.json` gains "Teensy 4.1" and "Teensy 4.1-NE + W5500", and `check_devices` checks every `chip` against that vocabulary and every listed firmware against the registry and its family. The installer reads the port's USB ids before any ESP32 handshake and reports a Teensy as found (`16C0:0483`). `/api/firmware/url` answers 501 on a device without TLS, and the Firmware card then downloads the release's `.bin` from its Pages copy, which allows a cross-origin read, and uploads it. Verified: the manifest test covers every platform, `check_devices` fires on a wrong family, the JS and Python suites pass, the bench board answers 501, and a headless browser drove the card's URL install on the W5500 board with the image served by the installer preview in place of Pages: downloaded, uploaded, rebooted, settings intact. Still to verify once a release carries the Teensy images: the same from a real release's Pages copy. -5. **The installer flashes a Teensy** (built, awaiting the bench). `teensy-writer.js` speaks HalfKay as `teensy_loader_cli` does: a running board enters the bootloader through a 134-baud Web Serial open, then 1088-byte reports, each a 64-byte header carrying the block's offset and 1024 bytes of image, block 0 first with a 45 s allowance since it erases the chip, blank blocks after it skipped, and a boot report to finish. A board held in its bootloader by the program button is offered after a canceled port pick ("Teensy not listed?"), the one moment it is needed, so the port row keeps one way in. Either way the browser's chooser opens straight from the Install click while that click still counts, and the page's own step, saying what to do, opens it when the click has expired or the user cancels; every install picks the bootloader once, since Chrome does not remember the grant (`chrome://settings/content/hidDevices` lists nothing after a pick), although the bootloader reports a USB serial number (`001E0D21`, the board's serial in hex; ioreg) and only lacks a product name, so why is open. The orchestrator picks the writer by the manifest's `chipFamily` before touching the device, so each next port adds a writer and nothing else: the RP2040, the RP2350 and the XIAO nRF52840 boot into a UF2 bootloader that shows a USB drive, so one UF2 writer serves all of them (PICOBOOT over WebUSB for the Picos as the direct route). After the boot the installer finds the re-enumerated serial port, reads `MM_IP` and `MM_DEVICE` for the success link, and with **Apply device defaults** sends the catalog state as APPLY_OP frames over it: the Teensy's USB serial listener (`platform_teensy_improv.cpp`, polled from the module's tick through `improvPoll`) takes that one command through the core helper `improvApplyOp`, which the ESP32's listener now shares. **Erase chip first** zeroes the settings' LittleFS superblock pair (offset 0x6C0000) after the image, so the firmware formats it at boot; the bootloader erases only the program area otherwise. Verified on the bench: the zeroed superblocks through `teensy_loader_cli` reset a marker setting to the defaults with the board booting normally, and an APPLY_OP document over USB serial got its acknowledgement and changed a setting; the unit tests pin the shared handler. Still to verify by the PO in the browser: an install with both boxes ticked. Verified so far: a node test pins the report layout, the skipped blocks, the boot report and the refused images, and that writing the reports onto erased flash reproduces the image; and on the bench by the PO, from the local installer preview, installs of the W5500 board's firmware from the program button, reached through the "Teensy not listed?" hint, and from a running board, with no button press, the browser's chooser opening straight from the Install click, the settings intact, each ending on the board's IP and `.local` links; the program-button path asks one click to grant the board's serial port, which the browser then remembers. Chrome lists the bootloader as "Unknown device (16C0:0478)". Still to verify: `teensy41` once a regular 4.1 arrives. +4. ✅ **The installer and the device UI see the Teensy.** `generate_manifest.py` reads the firmware registry and writes a Teensy manifest as one part, the `.bin` at offset 0, under the family `Teensy 4.1`, which `build_teensy.py` adds to the registry's one family vocabulary; the release generates a manifest for every shipping firmware, and the installer preview stages the built Teensy images. `deviceModels.json` gains "Teensy 4.1" and "Teensy 4.1-NE + W5500", and `check_devices` checks every `chip` against that vocabulary and every listed firmware against the registry and its family. The installer reads the port's USB ids before any ESP32 handshake and reports a Teensy as found (`16C0:0483`). `/api/firmware/url` answers 501 on a device without TLS, and the Firmware card then downloads the release's `.bin` from its Pages copy, which allows a cross-origin read, and uploads it. Verified: the manifest test covers every platform, `check_devices` fires on a wrong family, the JS and Python suites pass, the bench board answers 501, and a headless browser drove the card's URL install on the W5500 board with the image served by the installer preview in place of Pages: downloaded, uploaded, rebooted, settings intact. Verified by the PO on 2026-10-13 from the first release carrying the Teensy images, `6.1.0-dev.87`: a network update from the Firmware card, the image fetched from the release's Pages copy. +5. **The installer flashes a Teensy** (built, awaiting the bench). `teensy-writer.js` speaks HalfKay as `teensy_loader_cli` does: a running board enters the bootloader through a 134-baud Web Serial open, then 1088-byte reports, each a 64-byte header carrying the block's offset and 1024 bytes of image, block 0 first with a 45 s allowance since it erases the chip, blank blocks after it skipped, and a boot report to finish. A board held in its bootloader by the program button is offered after a canceled port pick ("Teensy not listed?"), the one moment it is needed, so the port row keeps one way in. Either way the browser's chooser opens straight from the Install click while that click still counts, and the page's own step, saying what to do, opens it when the click has expired or the user cancels; every install picks the bootloader once, since Chrome does not remember the grant (`chrome://settings/content/hidDevices` lists nothing after a pick), although the bootloader reports a USB serial number (`001E0D21`, the board's serial in hex; ioreg) and only lacks a product name, so why is open. The orchestrator picks the writer by the manifest's `chipFamily` before touching the device, so each next port adds a writer and nothing else: the RP2040, the RP2350 and the XIAO nRF52840 boot into a UF2 bootloader that shows a USB drive, so one UF2 writer serves all of them (PICOBOOT over WebUSB for the Picos as the direct route). After the boot the installer finds the re-enumerated serial port, reads `MM_IP` and `MM_DEVICE` for the success link, and with **Apply device defaults** sends the catalog state as APPLY_OP frames over it: the Teensy's USB serial listener (`platform_teensy_improv.cpp`, polled from the module's tick through `improvPoll`) takes that one command through the core helper `improvApplyOp`, which the ESP32's listener now shares. **Erase chip first** zeroes the settings' LittleFS superblock pair (offset 0x6C0000) after the image, so the firmware formats it at boot; the bootloader erases only the program area otherwise. Verified on the bench: the zeroed superblocks through `teensy_loader_cli` reset a marker setting to the defaults with the board booting normally, and an APPLY_OP document over USB serial got its acknowledgement and changed a setting; the unit tests pin the shared handler. Still to verify by the PO in the browser: an install with both boxes ticked. Verified so far: a node test pins the report layout, the skipped blocks, the boot report and the refused images, and that writing the reports onto erased flash reproduces the image; and on the bench by the PO, from the local installer preview, installs of the W5500 board's firmware from the program button, reached through the "Teensy not listed?" hint, and from a running board, with no button press, the browser's chooser opening straight from the Install click, the settings intact, each ending on the board's IP and `.local` links; the program-button path asks one click to grant the board's serial port, which the browser then remembers. Chrome lists the bootloader as "Unknown device (16C0:0478)". Verified by the PO on 2026-10-13: an install of `6.1.0-dev.87` from the published installer at moonmodules.org, the board coming up with its defaults. Still to verify: `teensy41` once a regular 4.1 arrives. 6. Optional, once: the desktop builds join the same list (`platform: desktop`), replacing the installer's hand-written asset pattern, which misses `linux-arm64` and `.deb`. **Once steps 3 to 5 ship in a release: tell PJRC.** Paul Stoffregen, PJRC's founder and the author of the Teensy core, HalfKay and `teensy_loader_cli`, reads forum.pjrc.com. A post there announces a Teensy firmware installed from the browser with no tools and updated over Ethernet from its own page, and asks two things: why Chrome does not keep the bootloader's grant although HalfKay reports a serial number, and whether HalfKay could report a product name, so browsers stop listing it as "Unknown device". @@ -140,8 +140,9 @@ The three risks, all settled on the board: the fifth argument (`ctrls`) arrives | APS6404L PSRAM | [PSRAM APS6404L 8 MB](https://www.tinytronics.nl/en/components/ics-and-microcontroller-chips/ics/aps6404l-3sqr-psram-8mb-sop8) (AP Memory) | Optional, once a layout outgrows the RAM | Tinytronics €3 | **After the phases: offer the patches upstream.** The files in `teensy/patches/` fix upstream code, so each goes back to its maintainer once the port is done, which lets the patch files go. -- `teensy_loader_cli-match-own-devices.patch`: a pull request to PaulStoffregen/teensy_loader_cli, fixing its open issue #60 ("Error opening HID Manager" on macOS) at the cause. The macOS HID manager matches every HID device, keyboards included, which needs the Input Monitoring permission; matching only the bootloader and the rebootor needs none. +- `teensy_loader_cli-match-own-devices.patch`: ✅ opened 2026-10-13 as [PaulStoffregen/teensy_loader_cli#96](https://github.com/PaulStoffregen/teensy_loader_cli/pull/96), from the fork `ewowi/teensy_loader_cli`, fixing its open issue #60 ("Error opening HID Manager" on macOS) at the cause. The macOS HID manager matches every HID device, keyboards included, which needs the Input Monitoring permission; matching only the bootloader and the rebootor needs none. Reproduced on macOS 27.0.1 before opening it: master waits forever with the board in its bootloader, the patched build flashes it. The patch file goes once Paul merges it. - `QNEthernet-w5500-bounded-waits.patch` and `QNEthernet-tcp-snd-buf-overridable.patch`: pull requests to ssilverman/QNEthernet. The first bounds the W5500 driver's waits on the chip at 20 ms, which ended the device's freezes; the second lets a build set `TCP_SND_BUF`, as it already can `MEM_SIZE` and `TCP_WND`. +- `QNEthernet-recv-window-on-read.patch`: a pull request to ssilverman/QNEthernet, after a reproduction with a plain QNEthernet sketch. QNEthernet reopens the receive window when it copies data into its buffer rather than when the sketch reads it, so a fast sender overruns the buffer and lwIP drops what follows: a 1 MB upload took 30 s with 35% retransmitted, and 4.8 s with none once the window reopens on read. - `QNEthernet-mdns-nsec.patch`: an issue with the patch to ssilverman/QNEthernet, asking whether it belongs in QNEthernet's lwIP copy or in lwIP's patch tracker first. lwIP's mDNS responder sends no NSEC answer (still a TODO on lwIP master), so a build without IPv6 leaves a client's AAAA question unanswered and a `.local` lookup waits 5 s; the patch answers it per RFC 6762 §6.1. ## What the core already handles diff --git a/docs/work/present/Plan-20261013 - Audio reactive live performance.md b/docs/work/present/Plan-20261013 - Audio reactive live performance.md new file mode 100644 index 000000000..4480076a3 --- /dev/null +++ b/docs/work/present/Plan-20261013 - Audio reactive live performance.md @@ -0,0 +1,126 @@ +# Plan: audio-reactive live performance, each band member drives the lights + +Draft for the product owner's approval. A band plays, and the lights answer each musician separately: a drum hit splashes, a key pressed on a MIDI keyboard lights its own spot and fades when the key is released, a bass line or a voice moves its own part of the rig, and moving heads follow each musician across the stage. Today the lights hear the whole mix through one microphone and react to it as one signal. + +## The design basis + +The core backlog already analyzed this, and the plan follows its conclusions rather than reopening them ([backlog-core: notes as an effect input, the rest of the band, everything as MIDI](../future/backlog-core.md)): + +- **Notes are performance data, not control-surface input.** A desk sets controls; a keyboard's notes are read by an effect, which decides what a note means, the way it reads audio. +- **Two frames, kept separate.** A `MidiFrame` carries what arrived as notes; an `AudioFrame` per analyzed channel carries what arrived as sound. A per-band onset never pretends to be a note-on with a fake pitch and velocity. +- **MIDI first.** Electronic drums, MIDI keyboards, hex-pickup guitars and pitch-to-MIDI voice boxes all speak MIDI, so the transport alone reaches most of a band with no analysis. +- **The frame budget bounds on-device analysis:** one analyzed mono input costs about 8 ms per tick on a P4, so a board analyzes one or two channels, not a band. + +## The bench instrument + +An **Akai MPK261**: 61 semi-weighted keys, 16 RGB-backlit pads, 8 knobs, 8 faders and 8 buttons, sustain and expression pedal inputs, and USB MIDI that needs no driver, with a 5-pin MIDI out besides. One instrument covers both first effects: the keys for step 2, the pads as a drum kit for step 3, and its knobs and faders reach the control surface through MIDI learn. + +## What exists + +- **`MidiService`** with three transports: the browser (Web MIDI), the board's own USB port (USB MIDI host, on the ESP32-S3 and P4 images), and the network (RTP-MIDI), plus `share` to pass a USB device on to the network. It decodes two desk profiles, Mackie Control and the Akai APC40 mkII, onto the control surface. +- **`AudioService`** publishing an `AudioFrame` per block: level, sixteen bands, the dominant frequency, spectral flux and `onset`, the block a hit was detected, which effects read through `latestFrame()` and MoonLive scripts through builtins. `BeatRipplesEffect` already splashes on `onset`. +- **Layers, layouts and modifiers,** which already give each musician a place: one layer per band member, placed on its own part of the rig. +- **The MovingHead effect and fixture profiles,** which already aim heads: pan and tilt per fixture, in formations, from the profiles' Pan and Tilt roles. +- **The input-mapping plan's sensors** ([input mapping and scripted sensors](Plan-20260901%20-%20Input%20mapping%20and%20scripted%20sensors%20(partial).md)): a PIR, a VL53L0X distance sensor, a VL53L8CX zone grid and an MPU6050, on the I2C seam that plan builds. + +## The shape + +**One band member is one layer.** An effect in that layer listens to one source, a MIDI channel or an audio channel, and draws only that musician; the layer's opacity, blend mode and modifiers place and mix it. A band is a stack of layers, so no new composition mechanism exists. + +**MIDI by the book.** A stage gives each instrument its own MIDI channel, so a channel select on the effect is the routing. Drums follow the General MIDI percussion map, so a kit means something with no setup: note 36 a kick, 38 a snare, 42 a closed hi-hat, 49 a crash, the same numbers every electronic kit sends on channel 10. + +**The `MidiFrame`,** published by `MidiService` from any transport, in the `AudioFrame`'s publish-and-read shape: + +- per channel, which of the 128 notes are held and how hard (velocity); +- the note starts and ends since the last frame as ordered events, each with its channel, pitch and velocity, so two hits of the same pitch between frames stay two and a note shorter than a frame still fires; +- pitch bend, modulation and sustain per channel; +- the MIDI clock's beat phase when a clock runs. + +An effect opts in, as audio effects do, and on a board with no MIDI it sees an empty frame and draws its idle state. + +**Latency is the requirement that decides the design.** A light that trails the hit by more than a few tens of milliseconds looks late on stage. A note-on reaches the next frame, so at 100 frames a second the render adds at most 10 ms. USB MIDI on the board is the shortest path; the browser and WiFi RTP-MIDI add jitter, and the plan measures each rather than guessing. + +## Steps + +Each step ends in something verified on the bench. + +### 1. Notes reach effects + +- `MidiService` publishes the `MidiFrame` from every transport, alongside its desk profiles, which stay as they are. +- MoonLive gains builtins for it (a note's velocity, whether it is held, whether it started this frame), so a script reacts to notes the way it reacts to `level` and `bands`. +- Unit tests pin the frame: notes started and ended between two frames, two note-on and note-off cycles of one pitch between frames read as four events in order, a note shorter than a frame, running status, a note-on with velocity 0 read as a note-off. +- Verify with the PO's Akai MPK261 over the board's USB port and over the browser. + +### 2. A keyboard lights per key + +- **`KeysEffect`:** each held note lights its own position along the layer, velocity sets the brightness, and a released note decays over a `release` time; sustain holds it. The note range follows a `low` and `high` setting, so a 25-key controller and an 88-key piano both span the layer. +- Verify on the MPK261's 61 keys: chords, fast runs, the sustain pedal, and the latency from key to light, measured with slow-motion video. + +### 3. Drums splash + +- **`DrumsEffect`:** each General MIDI drum fires its own shape: the kick a pulse from the floor, the snare a burst, the hi-hat a sparkle, a crash a wash over the layer. Velocity sets the size and brightness; a hit's shape is a splash with a decay, not a held light. +- Verify on the MPK261's 16 pads, which send notes like a drum kit, then on an electronic kit. The pads are RGB-lit, so the way back can light each pad with its splash's color, as the APC40 profile already lights its pads. + +### 4. A band on the rig + +- A demo preset: a keys layer and a drums layer side by side on one installation, each listening to its own channel. +- Several boards on one stage take the same notes through `share` and RTP-MIDI, so every board sees every musician. +- Verify with two instruments playing at once. + +### 5. Acoustic drums + +Two routes, the first needing no code: + +- **Drum triggers:** clip-on triggers into a trigger module send MIDI, so steps 1 to 3 already cover an acoustic kit. +- **Per-band onsets** in `AudioService`: the single `onset` fires on any hit, so a kick and a snare look alike. An onset per band group (low for the kick, mid and high for the snare and cymbals) separates them from the bands already computed. Best with a direct out or an aux send from the mixer giving the kit alone. +- Verify with a kit, or a recorded kit track, through one microphone or line input. + +### 6. Analog instruments, an investigation + +Decided per instrument, cheapest first: + +| Instrument | Route by the book | On the device | +|---|---|---| +| Guitar | a hex pickup or a guitar-to-MIDI converter: MIDI, so steps 1 and 2 cover it | strummed chords are polyphonic transcription, out of scope | +| Bass, lead lines | a pitch-to-MIDI pedal | monophonic pitch tracking (YIN) on one clean channel, as a `pitch` in that channel's `AudioFrame` | +| Voice | a pitch-to-MIDI box | pitch plus loudness envelope on one clean channel | +| Any instrument, loudness only | a direct out or aux send | the existing level and bands on its own channel | + +The on-device column needs a second audio channel: the multi-channel front-ends the [audio roadmap](../future/audio-dsp-roadmap.md) anticipates, such as a 4-channel TDM codec (the ES7210 on the Waveshare RGB-Matrix board) or line inputs from the mixer. The budget allows one or two analyzed channels per board. + +### 7. Follow the musicians + +Automated follow spots split the job in two, by the book: **tracking** delivers each performer's position in stage coordinates, and **aiming** turns a position into pan and tilt per fixture, from the fixture's position and its mounting, which is the inverse kinematics behind every console's XYZ focus. Between them sits an open standard for live position data, **PosiStageNet (PSN)**, a UDP multicast protocol that professional trackers and media servers speak. + +- **A position frame,** in the shape of the audio and MIDI frames: tracked targets, each with an ID and stage coordinates, published by a tracking service from whatever sensor feeds it. +- **The MovingHead effect gains a `follow` formation:** each head aims at a target instead of sweeping, from its position in the layout plus two mounting settings (floor or truss, and which way is forward). +- **A pixel effect that follows a target:** a glow under the singer on an LED floor or wall, from the same frame. +- **One musician per layer** carries over: the singer's layer follows target 1, the guitarist's target 2. + +The sensors, from what is listed to what a stage needs: + +| Picture | Sensor | Gives | Fit for a stage | Price | +|---|---|---|---|---| +| HC-SR501 PIR | [PIR, HC-SR501](https://www.tinytronics.nl/en/sensors/motion/ir-pyroelectric-infrared-pir-motion-sensing-detector-module) | presence, about 3 to 7 m | wakes a show, follows no one | Tinytronics €3.50 | +| VL53L0X | [VL53L0X distance](https://www.tinytronics.nl/en/sensors/distance/vl53l0x-time-of-flight-tof-distance-sensor) | one distance, up to about 1.2 m | one person on one axis: a walkway, a lectern | Tinytronics €5.75 | +| VL53L8CX | [VL53L8CX zone grid](https://eckstein-shop.de/Pololu-VL53L8CX-Time-of-Flight-8x8-Zone-Distance-Sensor-Carrier-with-Voltage-Regulators-400cm-Max-EN) (Pololu) | 8 × 8 depth zones, about 4 m | coarse position of one or two people on a small stage | Eckstein €23.95 | +| MPU6050 | [MPU6050, worn](https://www.tinytronics.nl/en/sensors/acceleration-rotation/mpu-6050-accelerometer-and-gyroscope-3-axis-module-3.3v-5v) | orientation | a gesture, not a position | Tinytronics €8.75 | +| HLK-LD2450 | [mmWave radar, HLK-LD2450](https://www.tinytronics.nl/en/sensors/motion/hi-link-hlk-ld2450-24ghz-radar-sensor-module-with-bluetooth) | up to 3 targets with x and y, about 6 m, UART | the cheapest real tracker, working in darkness and haze | Tinytronics €7.00 | +| DWM3000EVB | [UWB, Qorvo DWM3000EVB](https://www.digikey.nl/nl/products/detail/qorvo/DWM3000EVB/24367408) | about 10 cm, with anchors and a tag per performer | how professional radio trackers work | DigiKey €31.34 per node | +| | [PosiStageNet in](https://posistage.net/) | positions from a professional system | MoonLight joining a rig that already tracks | free, open protocol, MIT-licensed SDK | +| OV5647 camera | [A camera on the P4](https://www.tinytronics.nl/en/raspberry-pi/raspberry-pi-compatible-camera-5mp-v1.3) (OV5647, MIPI-CSI) | person detection | later, with the [streams](Plan-20261013%20-%20Streams%2C%20every%20frame%20from%20outside.md) work | Tinytronics €8.00 | + +**On order (2026-10-13): two HLK-LD2450 kit B** from Hi-Link's own store, each with a USB-to-serial adapter, a Bluetooth antenna and a cable. The adapter lets the radar be tried on a computer with Hi-Link's tool before the UART seam exists: targets, range and zone exclusion on day one. Two units answer the stage questions: whether two 24 GHz radars side by side interfere, and how their targets merge where their views overlap. + +The order: the radar first, which needs a UART seam the platform does not have yet; PosiStageNet in second, which is UDP and needs no new seam; the ToF zone grid as the indoor alternative once the input-mapping plan's I2C step lands and a VL53L8CX read is measured that does not stall the render task: an 8 × 8 frame over I2C is large, so the candidates are SPI, a lower ranging rate, or reading on a task off the render core. Verify with a person walking the bench stage and the heads following. + +## Open questions + +- **Does a note decay in the frame or in the effect?** The proposal: the frame carries held notes and edges only, and each effect owns its own decay, since a splash and a held key want different curves. +- **One board for the band, or a board per musician?** RTP-MIDI makes both possible; the first gig decides. +- **How far on-device analysis goes** before the musicians' own converters are the better answer, measured per instrument in step 6. +- **Which tracker for the first stage:** the mmWave radar covers a small stage for the price of a sensor; UWB is the accurate route and the larger build. + +## What it removes + +Nothing in code: it adds a MIDI frame, a position frame, two effects and a follow formation on the existing transports and fixtures, plus the UART seam the radar needs. In the docs, the three backlog sections it is built from (notes as an effect input, the rest of the band, everything as MIDI) leave the backlog once this plan is approved, since a plan is where an item goes when it is picked. diff --git a/docs/work/present/Plan-20261013 - Streams, every frame from outside.md b/docs/work/present/Plan-20261013 - Streams, every frame from outside.md new file mode 100644 index 000000000..ffb637fc5 --- /dev/null +++ b/docs/work/present/Plan-20261013 - Streams, every frame from outside.md @@ -0,0 +1,438 @@ +# Plan: streams, every frame from outside, one model + +Plan, for the product owner's approval; started as an analysis on 2026-10-13, when three pull requests by Gohnnyman (#138 a backlight effect and VideoService, #139 USB video capture, #140 backlight wiring) arrived the same week TouchMyLight announced an FSEQ player for a 20 × 20 × 20 cube (on #134). Read one by one they look like unrelated features: a TV backlight, a capture card, a show player. Read from MoonLight's architecture they are the same thing: a stream of frames arriving from outside the device. This plan models that once, by the textbook, so cameras, video, files, the network and the browser become combinations of a few parts rather than a feature each. + +## The textbook model: three independent axes + +Every media framework separates the same three questions: GStreamer, FFmpeg, Media Foundation and Video4Linux all do. Where do the bytes come from, how are they encoded, and what shape is the frame? Each axis varies on its own. + +| Axis | Values in MoonLight's world | +|---|---| +| **Transport** | a file on flash or the SD card, the network (UDP, TCP, the browser's WebSocket), USB, a camera port (MIPI-CSI) | +| **Format** | raw RGB, PPM, GIF, MJPEG, FSEQ, the DDP, Art-Net and E1.31 framings; H.264 on the way out | +| **Geometry** | a picture (width × height), a light list (N lights, mapped by the sender), a volume (width × height × depth) | + +A source is then a transport plus a decoder, delivering frames with a small descriptor: the geometry, the pixel format and the frame's time. An output is an encoder plus a transport. Everything users ask for is a combination: + +| What users ask for | Transport | Format | Geometry | +|---|---|---|---| +| An xLights show (FSEQ) | SD file | FSEQ | light list | +| Network Receive today | UDP | DDP, Art-Net, E1.31 | light list | +| A GIF animation | flash or SD file | GIF | picture | +| An HDMI grabber (#139) | USB | MJPEG | picture | +| A camera on the P4 | MIPI-CSI | raw, through the image processor | picture | +| A screen shared from a browser tab | WebSocket | raw or DDP | picture or light list | +| A volumetric show for a cube | SD file | FSEQ | volume, or a light list placed in 3D | +| HLS and RTSP (shipped) | HTTP, RTSP | H.264 | picture | +| Recording | SD file | FSEQ | light list | + +## How it lands on what MoonLight has: two families + +The three axes describe any stream, and they do not make every stream one module. The standard is **one module model with typed data:** GStreamer gives every source the same element model and types each output (`video/x-raw` and `audio/x-raw` connect only to their own kind), and TouchDesigner, the most general tool here, separates its operators by data type, CHOPs for channels (DMX In, Art-Net, MIDI In, audio) and TOPs for textures (video, files, capture). MoonLight's module model is that element model: every source is a MoonModule with one lifecycle. On top of it, two families exist in the industry and in MoonLight, each with its own semantics, and they meet in the layer. + +| | Channel data, the pixel protocols | Pictures | +|---|---|---| +| Live in | Network Receive: Art-Net, sACN (E1.31), DDP | capture: USB, MIPI-CSI, a browser tab | +| Recorded in | FSEQ playback | GIF, image sequences | +| Out | Network Send, FSEQ recording | HLS and RTSP, HDMI | +| Data | values mapped by the sender | width × height pixels | +| Into a layer by | placement through the layout | sampling per light | + +**Channel data is a protocol family, not a decoder.** Art-Net, sACN and DDP carry universes and channels, discovery (ArtPoll), sync packets, priorities and merging between senders, and Network Receive and Network Send are the two halves of the family. An FSEQ file is a recording of exactly that data, which is why Falcon Player plays FSEQ and outputs sACN. So FSEQ joins this family as a sibling of Network Receive, not as a decoder in a generic stream source. + +**Transports and files carry the same channel data.** Art-Net, E1.31 and DDP are transports, the data cut into packets to send live; FSEQ is a file format, the same data stored frame by frame with a step time to play later. So the family is one channel-data sink (staging, hold-last-frame, `mapped_by`, the placement, the counters) fed by sources: the network transports, and files from the SD card, a USB stick or flash, where the storage is a detail of the file source. Art-Net "from the SD card" is a recorded stream stored as FSEQ, as Falcon Player records it; FSEQ does not travel as a stream, Falcon Player copies the file to its remotes. Recording is the bridge: a live stream in, an FSEQ file out, written from the drivers' output. + +**Pictures are their own family:** a picture source publishing frames, and an effect that samples them per light. `VideoService` from #138 is that source, renamed for what it is. + +**What the families share, and only that:** + +1. **Light-list placement:** writing values mapped by the sender into the layer through the layout. FSEQ needs it, and so does Network Receive for frames mapped by the sender (below). One helper, two users. +2. **The timing rule:** live (the newest frame wins) or recorded (the clock picks the frame), below. +3. **The layer:** both families end as an effect in a layer, so they compose with effects, blend modes and modifiers. + +Two rules keep the picture side small enough for a microcontroller, where GStreamer's generality would not fit: + +- **Decoders always deliver RGB888,** so nothing negotiates formats. +- **The picture descriptor stays a handful of fields:** size, frame time, live or recorded, and the tone curve a picture carries so the sampler averages light rather than bytes. + +## Mapped by the receiver or by the sender + +Network Receive copies channel k into logical cell k of its layer, and the layer's mapping then carries each logical cell to its physical light, so it follows the pipeline like any effect. Whether that is right depends on who mapped the frame, and the industry uses two conventions: + +| Sender sends | Examples | Copied into the layer by index | +|---|---|---| +| **Mapped by the receiver:** row by row like a picture (raster order), expecting the receiver to apply its own wiring | image-based tools that leave the wiring to the controller | correct: the layer's mapping applies this board's serpentine or 3D wiring once | +| **Mapped by the sender:** arranged in the order the lights are wired (wiring order), because the sender did the mapping itself | xLights, FSEQ files, Falcon Player, pixel-mapping software with a fixture patch, MoonLight's own Network Send (it sends driver output) | mapped twice: the sender already applied the wiring and the layer applies it again, so a serpentine panel gets every other row reversed | + +On a plain grid without modifiers the two conventions coincide, which is why the difference stays invisible on simple setups. + +**So Network Receive gains a `mapped_by` setting,** `receiver` or `sender`, naming who maps the frame onto the wiring, `receiver` being the default: + +- **Mapped by the receiver** is today's copy, unchanged, so nothing changes for anyone using it now. +- **Mapped by the sender** places channel k through the light-list placement, which is the inverse of the layout's mapping, before the layer's modifiers: composed with the layout, the two cancel, so channel k reaches physical light k, while a mirror or a rotation still transforms the frame and it still blends. +- **FSEQ is always mapped by the sender,** since xLights writes it that way, so the FSEQ effect uses the placement without a setting, through the channel-data sink it shares with Network Receive. + +**The hot path stays as fast as today.** Network Receive is on it: at 16,000 lights and 40 frames a second every per-light step counts. The placement follows the rule the layer's mapping already follows, which keeps no table at all when logical order equals physical order (`hasLUT()` false): + +- **Orders coincide** (a plain grid, no modifiers): the placement is a `memcpy`, zero cost over today. +- **Orders differ** (serpentine, 3D, a mapped layout): a per-light gather through the table, where the copy by index would have mapped twice. +- **Only on new data,** as today: a frame without new packets costs nothing. + +A measurement gates it: `run_live_scenario` on the S3 with Network Receive on a large grid, before and after, in both the identity and the serpentine case; an identity path that moves at all does not merge. + +One more option exists for a single feed that must be the absolute fastest: writing straight into the drivers' output, bypassing the layer, which is what WLED's realtime override does. It gives up blending, modifiers and composition, so it is not the default; it is a setting to add only when a user needs it. + +## Real time and not real time + +Streams split a second way, by who sets the pace, and the split is the textbook one between live and on-demand media. + +| | Live (real time) | Recorded (not real time) | +|---|---|---| +| Examples | Network Receive, USB and CSI capture, a browser tab, MIDI-driven content | FSEQ, GIF and image sequences from flash or SD, a baked show | +| Who sets the pace | the sender: a frame arrives when it arrives | the device's clock: it pulls the frame the timeline asks for | +| Late or missing frames | the newest frame wins and an old one is dropped, never queued, since a queued frame is a late frame | none: the next frame is read ahead, one frame of buffer at most | +| Seek, loop, pause | not possible | the frame index comes from the clock, so they are arithmetic | +| Sync across boards | each board shows what it receives | clock sync: every board computes the same frame index from the shared clock | +| What it costs | latency, which the design keeps to one frame | storage bandwidth, which the design keeps to one frame per tick | + +Each source states which kind it is, so its effect knows whether to show the newest frame or the frame the clock asks for. The transport decides it: a socket, a USB grabber and a camera are live; a file is recorded. A file can also be played live, at the pace of a sender elsewhere, which is how a recording replays exactly what arrived. + +Not real time also names a third case that never reaches the device as a stream: **authoring**. Baking a picture or a video into FSEQ for a layout runs in the browser at whatever speed it takes, and only its result plays in real time. + +## Modifiers and the single pass + +A small device cannot run a PC pipeline, where each stage hands a full frame to the next. MoonLight never does: geometry is composed into coordinates at prepare time, and each frame does one pass. + +- **Modifiers are coordinate folds,** composed into the layer's mapping table at prepare (`Layer::rebuildLUT`). Mirror, rotate, tile and multiply change where a logical cell lands, never the pixels; an animated modifier remaps coordinates per frame (`modifyLive`), still without an intermediate frame. +- **Effects write colors for their own logical cells** into one buffer per layer, and the mapping table carries them to the physical lights. + +The textbook name is inverse mapping: compose every transform into one coordinate function, then sample the source once per output pixel. A GPU fragment shader works this way, and so does OpenCV's `remap` with a precomputed table. + +**Both families follow that rule, so their consumers are effects, not modifiers.** Sampling is what an effect does: a color for its own coordinates, read from a decoded frame instead of computed from a formula. + +- **At prepare** each builds a small table, as the layer builds its mapping: the picture effect maps each logical cell to a source pixel or, for area sampling, a source rectangle; the light-list placement maps light k to the logical cell it lands in, which is the physical-to-logical fold the layer's mapping build already computes. +- **Per frame** each does one gather into the layer buffer, and the existing mapping does the rest. + +**Modifiers stay the one home for geometry transforms** and apply to a stream like any effect, as the architecture already says: the same RotateModifier rotates Fire, Noise or a network-received frame. So the picture effect has no rotate, mirror, scroll or offset of its own. It keeps only what belongs to sampling: fitting or stretching the picture onto the grid, point or area averaging, and the edge depth a backlight samples at. + +**The picture side keeps the same budget:** decoding runs off the render tick, the effect does one gather per frame, and area sampling is capped per zone so a large source never costs a read per source pixel. + +**The one full frame is the decoded picture,** the texture the shader samples, and it is an input rather than a stage: the P4's JPEG hardware decodes whole frames and a GIF composes onto a canvas, so a decoder produces it anyway. Channel data needs none: an FSEQ or DDP frame is written into the layer buffer through the placement table as it is read. + +**What it removes.** The backlight stops being an effect of its own and becomes the picture effect's area sampler. Network Receive keeps its protocol family and gains sender mapping as a second convention, at no cost in the identity case. FSEQ reuses Network Receive's placement instead of growing its own. No stage holds a frame of its own, and no geometry option is duplicated inside an effect. + +```mermaid +flowchart TB + subgraph CH["Channel data: mapped by the sender"] + direction LR + nr["Network Receive
Art-Net, sACN, DDP"] + fseq["FSEQ playback
from /sd
TouchMyLight"] + end + subgraph PIC["Pictures: width × height"] + direction LR + cap["capture
USB #139, CSI, browser tab"] + gif["GIF, sequences
flash, /sd"] + ps["picture source
test pattern
#138"] + end + place["light-list placement
through the layout"] + samp["picture effect
sampled per light
backlight #138"] + mix["Layers: effects, opacity, blend modes, modifiers"] + map["Layouts: 2D, 3D, backlight.mll #140"] + subgraph OUT["Out"] + direction LR + drv["LED drivers
color trim #139"] + ns["Network Send"] + rec["FSEQ recording"] + hls["HLS, RTSP"] + hdmi["HDMI
ESP32-P4-PC"] + end + browser["Browser: converts and bakes
at authoring time"] + + nr --> place + fseq --> place + cap --> ps + gif --> ps + ps --> samp + place --> mix + samp --> mix + mix --> map + map --> drv + map --> ns + map --> rec + map --> hls + map --> hdmi + browser -.-> gif + browser -.-> fseq + + classDef shipped fill:#1f4d3d,stroke:#5fb89a,color:#fff + classDef pr fill:#2d3561,stroke:#7b88c9,color:#fff + classDef planned fill:#4d3d1f,stroke:#c9a95f,color:#fff + classDef proposal fill:#3d2d61,stroke:#a07bc9,color:#fff + class nr,mix,ns,hls shipped + class ps,samp,cap,map,drv pr + class fseq planned + class place,gif,rec,hdmi,browser proposal + style CH fill:none,stroke:#888 + style PIC fill:none,stroke:#888 + style OUT fill:none,stroke:#888 +``` + +Green ships today, blue is an open pull request, gold is planned by a contributor, purple is this plan's proposal. The diagram is configuration, not runtime stages: each family does a single gather into its layer, then the existing mapping. Everything from the layers down already exists. + +## Where MoonLight stands among the tools + +The tools look different, but they share this pipeline and put their weight in different places. + +| Tool | Weight on | Runs on | +|---|---|---| +| Resolume | video clips in layers, pixel mapping to fixtures | PC | +| TouchDesigner | a general dataflow graph, anything to anything | PC | +| MADRIX | LED pixels: generative effects, layers, a 2D and 3D patch, media and capture, many outputs, record and replay on a standalone player | PC, standalone nodes | +| xLights with Falcon Player | sequencing offline, playback standalone | PC, Raspberry Pi | +| Hyperion, HyperHDR | capture to LED zones | Raspberry Pi, PC | +| WLED | effects on segments, a realtime override | ESP32 | + +MoonLight is closest to MADRIX: generative effects, layers, 2D and 3D layouts and many outputs. The difference is that it runs on the controller itself. MADRIX's media, capture, and record-for-standalone-playback are what this stream model adds. The position is MADRIX-class pixel control, embedded, which no tool in the list occupies. + +## What WLED did, and where it stopped + +Checked 2026-10-13 against WLED main and WLED-MM. + +- **One picture format, decoded on the device.** The Image effect (Aircoookie, PR #3835, finished by netmindz, shipped in WLED 16.0, May 2026) plays a GIF from the internal file system, up to 320 × 320, with about 24 KB of decoder RAM (bitbank2's AnimatedGIF). It scales by nearest neighbor, decodes one image at a time across all segments, and redraws only changed pixels, which upsets brightness limiting. WLED-MM backported it. +- **Every conversion in the browser.** DedeHai's PixelForge (PR #4982, WLED 16.0) crops, scales and encodes a GIF in the page and uploads it; Video Lab turns video into GIFs, Pixel Paint draws on the matrix, Font Factory makes scrolling-text fonts. +- **No capture on the device.** The only video path is a browser page streaming DDP over the WebSocket (DedeHai, PR #4997). Hyperion does all backlight processing on a computer and drives WLED over DDP. +- **FSEQ: five attempts, none merged** (#2413, #3551, #4570, #5641 open since May 2026). They bolted playback onto the realtime override, which replaces effects rather than composing with them, and each bundled an SD driver, a player, a UI and Falcon Player sync in one pull request. Recording was planned and never built. +- **SD** is a usermod nothing in core reads. + +ESPixelStick, the reference small-device FSEQ player, plays uncompressed FSEQ v2 with sparse ranges from SD, floors the step at 25 ms, takes the frame index from a monotonic clock plus a sync offset, and seeks per frame, which keeps looping and sync trivial. + +What it teaches: GIF first among picture formats, decoded per source rather than in one global decoder; conversion as browser pages; the layer as the home of every stream, which is exactly where WLED's FSEQ attempts went wrong; FSEQ starting where ESPixelStick stops. New beyond WLED: a stream composing in layers on any layout, 3D included; area sampling in linear light; capture decoded on the device; recording; synchronized playback across boards; the SD card as a first-class file store. + +## Browser and device + +One rule decides the split, and MoonLight already follows it where the device cannot do a job (the Firmware card's download for a board without TLS, MoonCloud's outbox, scripts fetched from GitHub by the page): **what must run with no browser open belongs on the device; what happens once, with a person at the screen, belongs in the browser.** In the stream model the browser is one more transport, and an authoring tool. + +| Tier | Where | Examples | +|---|---|---| +| Authoring: once, a person present | browser | crop, scale and convert images; video to GIF or to FSEQ; fonts; an xLights model imported as a layout | +| Live, a browser as the source | the browser samples, the device displays | a screen (`getDisplayMedia`), a webcam or a video in a tab, streamed over the existing WebSocket | +| Run time: autonomous | device | effects, playback from flash or SD, USB and CSI capture, sync, recording, a show running for days | + +**The browser can bake for this layout.** It already knows every light's position, since it draws them in the preview. So it can do the expensive part once, sampling a picture or a video per light with the area filter in linear light, and write the result as an FSEQ file. The device then plays it with the same cheap light-list path as an xLights show, on any layout, with no decoder. + +| | Baked in the browser (FSEQ) | Decoded on the device (GIF) | +|---|---|---| +| Device cost | lowest: read and copy | decode and sample every frame | +| Follows a layout change | no: bake again | yes | +| Storage | large: 8000 lights at 40 fps is about 1 MB/s, so SD only | small: fits in flash | + +A live browser source costs bandwidth, lights × 3 bytes × fps: about 60 KB/s for a 600-light TV frame at 30 fps, too much for 8000 lights over WiFi. USB and CSI capture run with no browser, so they belong on the device. A TV's picture from a set-top box or console never passes through a computer, so a backlight following it needs the grabber on the board (#139). + +## 3D layouts + +TouchMyLight's cube is the first user, so 3D works from the start. + +- **A light list is mapped by the sender, and carries no positions.** An FSEQ frame holds values in the order xLights numbers the model's lights, the order the outputs drive them. Copied into the layer by index, it is mapped twice wherever the layer's order differs from the wiring, as on a cube (see mapped by the receiver or by the sender, above). The light-list placement puts light k at the coordinate the layout gives light k, and the layer's mapping carries it back to that physical light: correct on 1D, 2D and 3D layouts, and still composing with layers and modifiers. The user-side requirement is the one every FSEQ player has: the MoonLight layout and the xLights model number their lights the same way. +- **A picture reaches a 3D layout by projection:** each light samples the picture at its position seen along one axis, the planar projection media servers use. +- **A volume is the native 3D stream:** a frame per depth slice, copied straight into a 3D layer. + +## The P4 and its codecs + +The P4's hardware video codec encodes H.264 but does not decode it; its JPEG codec works both ways. So video arrives as MJPEG from a grabber, raw from a camera, or baked in the browser, while video leaves through the H.264 encoder HLS already drives. + +**One board carries every piece.** The Olimex ESP32-P4-PC (February 2026, on the PO's bench) has HDMI and MIPI-DSI out, a MIPI-CSI camera connector, four USB 2.0 host ports, a microSD slot, Ethernet with PoE and 32 MB of PSRAM: capture in, HDMI out and FSEQ from the card on one P4. HDMI out is also the textbook way to feed commercial LED walls: Novastar and Colorlight sending cards take HDMI or DVI as their input. + +## What each contribution becomes + +| Contribution | In the plan | +|---|---| +| `VideoService` (#138) | the picture source, renamed for what it is | +| #138's backlight effect | the picture effect's area sampler, edge depth among its settings | +| USB capture (#139, first commit) | a picture source's USB transport and MJPEG decoder, behind the platform seam, in the P4 images with room | +| Per-die trim (#139, second commit) | a separate idea: output calibration in fixture profiles and correction, judged against a 3×3 correction matrix | +| Backlight wiring (#140) | a layout: Hyperion's classic layout, xLights' frame models | +| FSEQ player (TouchMyLight) | its own thin effect beside Network Receive, both on one channel-data sink (staging, hold-last-frame, `mapped_by`, the placement, the counters); a third user later records a live stream to FSEQ | +| Network Receive (shipped) | kept as the pixel-protocol family, gaining a `mapped_by` setting: `receiver` as today, `sender` through the light-list placement | + +## What we do not do, and what we do differently + +The work follows the idea, not the pull requests: a contribution lands where it fits the plan, in the shape the plan gives it. + +**Not part of the idea:** + +| What | From | Why not | +|---|---|---| +| The backlight as an effect of its own | #138 | it is one sampler setting of the picture effect; a card of its own duplicates the picture path | +| PPM as a user format | #138 | chosen only for want of a software JPEG decoder; GIF covers that need with the tools users already own, and PPM stays a test fixture | +| HDR tone curves (PQ, HLG) | #114, partly #138 | no planned source delivers HDR; the descriptor carries sRGB or linear until one does | +| Video decoding beyond MJPEG on the device (H.264, software JPEG) | implied by "video" | the browser bakes video into GIF or FSEQ; the device decodes only what its hardware decodes | +| FSEQ extras up front: zstd, `.xlz` archives, FPP Connect upload, FPP MultiSync | the pattern of WLED's attempts | the bundling that stalled all five of them; uncompressed v2 first, each extra on a measurement or a user who needs it | +| One module for every transport and data type | the first draft of this plan | the standard is one module model with typed data, as GStreamer and TouchDesigner do; MoonModule is that model, and a single module would flatten the pixel protocols' semantics (universes, discovery, sync, priorities) | +| A cap on the current a frame draws, a WiFi TX-power cache | #114 riders | not this idea; already dropped | + +**In a different shape:** + +| What | As proposed | Instead | +|---|---|---| +| Who builds the groundwork | the contributor shapes `VideoService` and the descriptor | MoonLight builds the groundwork below, since core owns the hard constructs; contributors add sources on top in small pull requests | +| USB capture | linked into every P4 image | only into images with room, measured as the SD card was (#136), and after the browser tab source, which serves every board | +| Smoothing | a backlight setting | a picture effect setting, since temporal smoothing applies to any picture, or dropped | +| Letterbox detection | built in | a later sampler option, when a user asks; every setting names the user who needs it | +| The capture seat, one instance per board | the default for every source | only for transports with exclusive hardware (USB, CSI); files and the network feed several sources at once | +| Multi-board sync | FPP MultiSync, as WLED's attempts did | MoonLight's own clock sync from the core backlog first; FPP compatibility later, as an adapter, if users mix in Falcon Player | + +**As proposed:** #140's start corner, direction and offset (the textbook layout model, and small); area sampling in linear light (#138); the capture seam's shape, an opaque handle with a generation counter and formats learned at enumeration (#139); FSEQ playback, starting on the cube. + +## The groundwork + +What MoonLight builds first, so that every contribution after it is one source in a small pull request: + +| # | Piece | Where | What it does | +|---|---|---|---| +| G1 | **Light-list placement** ✅ built | `Layer::placeByLayout`, `ChannelDataSink` | each logical cell takes the light the layout places at its position, from a table built at prepare, only when the frame shares its layer or sits under a modifier; on an identity mapping it is one copy, and alone on its layer G5 sends it to the output as it is; it replaced the mapping's inverse (G1's first form), whose static modifiers cancelled | +| G2 | **Network Receive's `mapped_by`** ✅ built | `NetworkReceiveEffect` | `receiver` as before (the default), `sender` through G1, the status saying "one copy" or "mapped twice"; tests pin a serpentine grid and a 3D layout, which reversed every other row before; on the S3 at 12,288 lights a serpentine layout costs 4.5 ms per frame against 1.1 ms for receiver mapping, an identity layout the same as receiver mapping | +| G3 | **The picture source** ✅ built | a core service, from #138's `VideoService` | the descriptor, publishing in `AudioService`'s pattern, a test pattern as its only source | +| G4 | **The picture effect** ✅ built | a light effect, from #138's sampler | point or area sampling in linear light, fit or stretch, a table at prepare and one gather per frame | +| G5 | **Sender mapping skips the board's mapping** ✅ built | `Layer`, `Drivers` | the existing rule, hottest path when possible, extended to layouts that are not the identity: an identity layout is already one copy at any number of layers, since the drivers do not map; a layer whose only writer is mapped by the sender, with no modifiers and no second layer, is read as it is whatever the layout, so the layout still shapes the preview and the board's own effects; blending or a modifier on such a layout falls back to G1's round trip, with G1 inverting the layout's mapping alone so the modifier transforms the frame; the tests add a rotated and a mirrored layer to the serpentine and 3D cases | +| G6 | **The channel-data sink** ✅ built | `ChannelDataSink`, from Network Receive | staging, hold-last-frame, `mapped_by` and the placement, the counters and the status path, extracted from Network Receive so a file source is a thin effect on it; Network Receive's tests pass unchanged | +| G7 | **A contributor page** ✅ built, [the how-to](../../how-to/adding-a-stream-source.md) | docs | how to add a channel-data source and how to add a picture source, with the test pattern as the template | +| G8 | **The browser as a picture source** | `PictureService`, a UI page | a second `source`, `browser`: a page draws a video file, a webcam or a shared screen (`getDisplayMedia`) onto a canvas at the picture's size and sends the RGB frames over the existing WebSocket into the service's pixels, as WLED streams a tab (DedeHai, PR #4997); a `