From c2cf6a4b499ae10b87cfe94718235200b5e5d7b2 Mon Sep 17 00:00:00 2001 From: ewowi Date: Tue, 13 Oct 2026 23:20:51 +0200 Subject: [PATCH 1/4] Uploads stream without blocking, Teensy resolves host names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every file upload, to flash or to the SD card, now streams a slice per tick, so a slow client can no longer freeze the lights or reboot the board through the watchdog. On the Teensy, host names in NetworkSend, OSC, audio sync and MIDI resolve, and uploads run ten times faster after a QNEthernet flow-control fix. Two new plans: streams (pictures, recorded shows and live input) and audio-reactive live performance. KPI: 256lights | Desktop:2301KB | tick:7/7/7/2/10/7/264/7/7/10us(FPS:142857/142857/142857/500000/100000/142857/3787/142857/142857/100000) | src:325(83679) | test:240(55073) | functions-over:353 Commit: src +211/-150 lines | test +295/-99 lines | code findings 1634 -> 1632 (complex function -1, deeply nested -1) | duplicated lines 3656 -> 3656 (+0) **Core** - HttpServerModule: the SD transfer slots become file transfers for every upload and every /sd download, on every board; the buffer lives from claim to finish in internal RAM; one checked entry for both storages (413, 503, 507); every POST /api/file hands off as soon as its headers arrive; the synchronous flash upload is gone. - HostResolver: a board with no worker task looks names up from the asking side, one non-blocking step per lookup, sharing due() and settle() with the task's pass. - Platform seam resolveHostStep: QNEthernet's callback DNS on the Teensy with a generation counter and its own timeout; ESP32 and desktop answer Failed, since their task resolves. - Teensy: QNEthernet patch opening the TCP receive window as the sketch reads instead of on copy; a 1 MB upload went from 30 s with 35% retransmits to 4.8 s with none (bench Teensy 4.1-NE). **Tests** - unit_HttpServerModule_upload: a 20 KB flash upload lands whole, a stalled client never holds a tick and leaves no file, an oversized one gets 413; the stall test fails against the old synchronous drain (one tick took 5,081 ms). - unit_HostResolver: names resolve across non-blocking steps, and a failed name retries only when due; both fail without the inline step. - http_loopback.h: the loopback helpers the SD and upload tests share. **Docs/CI** - New plans: Streams, every frame from outside (two families meeting in the layer, image and wiring order, the groundwork), and Audio reactive live performance (MIDI frame, keys and drums effects, tracking musicians with mmWave radar, sensor table with prices). - present/index.md: the Next list; backlog: the two shipped Teensy items removed; supporting.md: HostResolver line; Teensy plan: release install and network update verified. - TeensyImage.h: its header comment cut to the group lead; metrics refreshed. **Reviews** - 👾 transfer buffer could land in PSRAM: done, allocInternal. - 👾 out of memory answered as 503: done, claimTransfer answers 503 or 507 in one place. - 👾 Next item described shipped work: done, it now names the open half (downloads). - 👾 "Verified" in an unbuilt plan: done, "Verify". - 👾 dead hasSdCard guards: done, removed. - 👾 the DNS step allocates on the asking thread: done, stated in the seam comment. - 👾 flash downloads still synchronous: deferred, a third concurrent read would get 503 with two slots; recorded as Next item 3. - Gates: check_docgen and check_code failed once (comment lengths, the test helpers' duplication, TeensyImage.h missing from the last baseline); fixed and rerun, both below baseline. ESP32 classic, S3 and P4 and the Teensy built; check_esp32_built passed for all three. Co-Authored-By: Claude Opus 5.5 --- docs/assets/hardware/sensors/dw3000.webp | Bin 0 -> 4884 bytes docs/assets/hardware/sensors/hc-sr501.webp | Bin 0 -> 7044 bytes docs/assets/hardware/sensors/hlk-ld2450.webp | Bin 0 -> 8068 bytes docs/assets/hardware/sensors/mpu6050.webp | Bin 0 -> 11512 bytes .../hardware/sensors/p4-csi-camera.webp | Bin 0 -> 4636 bytes docs/assets/hardware/sensors/vl53l0x.webp | Bin 0 -> 7400 bytes docs/assets/hardware/sensors/vl53l8cx.webp | Bin 0 -> 10176 bytes docs/moonmodules/core/supporting.md | 2 +- docs/reference/metrics/code.md | 26 +- docs/reference/metrics/docgen.md | 30 +- docs/reference/metrics/repo-health.json | 78 ++-- docs/reference/metrics/repo-health.md | 102 +++--- docs/work/future/backlog-core.md | 12 - .../present/Plan-20261010 - Teensy 4.1.md | 7 +- ...61013 - Audio reactive live performance.md | 126 +++++++ ...013 - Streams, every frame from outside.md | 334 ++++++++++++++++++ docs/work/present/index.md | 15 + src/core/system/HttpServerModule.cpp | 189 +++++----- src/core/system/HttpServerModule.h | 47 +-- src/core/util/HostResolver.h | 67 +++- src/core/util/TeensyImage.h | 11 +- src/platform/desktop/platform_desktop.cpp | 3 + src/platform/esp32/platform_esp32.cpp | 3 + src/platform/platform.h | 4 + src/platform/teensy/platform_teensy.cpp | 37 ++ .../QNEthernet-recv-window-on-read.patch | 38 ++ test/CMakeLists.txt | 1 + ...lette_preset_changes_only_the_palette.json | 2 +- ...ment_adds_an_effect_with_its_controls.json | 4 +- ...cenario_Drivers_output_and_brightness.json | 2 +- ...o_Effects_pipeline_builds_and_renders.json | 8 +- .../scenario_Effects_swap_while_running.json | 4 +- ...cts_teardown_under_a_running_pipeline.json | 14 +- .../scenario_Layers_stack_and_blend_live.json | 16 +- ...nario_Layouts_resize_reallocates_live.json | 6 +- ...cenario_Modifiers_reshape_the_mapping.json | 14 +- ..._Palettes_a_script_drives_the_effects.json | 4 +- test/unit/core/http_loopback.h | 74 ++++ test/unit/core/unit_HostResolver.cpp | 42 +++ .../core/unit_HttpServerModule_sdcard.cpp | 79 +---- .../core/unit_HttpServerModule_upload.cpp | 124 +++++++ 41 files changed, 1146 insertions(+), 379 deletions(-) create mode 100644 docs/assets/hardware/sensors/dw3000.webp create mode 100644 docs/assets/hardware/sensors/hc-sr501.webp create mode 100644 docs/assets/hardware/sensors/hlk-ld2450.webp create mode 100644 docs/assets/hardware/sensors/mpu6050.webp create mode 100644 docs/assets/hardware/sensors/p4-csi-camera.webp create mode 100644 docs/assets/hardware/sensors/vl53l0x.webp create mode 100644 docs/assets/hardware/sensors/vl53l8cx.webp create mode 100644 docs/work/present/Plan-20261013 - Audio reactive live performance.md create mode 100644 docs/work/present/Plan-20261013 - Streams, every frame from outside.md create mode 100644 teensy/patches/QNEthernet-recv-window-on-read.patch create mode 100644 test/unit/core/http_loopback.h create mode 100644 test/unit/core/unit_HttpServerModule_upload.cpp diff --git a/docs/assets/hardware/sensors/dw3000.webp b/docs/assets/hardware/sensors/dw3000.webp new file mode 100644 index 0000000000000000000000000000000000000000..5c5ae1dd46092e98d01a3d30438268c7620a8a75 GIT binary patch literal 4884 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 G00012f0-5l literal 0 HcmV?d00001 diff --git a/docs/assets/hardware/sensors/hc-sr501.webp b/docs/assets/hardware/sensors/hc-sr501.webp new file mode 100644 index 0000000000000000000000000000000000000000..a587d50f49145145d0b97c0dd9bdcb1188de7e3c GIT binary patch literal 7044 zcmZ{oWm6mgx1@1*3mzo63^usCySux)I{|{b26uOd0TLK|kT6Ja2$0|e1_^TCTf4PY zTXpaGa?beyPj_`|E6K};Qoz9($VqGJYYNhM{^#b(hWiT7nhu{1FYd;lr}T-Q?i1bJ z`gJ6BqJ#TQq&T)9$(wkeR!}QTUhiGn^M_hc{be8~OY!I7&0mAZJ12h)5;Yq57w8-}$oIN35z^9R!h1{eyz7 z*SnXey&3z>mjy$Zbj$vrNFQ5{6n^BP@H?agaeg8o?pM0!ZD3Gh8wvh0GG}gbxbk}Ag#(56!hm3dEcW-2|ScZ4b zRf2cl6SDIhoCio;F59;$%yxcXG`;mBp^c~|hkHQc^@^~&ZD#{pe95&ANhvf*BOvUj{<)DT$+KK|yg?X*@A*|g8^{L%6q6xJjcI$FwDSx?jTiBGR0qu7leLhYNRlEM0hQUo)r|+UZkCj?mGs)@1p0A>@r$U5 zkcoQz6R;VQEn>k*BsC~?z%RvqY(d}p{UX)XQx+_77pE$2l^z24TI~!4A6rtuByujK zDjJ?7-JXpQ9^4gi(}k+>nU>B?wD3>P^4d08CF>md+C`{E3=by^r!{r60Nh`P^f5+o zZwhZO|Dr$E#>(qwAA?L2C4Bfn_UNebHr_$n2OzV;V z0|D8^p=?-Mp20;>-Fgk5ZSGmVLIg&s?*y0f(?aUU$H#eBd0ZjEWR|9IvIvsV+fO?9 zzxioRJJYIcm(W023#osW%*LO-)pcTV6uo*)BfS#XYex=mgA&lc0v?iR%P9F)_E5v# z?xCF~m@(Sk$Hn0-OVmO0u~!`J4zz$etbUhLUuBxBe_y{MW6uuV{0f>2_XR7#tS-{>@SJy)@3f^E=$|tN@^)1V^fK{4&-PF6yYWQh6nlt;V>6 z{q>Q5F;v)j)D}7q({AZ=W1?3-vDVzVJZAG`%vp~ga9)=4zrEy<@B3YzuJgN0hr$)z zZQt=9l)Hhcf^LYhCYeo|O$E=*tL=Anz8e&?5@d$>_dNE?Lyo8aO~-CP@SC-co}Xmc zbr~o0UcyZ@P5Ym#PJay7Hm?V#xu{Hm--zy?7+swOlr&>eyi*6a$s&@*l6zODzz3Eh zms8i5dm^bMy~jzh|K{EGAJ>0lz45=Z|Nkii&m!F0doB&!{~2svUpP3U6>e(p2Pp`k zV+RR`kSt8?o4D{#PUQM2G&v%QN?f98+vana3iWjAZF={^-wy~9wMlzetCh4RbnTt_ zA0~dCzOL8uPRFalqac)wNDl?8R%Hs}?<}I?Gtyy*7HnCd2m|*ZMd%OG7spDd151

$R~Dj-}%jMF>bCyltJxE- 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..56b0c26a697ff1ae83e39ac9e6d483a20e468a1e GIT binary patch literal 4636 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 RjG8(gh`x9K{xtsy{{k)v>oouX literal 0 HcmV?d00001 diff --git a/docs/assets/hardware/sensors/vl53l0x.webp b/docs/assets/hardware/sensors/vl53l0x.webp new file mode 100644 index 0000000000000000000000000000000000000000..8644f8879c753be37244f36ad8c4d68196f25111 GIT binary patch literal 7400 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 akk9}V;9^i{VJ9&F00000000000002p2u;@j literal 0 HcmV?d00001 diff --git a/docs/assets/hardware/sensors/vl53l8cx.webp b/docs/assets/hardware/sensors/vl53l8cx.webp new file mode 100644 index 0000000000000000000000000000000000000000..116c9715fc3b05891cc07f2afa3105f317f2be0e GIT binary patch literal 10176 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 literal 0 HcmV?d00001 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/reference/metrics/code.md b/docs/reference/metrics/code.md index 7faa16982..20b47b93d 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,16 +4,16 @@ 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. +**1632 finding(s)** across 324 file(s). 2.66% of all lines are duplicated, 3656 lines. ## By rule | Rule | Findings | |---|---:| | duplicated block | 754 | -| complex function | 302 | +| complex function | 301 | | blocking call on the render path | 193 | -| deeply nested | 115 | +| deeply nested | 114 | | module type in the UI | 77 | | long function | 49 | | long parameter list | 35 | @@ -46,8 +46,8 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta |---|---:| | `test` | 560 | | `light` | 417 | -| `core` | 371 | -| `platform` | 173 | +| `core` | 368 | +| `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 9197 | | `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 | @@ -196,10 +196,9 @@ 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 | @@ -259,10 +258,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 760-768, 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 | @@ -308,6 +308,7 @@ The next function worth simplifying is at the top of each file's list. | `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 | @@ -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:760`, duplicated block 9 | +| `src/platform/platform.h` | 1 | `platform.h`, large file 1445 | | `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 | diff --git a/docs/reference/metrics/docgen.md b/docs/reference/metrics/docgen.md index 06ec75cbb..7b6c0f1b0 100644 --- a/docs/reference/metrics/docgen.md +++ b/docs/reference/metrics/docgen.md @@ -4,7 +4,7 @@ Generated by [`moondeck/check/check_docgen.py`](../../../moondeck/check/check_do Every place the generated documentation breaks the shape [the standards](../../contributing/documentation-standards.md#the-card) define. Current state only: the trend is this file's git history. The list only shrinks. -**0 error(s)** and **3736 warning(s)** across 434 page(s). +**0 error(s)** and **3734 warning(s)** across 433 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. @@ -16,8 +16,8 @@ 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 | +| over-wide comment lines | 0 | 1567 | +| split paragraphs | 0 | 1277 | | appendix sections nothing refers to | 0 | 355 | | multi-line comment blocks | 0 | 311 | | over-long sentences | 0 | 81 | @@ -30,11 +30,11 @@ 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 | 2031 | | `platform/index.md` | 0 | 658 | | `light/effects.md` | 0 | 251 | -| `core/system.md` | 0 | 138 | -| `core/supporting.md` | 0 | 132 | +| `core/system.md` | 0 | 136 | +| `core/supporting.md` | 0 | 131 | | `light/power-functions.md` | 0 | 122 | | `light/drivers.md` | 0 | 97 | | `core/services.md` | 0 | 88 | @@ -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 **2031 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, 1282 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: 1351 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. ### platform/index.md @@ -110,7 +110,7 @@ By rule, warnings: 170 split paragraphs, 81 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **138 warning(s)** across 30 file(s). +**0 error(s)** and **136 warning(s)** across 29 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 | *19 more warning files, 47 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: 99 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 diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index d416e3fee..ea20c5343 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,25 +1,25 @@ { - "commit": "ce37ca46", + "commit": "925757c3", "flash": { - "esp32s3-n16r8": 2438784, - "esp32": 2353056, - "esp32p4rev1-eth": 2364160, + "esp32s3-n16r8": 2437888, + "esp32": 2240864, + "esp32p4rev1-eth": 2373392, "esp32p4rev1-eth-wifi": 2523840, - "esp32s3-n8r8": 2438784, - "esp32s31": 2694736, - "esp32-16mb": 2353248, + "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": 2357000, "desktop-windows": 1925120, "esp32c3": 2335072, "desktop-linux": 2548000, - "esp32s3-n32r16": 2438000, - "teensy41": 1266668 + "esp32s3-n32r16": 2438176, + "teensy41": 1267692 }, "measured": { "esp32p4rev1-eth": "2026-10-13", @@ -51,8 +51,8 @@ "desktop-macos": { "p50": 8, "p95": 324, - "n": 31, - "last": "2026-10-12" + "n": 32, + "last": "2026-10-13" }, "esp32s3-n16r8": { "p50": 2073, @@ -127,10 +127,10 @@ }, "State_one_document_adds_an_effect_with_its_controls": { "desktop-macos": { - "p50": 4, + "p50": 7, "p95": 387, - "n": 30, - "last": "2026-10-12" + "n": 32, + "last": "2026-10-13" }, "esp32s3-n16r8": { "p50": 2505, @@ -548,7 +548,7 @@ "p50": 7, "p95": 15, "n": 32, - "last": "2026-10-12" + "last": "2026-10-13" }, "desktop-windows": { "p50": 4, @@ -580,7 +580,7 @@ "p50": 7, "p95": 10, "n": 32, - "last": "2026-10-12" + "last": "2026-10-13" }, "desktop-windows": { "p50": 4, @@ -594,7 +594,7 @@ "p50": 1, "p95": 2, "n": 32, - "last": "2026-10-12" + "last": "2026-10-13" }, "desktop-windows": { "p50": 4, @@ -626,7 +626,7 @@ "p50": 7, "p95": 18, "n": 32, - "last": "2026-10-12" + "last": "2026-10-13" }, "esp32s3-n16r8": { "p50": 9080, @@ -658,7 +658,7 @@ "p50": 267, "p95": 355, "n": 32, - "last": "2026-10-12" + "last": "2026-10-13" } }, "Layouts_resize_reallocates_live": { @@ -742,7 +742,7 @@ "p50": 7, "p95": 10, "n": 32, - "last": "2026-10-12" + "last": "2026-10-13" }, "Layouts_resize_reallocates_live": { "p50": 10, @@ -776,16 +776,16 @@ } }, "loc": { - "core": 30056, + "core": 30147, "light": 31265, - "platform": 21118, - "ui": 12470, - "test": 67258, - "moondeck": 31290 + "platform": 21509, + "ui": 12471, + "test": 67552, + "moondeck": 31292 }, "comments": { "core": { - "lines": 6425, + "lines": 6446, "ratio": 0.237 }, "light": { @@ -793,15 +793,15 @@ "ratio": 0.27 }, "platform": { - "lines": 4133, - "ratio": 0.219 + "lines": 4159, + "ratio": 0.217 }, "ui": { - "lines": 3470, + "lines": 3471, "ratio": 0.295 }, "test": { - "lines": 7114, + "lines": 7131, "ratio": 0.122 }, "moondeck": { @@ -810,20 +810,20 @@ } }, "tests": { - "cases": 2515, + "cases": 2523, "scenarios": 30 }, "docs": { - "md_files": 156, - "md_lines": 33332, - "plans_files": 48, - "backlog_lines": 3399, + "md_files": 160, + "md_lines": 33946, + "plans_files": 50, + "backlog_lines": 3387, "lessons_lines": 532, "claude_md_lines": 285 }, "complexity": { - "functions": 4989, - "over_threshold": 354, + "functions": 4996, + "over_threshold": 353, "worst_ccn": 128 } } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 0e4206943..7a208e469 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 `925757c3`. 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. @@ -10,27 +10,27 @@ Current state only; the trend is this file's git history (`git log -p docs/refer |---|---:|---:|---:| | 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 esp32 | 2,061 KB | 2,188 KB | +127.2 KB (+6.2%) | +| 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 | 2,022 KB | 2,318 KB | +296.0 KB (+14.6%) | | flash esp32p4rev1-eth-wifi | 2,304 KB | 2,465 KB | +161.1 KB (+7.0%) | | 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-n16r8 | 2,111 KB | 2,381 KB | +269.3 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-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,238 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 core | 22,299 | 30,147 | +7,848 (+35.2%) | | 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%) | +| lines platform | 16,798 | 21,509 | +4,711 (+28.0%) | +| lines ui | 11,394 | 12,471 | +1,077 (+9.5%) | +| lines test | 57,759 | 67,552 | +9,793 (+17.0%) | +| lines moondeck | 29,325 | 31,292 | +1,967 (+6.7%) | +| unit tests | 2,120 | 2,523 | +403 (+19.0%) | | scenarios | 21 | 30 | +9 (+42.9%) | 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. @@ -40,24 +40,24 @@ 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-macos | 2,302 KB (+80 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 | 2,188 KB (−110 KB) ✓ | 2,496 KB | 88% | yes | +| esp32-16mb | 2,301 KB (+3 KB) ⚠ | 4,096 KB | 56% | yes | | esp32-eth | 1,642 KB | 2,496 KB | 66% | **STALE 32d** | | esp32-pico | 2,058 KB | 3,072 KB | 67% | **STALE 34d** | | 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 | 2,318 KB (+9 KB) ⚠ | 4,096 KB | 57% | yes | | 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 | +| esp32s3-n16r8 | 2,381 KB (−896 B) ✓ | 4,096 KB | 58% | yes | +| esp32s3-n32r16 | 2,381 KB (+176 B) ⚠ | 4,096 KB | 58% | yes | +| esp32s3-n8r8 | 2,381 KB (−992 B) ✓ | 3,072 KB | 77% | yes | +| esp32s3-zero | 2,173 KB (−1 KB) ✓ | 2,496 KB | 87% | yes | +| esp32s31 | 2,680 KB (+48 KB) ⚠ | 4,096 KB | 65% | yes | | qemu | 1,351 KB | - | - | carried (age?) | -| teensy41 | 1,237 KB (−1 KB) ✓ | 3,456 KB | 36% | yes | +| teensy41 | 1,238 KB (+1 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,25 @@ 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 | -109.6 KB | +28.6 KB | -98.1 KB | +11.9 KB | -30.0 KB | -21.8 KB | `925757c3` | +| esp32-16mb | +2.7 KB | +27.4 KB | -11.4 KB | +11.7 KB | -25.2 KB | +0.2 KB | `925757c3` | +| esp32p4rev1-eth | +9.0 KB | +34.2 KB | -15.8 KB | +15.7 KB | -25.1 KB | -0.1 KB | `925757c3` | +| esp32s3-n16r8 | -0.9 KB | +3.1 KB | -3.4 KB | +1.3 KB | -1.9 KB | -0.0 KB | `925757c3` | +| esp32s3-n32r16 | +0.2 KB | +0.3 KB | +0.0 KB | +0.2 KB | -0.3 KB | -0.0 KB | `925757c3` | +| esp32s3-n8r8 | -1.0 KB | +2.2 KB | -2.2 KB | +0.6 KB | -1.6 KB | +0.1 KB | `925757c3` | +| esp32s3-zero | -1.0 KB | +2.2 KB | -2.2 KB | +0.6 KB | -1.6 KB | +0.1 KB | `925757c3` | +| esp32s31 | +48.1 KB | +69.9 KB | -19.8 KB | +16.6 KB | -25.5 KB | +7.0 KB | `925757c3` | Largest moves: -- **esp32s3-n16r8**: none -- **esp32s3-n32r16**: none -- **esp32s3-n8r8**: none -- **esp32s3-zero**: none +- **esp32**: `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` -3,419 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` -2,866 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` -2,644 B, `f_mkfs` -2,321 B, `mm::ui::appJs` +2,258 B +- **esp32-16mb**: `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` -3,419 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` -2,866 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` -2,644 B, `mm::ui::appJs` +2,258 B, `mm::HttpServerModule::serveScriptCatalog(mm::platform::TcpCo` -2,105 B +- **esp32p4rev1-eth**: `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` -3,400 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` -2,584 B, `mm::HttpServerModule::serveScriptCatalog(mm::platform::TcpCo` -2,466 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` -2,376 B, `mm::ui::appJs` +2,258 B +- **esp32s3-n16r8**: `mm::HttpServerModule::pollWledStateFromWebSockets()` -1,351 B, `_ZN2mm16HttpServerModule14visitOwnLeavesIRZNS0_18baselineLea` -824 B, `mm::HttpServerModule::pollControlClient(int)` +572 B, `mm::HttpServerModule::writeTreeFragment(mm::JsonSink&, mm::H` -399 B, `mm::HttpServerModule::onTextFrame(int, char const*)` +394 B +- **esp32s3-n32r16**: `void mm::detail::blendMapped(mm::Buffer` +283 B, `mm::blendMap(mm::Buffer const&, mm::Buffer&, mm::MappingLUT ` -128 B, `mm::moonlive::(anonymous namespace)::Parser::structValueAt()` -98 B, `mm::ui::appJs` -71 B, `mm::PwmLightDriver::release()` +67 B +- **esp32s3-n8r8**: `mm::HttpServerModule::pollWledStateFromWebSockets()` -1,351 B, `_ZN2mm16HttpServerModule14visitOwnLeavesIRZNS0_18baselineLea` -824 B, `mm::HttpServerModule::pollControlClient(int)` +592 B, `mm::HttpServerModule::writeTreeFragment(mm::JsonSink&, mm::H` -399 B, `mm::HttpServerModule::onTextFrame(int, char const*)` +398 B +- **esp32s3-zero**: `mm::HttpServerModule::pollWledStateFromWebSockets()` -1,335 B, `_ZN2mm16HttpServerModule14visitOwnLeavesIRZNS0_18baselineLea` -824 B, `mm::HttpServerModule::pollControlClient(int)` +572 B, `mm::HttpServerModule::writeTreeFragment(mm::JsonSink&, mm::H` -399 B, `mm::HttpServerModule::onTextFrame(int, char const*)` +394 B +- **esp32s31**: `mm::SdCardModule::defineControls()` -3,496 B, `spicommon_bus_initialize_io` +3,402 B, `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` -3,400 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` -2,584 B, `mm::HttpServerModule::serveScriptCatalog(mm::platform::TcpCo` -2,506 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. @@ -106,7 +114,7 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | 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 ? | - | - | - | - | - | - | - | +| State_one_document_adds_an_effect_with_its_controls | 7 (+3) ⚠ | - | - | 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 ? | @@ -138,36 +146,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,147 (+91) ⚠ | 6,446 | 23.7 % | +| light | 31,265 | 7,512 | 27.0 % | +| platform | 21,509 (+391) ⚠ | 4,159 | 21.7 % (−0.2 %) ✓ | +| ui | 12,471 (+1) ⚠ | 3,471 | 29.5 % | +| test | 67,552 (+294) ⚠ | 7,131 | 12.2 % | +| moondeck | 31,292 (+2) ⚠ | 4,933 | 18.0 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,515 | +| unit cases | 2,523 (+8) ✓ | | scenarios | 30 | ## Complexity | Metric | Value | |---|---:| -| functions | 4,989 (+4) ✓ | -| over threshold | 354 | +| functions | 4,996 (+7) ✓ | +| over threshold | 353 (−1) ✓ | | 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 | 160 (+4) ⚠ | +| markdown lines | 33,946 (+614) ⚠ | +| plan files | 50 (+2) ⚠ | +| backlog lines | 3,387 (−12) ✓ | | lessons lines | 532 | | CLAUDE.md lines | 285 | 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/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..72167cc46 --- /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); +- which notes started and which ended since the last frame, so two hits between frames are never lost 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, 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. 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..afeb60547 --- /dev/null +++ b/docs/work/present/Plan-20261013 - Streams, every frame from outside.md @@ -0,0 +1,334 @@ +# 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 Ambilight 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 in wiring order), 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 in wiring order | 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. + +**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 in wiring order into the layer through the layout. FSEQ needs it, and so does Network Receive for senders that send in wiring order (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. + +## Image order and wiring order + +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 the order the sender's data is in, and the industry uses two conventions: + +| Sender sends | Examples | Copied into the layer by index | +|---|---|---| +| **Image order:** row by row, like a picture, 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 | +| **Wiring order:** arranged in the order the lights are wired, 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 orders coincide, which is why the difference stays invisible on simple setups. + +**So Network Receive gains a `data order` setting,** image or wiring: + +- **Image order** is today's copy, unchanged, so nothing changes for anyone using it now. +- **Wiring order** places channel k through the light-list placement, which is the inverse of the layer's mapping: composed, the two cancel, so channel k reaches physical light k while still blending and taking modifiers. +- **FSEQ is always wiring order,** since xLights writes it that way, so the FSEQ effect uses the placement without a setting. + +**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 Ambilight 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.** Ambilight stops being an effect of its own and becomes the picture effect's area sampler. Network Receive keeps its protocol family and gains wiring order 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: wiring order"] + 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
Ambilight #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 ambilight 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. + +## 3D layouts + +TouchMyLight's cube is the first user, so 3D works from the start. + +- **A light list carries wiring order, not 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, wiring-order data is mapped twice wherever the layer's order differs from the wiring, as on a cube (see image order and wiring order 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 | +| `AmbilightEffect` (#138) | 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 effect beside Network Receive, sharing the light-list placement and the universe settings | +| Network Receive (shipped) | kept as the pixel-protocol family, gaining a `data order` setting: image order as today, wiring order 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 | +|---|---|---| +| Ambilight 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 | an Ambilight 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** | a helper in `src/light/layers/` | at prepare, maps light k to the logical cell it lands in, from the layer's own physical-to-logical fold, with no table when the orders coincide; per frame, writes values in wiring order into the layer buffer | +| G2 | **Network Receive's `data order`** | `NetworkReceiveEffect` | image order as today, wiring order through G1; the first user of G1 and the path FSEQ copies; gated by the before-and-after measurement | +| G3 | **The picture source** | 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** | 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 | **A contributor page** | docs | how to add a channel-data source and how to add a picture source, with the test pattern as the template | + +G1 and G2 come first: they add the second convention every wiring-order sender needs, and wait on no naming. G3 and G4 take #138's core part, credited to Gohnnyman. Before code: the double mapping confirmed on the bench (a serpentine grid fed by xLights, expected to show every other row reversed) and the open questions below decided. + +## How contributions arrive + +Each later piece is a small pull request, one topic, based on current main, with only its own commits: + +| Pull request | Who | On | +|---|---|---| +| Backlight wiring, today's #140 resubmitted on main | Gohnnyman | nothing; it can come now | +| Color trim, split from #139 | Gohnnyman | nothing; a separate idea | +| FSEQ playback from SD: uncompressed v2, sparse ranges, the frame index from the clock, verified on a 3D layout | TouchMyLight | G1, G2 | +| Border sampling: edge depth, smoothing | Gohnnyman | G4 | +| GIF decoder, PPM as a test format | open | G3 | +| USB capture with its MJPEG decoder, P4, with Teensy stubs | Gohnnyman | G3 | +| Browser pages: converter, baking to FSEQ, a live tab source | open | G1 to G4 | +| Recording, HDMI out on the ESP32-P4-PC, clock sync | open | FSEQ playback | + +#138 and #139 close with thanks once G3 and G4 land, their good parts credited in the groundwork; a stacked pull request from a fork always shows its parents, so it is reviewed only after the one below it merges. + +## Open questions + +- The names: `PictureService` and `PictureEffect`, or names closer to the industry's (media source, pixel map)? +- FSEQ as its own effect beside Network Receive (proposed), or a second source of one channel-data effect, which would rename Network Receive? +- Where the light-list placement lives: a helper beside the layer's mapping build, or a method of the layer itself? +- Does a recording capture the layer's input (before mapping) or the drivers' output (after correction)? xLights expects the output. +- How far video goes on a board without a hardware decoder: GIF and image sequences, or a software decoder measured in flash first. +- How HDMI out is wired on the ESP32-P4-PC: its sources name HDMI and MIPI-DSI but no bridge chip; Olimex's separate P4 HDMI adapter uses Lontium's LT8912B (MIPI-DSI to HDMI, up to 1080p60), so check the schematic. +- Whether a layout reads xLights model exports, so a 3D model's node order and positions come from the file that designed the show. + +## Prior art + +- GStreamer and FFmpeg: transport, format and geometry as separate concerns; one element model with typed outputs. +- TouchDesigner: one operator model, separated by data type into channel operators (CHOPs: DMX In, Art-Net, MIDI, audio) and texture operators (TOPs: video, files, capture), the reference for the two families. +- Inverse mapping: a GPU fragment shader sampling a texture through a composed coordinate transform, and OpenCV's `remap` with a precomputed table; the reason MoonLight composes geometry instead of chaining frames. +- MADRIX: the closest tool, LED pixels with effects, layers, a 3D patch, media, capture and standalone playback. +- Resolume: video layers with pixel mapping to fixtures on a PC. +- Hyperion and HyperHDR: grabbers, LED layout, mean-color zones, smoothing, black-border detection. +- xLights and the FSEQ format (v2: header, channel count, frame count, step time, optional zstd and sparse ranges); Falcon Player for standalone playback and MultiSync; ESPixelStick for FSEQ from SD on an ESP32. +- WLED: the Image effect (PR #3835), PixelForge (PR #4982), DDP over WebSocket (PR #4997), and the unmerged FSEQ attempts (#2413, #4570, #5641). diff --git a/docs/work/present/index.md b/docs/work/present/index.md index 08ba7e4c6..685c6a39c 100644 --- a/docs/work/present/index.md +++ b/docs/work/present/index.md @@ -5,3 +5,18 @@ One file per plan being built now, as `Plan-YYYYMMDD - .md`. A plan is written in plan mode, approved before work starts, and edited as reality changes. **Its life ends at its PR**: the plan becomes the PR description and the file is deleted in that same PR ([CLAUDE.md, Branch](../../../CLAUDE.md#branch)). The merged PR is then the design record, and it carries the diff the plan describes. A file here that no longer has work behind it is either finished, and belongs in its PR, or abandoned, and belongs deleted. + +## Next (2026-10-13) + +1. **[Boards known by name](Plan-20261009%20-%20Boards%20known%20by%20name.md)**, not started. A setting that points at another board resolves its name through MoonLight's own Devices discovery before mDNS, and cards name their peers. It also gives the Teensy board names, since its lwIP has no mDNS lookup and a `.local` name fails there today. +2. **[Layer compositing](Plan-20261011%20-%20Layer%20compositing%2C%20masks%20and%20transitions.md), step 3: transitions.** Steps 1 and 2 shipped in #137. The old effect fades out while the new one fades in, with one ramp type shared with the boot soft start; a Discord request. +3. **Downloads on the tick path.** Every upload now moves through the transfer slots; a flash download still writes its whole file on one tick, bounded per chunk by the write deadline. Moving downloads onto the slots too needs a look at how many files the UI reads at once, since two slots answer a third request with 503. Firmware uploads drain whole by design and feed the watchdog. + +| Plan | State | +|---|---| +| Teensy 4.1, State by subscription, MoonLive Coord3D and RGB | shipped in #137 | +| Native HUB75 output | shipped | +| Network submodules | shipped, partly verified | +| Audio codec | in PR 129, M3 unverified | +| ESP32-C3 and the Athom bulb | open, waiting on the bulb | +| Input mapping, Gamepads and MIDI desks, Presets as state documents, Code ratchets | partial | diff --git a/src/core/system/HttpServerModule.cpp b/src/core/system/HttpServerModule.cpp index 4965afc4b..b3fb5ba27 100644 --- a/src/core/system/HttpServerModule.cpp +++ b/src/core/system/HttpServerModule.cpp @@ -5,13 +5,13 @@ /// /// @moreinfo /// -/// ## Why an upload needs two timeouts +/// ## Why a drained body needs two timeouts /// -/// The upload drain blocks rendering on `tick20ms()` until the transfer ends or a bound trips, which is accepted for a user-initiated transient upload. +/// A file upload moves through a transfer slot a bounded slice per tick and blocks nothing. A state document and a firmware image drain whole, blocking `tick20ms()` until the body ends or a bound trips. /// -/// `kUploadIdleMs` resets on every read so a steady upload never trips it, and `kUploadHardMs` caps the whole request so a slowloris trickle cannot hold the freeze open. +/// `kUploadIdleMs` resets on every read so a steady body never trips it, and `kUploadHardMs` caps the whole request so a slowloris trickle cannot hold the freeze open. /// -/// The hard cap sits above a legitimate worst case of 256 KB at ~50 KB/s, about 5 s. +/// The idle bound sits under the 12 s watchdog, and the hard cap far above a legitimate worst case: a 32 KB state document arrives in well under a second. /// /// ## Why firmware gets a larger ceiling /// @@ -112,7 +112,7 @@ void HttpServerModule::release() { for (auto& st : streams_) endStream(st); for (auto& ws : wsClients_) ws.close(); for (auto& c : parked_) c.close(); - releaseSdTransfers(); + releaseTransfers(); // Every close site notifies the producer (see cancelBufferedSend), else ghost standing requests keep the driver gathering frames for nobody. for (int i = 0; i < MAX_PREVIEW_CLIENTS; i++) { if (previewClients_[i].valid() && clientSink_) clientSink_->onClientGone(i); @@ -142,7 +142,7 @@ void HttpServerModule::tick20ms() MM_NONBLOCKING { if (soonCount_) pushSoonPatch(); } serveConnections(); - if constexpr (platform::hasSdCard) driveSdTransfers(); // sdTransfers_ has zero slots otherwise, so there is nothing to drive + driveTransfers(); } // One entry from the tick, so the connection work, which blocks by nature, is one call on the render path. @@ -182,40 +182,48 @@ void HttpServerModule::serveParked() { } } -HttpServerModule::SdTransfer* HttpServerModule::claimSdTransfer() { - for (auto& t : sdTransfers_) if (!t.active) return &t; - return nullptr; // both slots busy: the caller answers 503/closes rather than blocking for one to free +// The buffer lives from claim to finishTransfer, so idle slots cost nothing; internal RAM, since the SD host bounces a PSRAM buffer per sector. +HttpServerModule::FileTransfer* HttpServerModule::claimTransfer(platform::TcpConnection& conn) { + for (auto& t : transfers_) { + if (t.active) continue; + t.buf = static_cast<char*>(platform::allocInternal(kTransferBufBytes)); + if (t.buf) return &t; + sendResponse(conn, 507, "application/json", "{\"error\":\"out of memory\"}"); + return nullptr; + } + // Both slots busy: answered rather than blocking for one to free. + sendResponse(conn, 503, "application/json", "{\"error\":\"another transfer is in progress\"}"); + return nullptr; } -void HttpServerModule::finishSdTransfer(SdTransfer& t, bool closeConn) { +void HttpServerModule::finishTransfer(FileTransfer& t, bool closeConn) { if (closeConn) t.conn.close(); t.active = false; t.wstream = platform::FsWriteStream{}; t.rstream = platform::FsReadStream{}; + if (t.buf) { platform::free(t.buf); t.buf = nullptr; } } -// release()'s own cleanup: an active slot holds a FIL (or its desktop equivalent) and a socket, neither freed by going out of scope. -void HttpServerModule::releaseSdTransfers() { - if constexpr (platform::hasSdCard) { - for (auto& t : sdTransfers_) { - if (!t.active) continue; - if (t.isUpload) platform::fsWriteStreamClose(t.wstream, false); - else platform::fsReadStreamClose(t.rstream); - finishSdTransfer(t, true); - } +// release()'s own cleanup: an active slot holds a file handle, a socket and a buffer, none freed by going out of scope. +void HttpServerModule::releaseTransfers() { + for (auto& t : transfers_) { + if (!t.active) continue; + if (t.isUpload) platform::fsWriteStreamClose(t.wstream, false); + else platform::fsReadStreamClose(t.rstream); + finishTransfer(t, true); } } -// An upload slot's bounded chunk, socket to card then the stall guard; uses t.buf, not a stack-local, since a 4 KB frame here once panicked the board. -bool HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { +// An upload slot's bounded chunk, socket to storage then the stall guard; uses t.buf, not a stack-local, since a 4 KB frame here once panicked the board. +bool HttpServerModule::driveUpload(FileTransfer& t, uint32_t now) { if (t.remaining > 0) { - const size_t want = t.remaining < sizeof(t.buf) ? t.remaining : sizeof(t.buf); + const size_t want = t.remaining < kTransferBufBytes ? t.remaining : kTransferBufBytes; const int n = t.conn.read(reinterpret_cast<uint8_t*>(t.buf), want); if (n > 0) { if (!platform::fsWriteStreamWrite(t.wstream, t.buf, static_cast<size_t>(n))) { platform::fsWriteStreamClose(t.wstream, false); sendResponse(t.conn, 500, "application/json", "{\"error\":\"write failed\"}"); - finishSdTransfer(t, true); + finishTransfer(t, true); return false; } t.remaining -= static_cast<size_t>(n); @@ -224,37 +232,38 @@ bool HttpServerModule::driveSdUpload(SdTransfer& t, uint32_t now) { } if (n == 0) { // peer closed with body remaining platform::fsWriteStreamClose(t.wstream, false); - finishSdTransfer(t, true); // the peer is already gone: no response to send + finishTransfer(t, true); // the peer is already gone: no response to send return false; } } if (t.active && t.remaining == 0) { const bool ok = platform::fsWriteStreamClose(t.wstream, true); + if (ok) applyFileChanged(t.path); // the write succeeded, so what was built from it may be stale sendResponse(t.conn, ok ? 200 : 500, "application/json", ok ? "{\"ok\":true}" : "{\"error\":\"write failed\"}"); - finishSdTransfer(t, true); + finishTransfer(t, true); return false; } // A liveness guard, not a size or time cap on the transfer itself: only a stalled peer trips it. - if (t.active && now - t.lastActivityMs > kSdTransferStallMs) { + if (t.active && now - t.lastActivityMs > kTransferStallMs) { platform::fsWriteStreamClose(t.wstream, false); - finishSdTransfer(t, true); + finishTransfer(t, true); } return false; } -// A download slot's bounded chunk: card to socket, resuming where last tick's writeSome left off. -bool HttpServerModule::driveSdDownload(SdTransfer& t, uint32_t now) { +// A download slot's bounded chunk: storage to socket, resuming where last tick's writeSome left off. +bool HttpServerModule::driveDownload(FileTransfer& t, uint32_t now) { if (t.bufSent < t.bufLen) { const int n = t.conn.writeSome(reinterpret_cast<const uint8_t*>(t.buf + t.bufSent), t.bufLen - t.bufSent); - if (n < 0) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); return false; } + if (n < 0) { platform::fsReadStreamClose(t.rstream); finishTransfer(t, true); return false; } if (n > 0) { t.bufSent += static_cast<size_t>(n); t.lastActivityMs = now; return true; } // n == 0: socket buffer full, nothing sent; falls through to the stall guard below. } else if (t.remaining > 0) { - const size_t want = t.remaining < sizeof(t.buf) ? t.remaining : sizeof(t.buf); + const size_t want = t.remaining < kTransferBufBytes ? t.remaining : kTransferBufBytes; const int got = platform::fsReadStreamRead(t.rstream, t.buf, want); - if (got <= 0) { platform::fsReadStreamClose(t.rstream); finishSdTransfer(t, true); return false; } + if (got <= 0) { platform::fsReadStreamClose(t.rstream); finishTransfer(t, true); return false; } t.bufLen = static_cast<size_t>(got); t.bufSent = 0; t.remaining -= static_cast<size_t>(got); @@ -262,28 +271,28 @@ bool HttpServerModule::driveSdDownload(SdTransfer& t, uint32_t now) { return true; } else { platform::fsReadStreamClose(t.rstream); - finishSdTransfer(t, true); + finishTransfer(t, true); return false; } // A liveness guard, not a size or time cap on the transfer itself: only a stalled peer trips it. - if (t.active && now - t.lastActivityMs > kSdTransferStallMs) { + if (t.active && now - t.lastActivityMs > kTransferStallMs) { platform::fsReadStreamClose(t.rstream); - finishSdTransfer(t, true); + finishTransfer(t, true); } return false; } // Several bounded chunks per slot per tick, not one, dispatched to the upload or download leg while storage and the peer keep up; mirrors drainPreviewSend/drainStateSend's non-blocking shape. -void HttpServerModule::driveSdTransfers() { +void HttpServerModule::driveTransfers() { const uint32_t budgetStart = platform::millis(); - for (auto& t : sdTransfers_) { + for (auto& t : transfers_) { if (!t.active) continue; for (;;) { const uint32_t now = platform::millis(); - const bool progressed = t.isUpload ? driveSdUpload(t, now) : driveSdDownload(t, now); + const bool progressed = t.isUpload ? driveUpload(t, now) : driveDownload(t, now); if (!t.active) break; // finished or failed this chunk if (!progressed) break; // no data moved: nothing more to do until next tick - if (now - budgetStart >= kSdTransferTickBudgetMs) break; + if (now - budgetStart >= kTransferTickBudgetMs) break; } } } @@ -357,7 +366,8 @@ bool HttpServerModule::handleConnection(platform::TcpConnection& conn, int patie if (clh) { hasContentLen = true; // An /sd upload rides FAT32's own ceiling, 4 GiB-1 (UINT32_MAX), via strtoull not strtoul: `unsigned long` is only 32-bit on this RV32 target, the same width as that ceiling itself. - const bool sdUpload = std::strncmp(req, "POST /api/file", 14) == 0 && requestNamesSdPath(req); + const bool fileUpload = std::strncmp(req, "POST /api/file", 14) == 0; + const bool sdUpload = fileUpload && requestNamesSdPath(req); const unsigned long long kContentLenMax = sdUpload ? 0xFFFFFFFFULL : 8ULL * 1024 * 1024; const char* valStart = clh + 15; while (*valStart == ' ' || *valStart == '\t') valStart++; // skip OWS after the colon @@ -379,14 +389,13 @@ bool HttpServerModule::handleConnection(platform::TcpConnection& conn, int patie return true; } contentLen = static_cast<size_t>(parsed); - if (sdUpload) { - // Hand off early: the chunked SD-transfer driver pulls the rest off this same connection, a bounded slice per tick, rather than looping here for the whole body. + if (fileUpload) { + // Hand off early: a transfer slot pulls the rest off this same connection, a bounded slice per tick, rather than looping here for the whole body. } else { const size_t headerSize = static_cast<size_t>(headerEnd + 4 - req); size_t bodyNeeded = headerSize + contentLen; // Only streaming routes pull a body larger than buf off the socket; every other route parses it whole, so an oversized body gets 413, never a truncated read. const bool isStreamingRoute = - std::strncmp(req, "POST /api/file", 14) == 0 || std::strncmp(req, "POST /api/firmware/upload", 25) == 0 || // A MoonBase image (~750 KB) streams too; the trailing space keeps `moonbase-update-url`, a small JSON body read whole, out of the match. std::strncmp(req, "POST /api/firmware/moonbase-update ", 35) == 0 || @@ -502,7 +511,7 @@ bool HttpServerModule::handleConnection(platform::TcpConnection& conn, int patie handleApplyState(conn, body, static_cast<size_t>(totalRead) - static_cast<size_t>(body - req), static_cast<size_t>(contentLen)); } else if (std::strcmp(path, "/api/file") == 0 && body) { - // File Manager: POST /api/file?path=<rel> streams the body to an atomic write; `body` holds the buffered bytes and handleWriteFile pulls the rest off the socket. + // File Manager: POST /api/file?path=<rel> streams the body to an atomic write; `body` holds the bytes that came with the headers, and a transfer slot pulls the rest off the socket. const size_t initialLen = static_cast<size_t>(totalRead) - static_cast<size_t>(body - req); // No Content-Length (chunked) is 411, since acting on it would commit an empty file; keyed on header absence, so an explicit `Content-Length: 0` stays a valid empty write. if (!hasContentLen) { @@ -777,16 +786,18 @@ void HttpServerModule::streamFsFile(platform::TcpConnection& conn, const char* p "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %lld\r\n%s" "Connection: close\r\nAccess-Control-Allow-Origin: *\r\n\r\n", mime, static_cast<long long>(size), extraHeaders); - // An /sd download hands off to the chunked driver, one bounded slice per tick. Claimed and opened before the header, so a busy slot or a failed open answers 503/500, not a 200 with no body. + // An /sd download hands off to the chunked driver, one bounded slice per tick. Claimed and opened before the header, so a busy slot, a missing buffer or a failed open answers 503/507/500, not a 200 with no body. if (platform::isSdPath(path)) { - SdTransfer* t = claimSdTransfer(); - if (!t) { sendResponse(conn, 503, "application/json", "{\"error\":\"another SD transfer is in progress\"}"); return; } + FileTransfer* t = claimTransfer(conn); + if (!t) return; if (!platform::fsReadStreamOpen(t->rstream, path)) { + finishTransfer(*t, false); sendResponse(conn, 500, "application/json", "{\"error\":\"read failed\"}"); return; } if (!conn.write(reinterpret_cast<const uint8_t*>(header), static_cast<size_t>(hn))) { platform::fsReadStreamClose(t->rstream); + finishTransfer(*t, false); return; } t->isUpload = false; @@ -856,7 +867,7 @@ void HttpServerModule::serveHlsFile(platform::TcpConnection& conn, const char* n // Source for the streamed upload: the body bytes already in the request buffer, then the rest straight off the socket, so the device never holds the whole upload in RAM. namespace { -// The drain blocks rendering, and the two bounds cap for how long: idle scales with a steady transfer, hard caps the total against a slowloris. @xref{why-an-upload-needs-two-timeouts} +// The drain blocks rendering, and the two bounds cap for how long: idle scales with a steady transfer, hard caps the total against a slowloris. @xref{why-a-drained-body-needs-two-timeouts} constexpr uint32_t kUploadIdleMs = 5000; // max gap between successful reads before abort constexpr uint32_t kUploadHardMs = 60000; // absolute whole-request ceiling (anti-slowloris) // A firmware push is MB-scale and bounds a SLOW transfer where kUploadIdleMs bounds a stalled one, kept tight because it caps the render freeze. @xref{why-firmware-gets-a-larger-ceiling} @@ -925,47 +936,30 @@ void HttpServerModule::handleApplyState(platform::TcpConnection& conn, const cha sendResponse(conn, r.ok ? 200 : 400, "application/json", sink.overflowed() ? "{\"error\":\"out of memory\"}" : sink.data()); } -// The /sd leg of handleWriteFile: claims a slot, opens the stream, writes the body already buffered, and leaves the rest to driveSdTransfers. -void HttpServerModule::handleWriteFileSd(platform::TcpConnection& conn, const char* path, - const char* initialBody, size_t initialLen, size_t contentLen) { - // Reject up front if it would not fit the card, rather than failing mid-write; free is taken conservatively, with no credit for an overwrite. Without hasSdCard, claimSdTransfer answers 503. - if constexpr (platform::hasSdCard) { - if (!platform::sdCardMounted()) { - sendResponse(conn, 503, "application/json", "{\"error\":\"no card mounted\"}"); - return; - } - const uint64_t total = platform::sdCardTotal(); - const uint64_t used = platform::sdCardUsed(); - const uint64_t freeBytes = total > used ? total - used : 0; - if (total > 0 && contentLen > freeBytes) { - char msg[96]; - std::snprintf(msg, sizeof(msg), "{\"error\":\"not enough space (%llu free)\"}", - static_cast<unsigned long long>(freeBytes)); - sendResponse(conn, 507, "application/json", msg); - return; - } - } - SdTransfer* t = claimSdTransfer(); - if (!t) { - sendResponse(conn, 503, "application/json", "{\"error\":\"another SD transfer is in progress\"}"); - return; - } +// Claims a slot, opens the stream, writes the body already buffered, and leaves the rest to driveTransfers; the response goes out once the file commits. +void HttpServerModule::startUpload(platform::TcpConnection& conn, const char* path, + const char* initialBody, size_t initialLen, size_t contentLen) { + FileTransfer* t = claimTransfer(conn); + if (!t) return; if (!platform::fsWriteStreamOpen(t->wstream, path)) { + finishTransfer(*t, false); sendResponse(conn, 500, "application/json", "{\"error\":\"write failed\"}"); return; } - // Whatever of the body already arrived in the request buffer lands now; the rest is pulled off the socket a bounded slice per tick, by driveSdTransfers. + // Hand the stream at most Content-Length of the buffered bytes, since a pipelined next request may follow the body and must not land in the file. const size_t initial = initialLen < contentLen ? initialLen : contentLen; if (initial > 0 && !platform::fsWriteStreamWrite(t->wstream, initialBody, initial)) { platform::fsWriteStreamClose(t->wstream, false); + finishTransfer(*t, false); sendResponse(conn, 500, "application/json", "{\"error\":\"write failed\"}"); return; } + std::snprintf(t->path, sizeof(t->path), "%s", path); t->isUpload = true; t->remaining = contentLen - initial; t->lastActivityMs = platform::millis(); t->active = true; - t->conn = std::move(conn); // the response lands once driveSdTransfers sees remaining reach 0 + t->conn = std::move(conn); } void HttpServerModule::handleWriteFile(platform::TcpConnection& conn, const char* query, @@ -975,35 +969,32 @@ void HttpServerModule::handleWriteFile(platform::TcpConnection& conn, const char sendResponse(conn, 400, "application/json", "{\"error\":\"bad path\"}"); return; } + // Reject up front if it would not fit, rather than failing mid-write; free is taken conservatively, with no credit for an overwrite. + uint64_t total = 0, used = 0; if (platform::isSdPath(path)) { - handleWriteFileSd(conn, path, initialBody, initialLen, contentLen); - return; - } - if (contentLen > kUploadMax) { - sendResponse(conn, 413, "application/json", "{\"error\":\"file too large\"}"); - return; + if (!platform::sdCardMounted()) { + sendResponse(conn, 503, "application/json", "{\"error\":\"no card mounted\"}"); + return; + } + total = platform::sdCardTotal(); + used = platform::sdCardUsed(); + } else { + if (contentLen > kUploadMax) { + sendResponse(conn, 413, "application/json", "{\"error\":\"file too large\"}"); + return; + } + total = platform::filesystemTotal(); + used = platform::filesystemUsed(); } - // Reject up front if it would not fit the free space, rather than failing mid-write; free is taken conservatively, with no credit for an overwrite. - const size_t total = platform::filesystemTotal(); - const size_t used = platform::filesystemUsed(); - const size_t freeBytes = total > used ? total - used : 0; + const uint64_t freeBytes = total > used ? total - used : 0; if (total > 0 && contentLen > freeBytes) { char msg[96]; - std::snprintf(msg, sizeof(msg), "{\"error\":\"not enough space (%lu free)\"}", - static_cast<unsigned long>(freeBytes)); + std::snprintf(msg, sizeof(msg), "{\"error\":\"not enough space (%llu free)\"}", + static_cast<unsigned long long>(freeBytes)); sendResponse(conn, 507, "application/json", msg); // 507 Insufficient Storage return; } - // Hand the source at most Content-Length of the buffered bytes, since a pipelined next request may follow the body and must not land in the file. - const size_t initial = initialLen < contentLen ? initialLen : contentLen; - UploadSource src{&conn, initialBody, initial, contentLen, - platform::millis() + kUploadHardMs}; - if (platform::fsWriteStream(path, &uploadPull, &src)) { - applyFileChanged(path); // the write succeeded, so what was built from it may be stale - sendResponse(conn, 200, "application/json", "{\"ok\":true}"); - } else { - sendResponse(conn, 500, "application/json", "{\"error\":\"write failed\"}"); - } + startUpload(conn, path, initialBody, initialLen, contentLen); } // See the header for WHY this exists and why it is whole-tree. Here is only the how. @@ -1344,7 +1335,7 @@ HttpServerModule::LeafHash* HttpServerModule::findLeaf(uint32_t pathHash) { } void HttpServerModule::baselineLeafHashes(bool everything) { - // Count the leaves and the modules, size the hashes and the patch slices exactly, then fill from scratch, since resize does not preserve; this runs on a resync, not each second. + // Count leaves and modules, size the hashes and patch slices exactly, then fill from scratch, since resize does not preserve; a resync, not each second. uint16_t n = 0, modules = 0; const MoonModule* last = nullptr; forEachStateLeaf([&](const MoonModule* mod, uint32_t, uint32_t, const char*, JsonSink&) { @@ -3010,7 +3001,7 @@ bool HttpServerModule::nextStreamFrame(int client) { return true; } -// Per client, the fragment in flight goes out a chunk at a time and the next is generated once it has left; core-0 only, so unlike the preview slot it needs no sender lease. +// Per client, the fragment in flight leaves a chunk at a time and the next is generated after it; core-0 only, so it needs no sender lease. void HttpServerModule::drainStateSend() { const size_t chunk = drainChunkBytes(); for (int i = 0; i < MAX_WS_CLIENTS; i++) diff --git a/src/core/system/HttpServerModule.h b/src/core/system/HttpServerModule.h index d62114275..05caecd4c 100644 --- a/src/core/system/HttpServerModule.h +++ b/src/core/system/HttpServerModule.h @@ -389,36 +389,37 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { platform::TcpConnection parked_[kParkedSlots]; uint32_t parkedAt_[kParkedSlots] = {}; - // A large /sd transfer resumes here across ticks, a bounded slice at a time, drainPreviewSend's shape; flash stays on the synchronous path below, small enough for one tick. - struct SdTransfer { + // Every upload, and every /sd download, resumes here across ticks, a bounded slice at a time, drainPreviewSend's shape: neither a large file nor a slow client holds the render loop. + struct FileTransfer { bool active = false; bool isUpload = false; platform::TcpConnection conn; platform::FsWriteStream wstream; // valid while isUpload platform::FsReadStream rstream; // valid while !isUpload size_t remaining = 0; // upload: body bytes still expected; download: bytes left to send - char buf[4096] = {}; // download only: one read chunk, drained across ticks by writeSome - size_t bufLen = 0; + char* buf = nullptr; // kTransferBufBytes from the claim to the finish, so an idle server holds none + size_t bufLen = 0; // download only: the read chunk, drained across ticks by writeSome size_t bufSent = 0; uint32_t lastActivityMs = 0; // a stall guard, not a size or time cap on the transfer itself + char path[160] = {}; // upload only: the file applyFileChanged announces once it commits }; - // Zero slots, not two, on a board without SD: std::array<T,0> is standard, constructs no SdTransfer at all, and every loop over it already runs zero times. - static constexpr int kSdTransferSlots = platform::hasSdCard ? 2 : 0; - static constexpr uint32_t kSdTransferStallMs = 10000; // no progress this long: abort, free the slot - static constexpr uint32_t kSdTransferTickBudgetMs = 3; // several chunks while storage/peer keep up, not one - std::array<SdTransfer, kSdTransferSlots> sdTransfers_{}; - /// A free slot for a new transfer, or null when every slot is busy. - SdTransfer* claimSdTransfer(); + static constexpr int kTransferSlots = 2; + static constexpr size_t kTransferBufBytes = 4096; + static constexpr uint32_t kTransferStallMs = 10000; // no progress this long: abort, free the slot + static constexpr uint32_t kTransferTickBudgetMs = 3; // several chunks while storage/peer keep up, not one + std::array<FileTransfer, kTransferSlots> transfers_{}; + /// A free slot with its buffer, or null once it has answered: 503 when both slots are busy, 507 when the buffer does not fit. + FileTransfer* claimTransfer(platform::TcpConnection& conn); /// Advance every active slot by one bounded chunk; called once per tick, the drainXxxSend's sibling. - void driveSdTransfers(); - /// One upload slot's bounded chunk: socket to card, then the stall guard. Returns whether real bytes moved. - bool driveSdUpload(SdTransfer& t, uint32_t now); - /// One download slot's bounded chunk: card to socket, then the stall guard. Returns whether real bytes moved. - bool driveSdDownload(SdTransfer& t, uint32_t now); - /// Close the stream (already done by the caller) and the connection, and free the slot. - void finishSdTransfer(SdTransfer& t, bool closeConn); + void driveTransfers(); + /// One upload slot's bounded chunk: socket to storage, then the stall guard. Returns whether real bytes moved. + bool driveUpload(FileTransfer& t, uint32_t now); + /// One download slot's bounded chunk: storage to socket, then the stall guard. Returns whether real bytes moved. + bool driveDownload(FileTransfer& t, uint32_t now); + /// Close the connection when asked, free the buffer and the slot; the caller closed the stream. + void finishTransfer(FileTransfer& t, bool closeConn); /// Close every active slot's stream and socket; release()'s own cleanup, since going out of scope frees neither. - void releaseSdTransfers(); + void releaseTransfers(); ClientMessageSink* clientSink_ = nullptr; // the producer's inbound-message sink (PreviewDriver) @xref{crossing-the-domain-boundary} @@ -615,12 +616,12 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster { const char* extraHeaders); /// One HLS artifact (playlist / segment) from /.hls/, video MIME + no-cache; flat names only. void serveHlsFile(platform::TcpConnection& conn, const char* name); - // Streams the remainder off the socket, so an upload of any size never needs one buffer. + // Checks an upload against its storage's limits, then streams it through a transfer slot, so an upload of any size never needs one buffer. void handleWriteFile(platform::TcpConnection& conn, const char* query, const char* initialBody, size_t initialLen, size_t contentLen); - // The /sd leg of handleWriteFile: claims a transfer slot and hands the rest to driveSdTransfers. - void handleWriteFileSd(platform::TcpConnection& conn, const char* path, - const char* initialBody, size_t initialLen, size_t contentLen); + // Claims a transfer slot for a checked upload, writes the bytes already buffered and hands the rest to driveTransfers. + void startUpload(platform::TcpConnection& conn, const char* path, + const char* initialBody, size_t initialLen, size_t contentLen); // One directory's children, single-level, which is what the lazy tree loads a node from. void serveDirListing(platform::TcpConnection& conn, const char* query); void handleMakeDir(platform::TcpConnection& conn, const char* query); // POST /api/dir?path= diff --git a/src/core/util/HostResolver.h b/src/core/util/HostResolver.h index cffcb6743..dd58ae39b 100644 --- a/src/core/util/HostResolver.h +++ b/src/core/util/HostResolver.h @@ -13,9 +13,9 @@ namespace mm { /// A cache that answers a host name's last known address at once, while one low-priority task looks names up. /// -/// The asking side runs on the render thread, so it only ever reads the cache: a lookup there would stall a frame for the resolver's timeout. -/// A new name answers `Waiting` until it first resolves; a name that stops resolving answers `Stale` with its last address. -/// Names are looked up again about once a minute, and the cache costs nothing until an owner with a name starts it. +/// The asking side runs on the render thread and only reads the cache, since a lookup there stalls a frame; a new name answers `Waiting`, a failing one `Stale`. +/// +/// A board with no worker task looks names up from the asking side instead, one non-blocking step per ask. class HostResolver { public: /// What the cache knows about a name. @@ -32,7 +32,7 @@ class HostResolver { Self& s = self(); if (s.slots) return; s.slots = new (std::nothrow) Slot[kSlots]; - if (s.slots && !s.testMode) platform::spawnPinnedTask(s.task, "hosts", &run, &s, 5120, 1, -1); + if (s.slots && !s.testMode) s.inlineSteps = !platform::spawnPinnedTask(s.task, "hosts", &run, &s, 5120, 1, -1); } /// The name's address into `ip` when it is Resolved or Stale; the first ask queues a lookup, and nothing here allocates. @@ -42,6 +42,7 @@ class HostResolver { LockGuard guard(s.lock); if (!guard) return State::Busy; const uint32_t now = platform::millis(); + if (s.inlineSteps) step(s, now); Slot* free = nullptr; for (uint8_t i = 0; i < kSlots; i++) { Slot& slot = s.slots[i]; @@ -61,16 +62,20 @@ class HostResolver { return State::Waiting; } - // For a test's scope, replace the platform lookup with an empty cache and no task, so the test drives the passes itself. + // For a test's scope, an empty cache and no task: a blocking resolve driven by passes, or a non-blocking step each lookup drives. struct LookupForTest { - explicit LookupForTest(bool (*resolve)(const char*, uint8_t*)) { use(resolve); } - ~LookupForTest() { use(&platform::resolveHost); } // so no later test inherits the fake + explicit LookupForTest(bool (*resolve)(const char*, uint8_t*)) { use(resolve, &platform::resolveHostStep, false); } + explicit LookupForTest(platform::LookupStep (*step)(const char*, uint8_t*)) { use(&platform::resolveHost, step, true); } + ~LookupForTest() { use(&platform::resolveHost, &platform::resolveHostStep, false); } // so no later test inherits the fake LookupForTest(const LookupForTest&) = delete; LookupForTest& operator=(const LookupForTest&) = delete; private: - static void use(bool (*resolve)(const char*, uint8_t*)) { + static void use(bool (*resolve)(const char*, uint8_t*), platform::LookupStep (*step)(const char*, uint8_t*), bool inlineSteps) { Self& s = self(); s.resolve = resolve; + s.resolveStep = step; + s.inlineSteps = inlineSteps; + s.inFlight = -1; s.testMode = true; ensureStarted(); for (uint8_t i = 0; i < kSlots; i++) s.slots[i] = Slot{}; @@ -101,6 +106,9 @@ class HostResolver { TryLock lock; platform::WorkerTask task; bool (*resolve)(const char*, uint8_t*) = &platform::resolveHost; + platform::LookupStep (*resolveStep)(const char*, uint8_t*) = &platform::resolveHostStep; + bool inlineSteps = false; ///< no worker task: lookup() drives the lookups + int8_t inFlight = -1; ///< the slot the inline lookup in flight belongs to bool testMode = false; }; @@ -123,27 +131,48 @@ class HostResolver { while (!s.lock.tryAcquire()) platform::yield(); // the asking side holds it for microseconds Slot& slot = s.slots[i]; const uint32_t now = platform::millis(); - // A name nobody asked for in ten minutes is freed rather than looked up forever. - if (slot.name[0] && now - slot.askedMs > kForgetMs) slot = Slot{}; - const bool due = slot.name[0] - && (!slot.looked || now - slot.lookedMs >= (slot.known ? kRefreshMs : kRetryMs)); + const bool lookUp = due(slot, now); std::memcpy(name, slot.name, sizeof(name)); s.lock.release(); - if (!due) continue; + if (!lookUp) continue; } uint8_t ip[4] = {}; const bool ok = s.resolve(name, ip); // blocking, with the cache free while (!s.lock.tryAcquire()) platform::yield(); Slot& slot = s.slots[i]; - if (std::strcmp(slot.name, name) == 0) { // the slot may have been reused meanwhile - slot.looked = true; - slot.lookedMs = platform::millis(); - if (ok) { std::memcpy(slot.ip, ip, 4); slot.known = true; slot.stale = false; } - else if (slot.known) slot.stale = true; - } + if (std::strcmp(slot.name, name) == 0) settle(slot, ok, ip, platform::millis()); // the slot may have been reused meanwhile s.lock.release(); } } + + /// One non-blocking step on the asking side, with the cache held: continue the lookup in flight, else start the next name due. + static void step(Self& s, uint32_t now) { + if (s.inFlight < 0) { + for (uint8_t i = 0; i < kSlots && s.inFlight < 0; i++) + if (due(s.slots[i], now)) s.inFlight = static_cast<int8_t>(i); + if (s.inFlight < 0) return; + } + Slot& slot = s.slots[s.inFlight]; + uint8_t ip[4] = {}; + const platform::LookupStep r = slot.name[0] ? s.resolveStep(slot.name, ip) : platform::LookupStep::Failed; + if (r == platform::LookupStep::Pending) return; + if (slot.name[0]) settle(slot, r == platform::LookupStep::Resolved, ip, now); + s.inFlight = -1; + } + + /// Whether a slot's name is due a lookup, freeing first a name nobody asked for in ten minutes rather than looking it up forever. + static bool due(Slot& slot, uint32_t now) { + if (slot.name[0] && now - slot.askedMs > kForgetMs) slot = Slot{}; + return slot.name[0] && (!slot.looked || now - slot.lookedMs >= (slot.known ? kRefreshMs : kRetryMs)); + } + + /// Record a lookup's answer: the address when it resolved, else the last known one marked stale. + static void settle(Slot& slot, bool ok, const uint8_t ip[4], uint32_t now) { + slot.looked = true; + slot.lookedMs = now; + if (ok) { std::memcpy(slot.ip, ip, 4); slot.known = true; slot.stale = false; } + else if (slot.known) slot.stale = true; + } }; } // namespace mm diff --git a/src/core/util/TeensyImage.h b/src/core/util/TeensyImage.h index 2ddac6ce8..c2febbc60 100644 --- a/src/core/util/TeensyImage.h +++ b/src/core/util/TeensyImage.h @@ -4,16 +4,9 @@ /// @{ /// The checks an uploaded image passes before it may overwrite a Teensy's only program: a Teensy 4 image, a MoonLight one, and for the firmware the board runs. /// -/// @moreinfo +/// A wrong image leaves the board offline until a USB flash, so the checks are pure byte reads, which a host test drives with synthetic and real images. /// -/// ## Pure, so a host test pins them -/// -/// A Teensy holds one program, so a wrong image copied over it leaves the board off the network until a USB flash: these checks are all that stands between an upload and that. -/// They read bytes alone, so they live here rather than in the platform file, and a host test drives them with synthetic and real images alike. -/// -/// ## The caller passes the mark -/// -/// The search takes its prefix from the caller's own mark, so the prefix occurs once in a firmware image and a literal here cannot be found in its place. +/// The mark's prefix comes from the caller's own mark, so it occurs once in a firmware image and a literal here cannot be found in its place. #include <cstddef> #include <cstdint> diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index e576b9915..7ba00083d 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1895,6 +1895,9 @@ bool resolveHost(const char* name, uint8_t ip[4]) { ::freeaddrinfo(res); return true; } + +// resolveHost runs on the resolver's worker task here, so this step answers Failed: it is reached only when that task could not be created. +LookupStep resolveHostStep(const char*, uint8_t*) { return LookupStep::Failed; } // Advertising is a device concern, so these are stubs; discovery itself is datagram presence and runs here too, testable over real loopback. // No update partition here, and the route guards on the capability, so this stub exists for compile coverage only. diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 5dc602cf4..4c2804507 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -1847,6 +1847,9 @@ bool resolveHost(const char* name, uint8_t ip[4]) { return true; } +// resolveHost runs on the resolver's worker task here, so this step answers Failed: it is reached only when that task could not be created. +LookupStep resolveHostStep(const char*, uint8_t*) { return LookupStep::Failed; } + // Advertise-only: discovery is datagram presence, each device broadcasting and listening on its own port. Keeping it off this protocol keeps the advertisement stable, since a query for a service this device hosts destabilizes its own. // Outbound HTTP request (plain HTTP, LAN, no TLS), see platform.h. A bounded blocking lwIP call, which the caller (HueDriver) runs off the render path on tick1s. Mirrors the desktop impl. diff --git a/src/platform/platform.h b/src/platform/platform.h index 21765d7f7..6a5d1d62b 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -748,6 +748,10 @@ void mdnsStop(); void mdnsShutdown(); /// Resolve a host name to its IPv4 address, `.local` by mDNS and any other by DNS; it blocks, so only a background task calls it. bool resolveHost(const char* name, uint8_t ip[4]); +/// How far a non-blocking lookup has come. +enum class LookupStep : uint8_t { Pending, Resolved, Failed }; +/// Start or continue resolving `name` without blocking, for a board with no worker task. One name at a time: a new name abandons the one in flight, and a start may allocate once. +LookupStep resolveHostStep(const char* name, uint8_t ip[4]); /// Store the DHCP hostname the next bring-up advertises; call it before ethInit or wifiStaInit. void setHostname(const char* name); diff --git a/src/platform/teensy/platform_teensy.cpp b/src/platform/teensy/platform_teensy.cpp index a20eaab3c..58be1ae1c 100644 --- a/src/platform/teensy/platform_teensy.cpp +++ b/src/platform/teensy/platform_teensy.cpp @@ -9,6 +9,7 @@ #include <QNEthernet.h> #include "qnethernet/qnethernet_dual_driver.h" #include <qnethernet/QNDNSClient.h> +#include <qnethernet/util/ip_tools.h> #include <lwip/opt.h> #include <cerrno> @@ -483,6 +484,42 @@ bool resolveHost(const char* name, uint8_t ip[4]) { return true; } +namespace { +enum class DnsState : uint8_t { Idle, InFlight, Found, Failed }; +// The one lookup in flight; lwIP answers it through the callback while Ethernet.loop runs in yield. +struct DnsStep { + char name[64] = {}; + DnsState state = DnsState::Idle; + uint8_t ip[4] = {}; + uint32_t startMs = 0; + uint32_t generation = 0; // tells an abandoned lookup's late answer from the current one +}; +DnsStep g_dns; +} // namespace + +LookupStep resolveHostStep(const char* name, uint8_t ip[4]) { + if (!name || !ethConnected() || std::strlen(name) >= sizeof(g_dns.name)) return LookupStep::Failed; + if (g_dns.state == DnsState::Idle || std::strcmp(g_dns.name, name) != 0) { + std::strcpy(g_dns.name, name); + const uint32_t generation = ++g_dns.generation; + g_dns.state = DnsState::InFlight; + g_dns.startMs = ::millis(); + const bool started = DNSClient::getHostByName(g_dns.name, [generation](const ip_addr_t* found) { + if (generation != g_dns.generation) return; + if (found) copyIp(IPAddress(qindesign::network::util::ip_addr_get_ip4_uint32(found)), g_dns.ip); + g_dns.state = found ? DnsState::Found : DnsState::Failed; + }, kResolveTimeoutMs); + if (!started) g_dns.state = DnsState::Failed; + } + // QNEthernet drops an answer that arrives after the timeout without calling back, so the timeout is read here. + if (g_dns.state == DnsState::InFlight && ::millis() - g_dns.startMs >= kResolveTimeoutMs) g_dns.state = DnsState::Failed; + if (g_dns.state == DnsState::InFlight) return LookupStep::Pending; + const bool found = g_dns.state == DnsState::Found; + if (found) std::memcpy(ip, g_dns.ip, 4); + g_dns.state = DnsState::Idle; + return found ? LookupStep::Resolved : LookupStep::Failed; +} + // --- WiFi: none on a Teensy --------------------------------------------------------------------- void wifiStaGetIPv4(uint8_t out[4]) MM_NONBLOCKING { std::memset(out, 0, 4); } diff --git a/teensy/patches/QNEthernet-recv-window-on-read.patch b/teensy/patches/QNEthernet-recv-window-on-read.patch new file mode 100644 index 000000000..2144a82dc --- /dev/null +++ b/teensy/patches/QNEthernet-recv-window-on-read.patch @@ -0,0 +1,38 @@ +Open the TCP receive window as the application reads, not as the stack copies: +acknowledging on copy let the peer send into a full buffer, which refused the +segment, dropped the ones behind it and cost a retransmit each. + +diff --git a/src/qnethernet/QNEthernetClient.cpp b/src/qnethernet/QNEthernetClient.cpp +index 080c35e..33780a4 100644 +--- a/src/qnethernet/QNEthernetClient.cpp ++++ b/src/qnethernet/QNEthernetClient.cpp +@@ -573,6 +573,7 @@ int EthernetClient::read() { + if (!isAvailable(*state)) { + return -1; + } ++ altcp_recved((*state)->pcb, 1); // Reopen the window by what was consumed + return (*state)->buf[(*state)->bufPos++]; + } + +@@ -617,6 +618,7 @@ int EthernetClient::read(uint8_t* const buf, const size_t size) { + (void)std::copy_n(&(*state)->buf.data()[(*state)->bufPos], actualSize, buf); + } + (*state)->bufPos += actualSize; ++ altcp_recved((*state)->pcb, static_cast<uint16_t>(actualSize)); // Reopen the window by what was consumed + return actualSize; + } + +diff --git a/src/qnethernet/internal/ConnectionManager.cpp b/src/qnethernet/internal/ConnectionManager.cpp +index 5befd32..011da4a 100644 +--- a/src/qnethernet/internal/ConnectionManager.cpp ++++ b/src/qnethernet/internal/ConnectionManager.cpp +@@ -212,7 +212,8 @@ err_t ConnectionManager::recvFunc(void* const arg, struct altcp_pcb* const tpcb, + } + } + +- altcp_recved(tpcb, p->tot_len); ++ // The window reopens as the application reads, in EthernetClient::read, so ++ // the peer never sends more than this buffer has room for. + (void)pbuf_free(p); + + return ERR_OK; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 3b7bc7432..d02bad042 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -55,6 +55,7 @@ add_executable(mm_tests unit/core/unit_HttpServerModule_connections.cpp unit/core/unit_HttpServerModule_sdcard.cpp unit/core/unit_HttpServerModule_subscribe.cpp + unit/core/unit_HttpServerModule_upload.cpp unit/core/unit_StateDocument.cpp unit/core/unit_ImprovFrame.cpp unit/core/unit_ImprovOpReassembler.cpp diff --git a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json index 33c59cbb8..b48891058 100644 --- a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json +++ b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json @@ -179,7 +179,7 @@ "min": 5, "max": 462, "n": 32, - "samples": [5, 462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11, 8, 11] + "samples": [462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11, 8, 11, 9] }, "last_updated": "2026-10-13" }, diff --git a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json index 5866729d6..5a24f899b 100644 --- a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json +++ b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json @@ -73,8 +73,8 @@ "p95": 387, "min": 1, "max": 555, - "n": 31, - "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8, 9, 7] + "n": 32, + "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8, 9, 7, 7] }, "last_updated": "2026-10-13" }, diff --git a/test/scenarios/light/scenario_Drivers_output_and_brightness.json b/test/scenarios/light/scenario_Drivers_output_and_brightness.json index 8299f8b56..17c66944a 100644 --- a/test/scenarios/light/scenario_Drivers_output_and_brightness.json +++ b/test/scenarios/light/scenario_Drivers_output_and_brightness.json @@ -107,7 +107,7 @@ "min": 6, "max": 17, "n": 32, - "samples": [7, 7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8, 7, 7] + "samples": [7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8, 7, 7, 7] }, "last_updated": "2026-10-13" }, diff --git a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json index 80cfde5df..5d7ecbcac 100644 --- a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json +++ b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json @@ -54,8 +54,8 @@ "p95": 7, "min": 1, "max": 7, - "n": 21, - "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7, 6, 6] + "n": 22, + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7, 6, 6, 7] }, "last_updated": "2026-10-13" } @@ -88,7 +88,7 @@ "min": 1, "max": 8, "n": 32, - "samples": [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8, 8, 7] + "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8, 8, 7, 7] }, "last_updated": "2026-10-13" }, @@ -118,7 +118,7 @@ "min": 6, "max": 46, "n": 32, - "samples": [7, 46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6, 7, 7] + "samples": [46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6, 7, 7, 9] }, "last_updated": "2026-10-13" }, diff --git a/test/scenarios/light/scenario_Effects_swap_while_running.json b/test/scenarios/light/scenario_Effects_swap_while_running.json index f1d048a07..3ea73fc73 100644 --- a/test/scenarios/light/scenario_Effects_swap_while_running.json +++ b/test/scenarios/light/scenario_Effects_swap_while_running.json @@ -86,7 +86,7 @@ "min": 1, "max": 2, "n": 32, - "samples": [1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-10-13" }, @@ -195,7 +195,7 @@ "min": 1, "max": 4, "n": 32, - "samples": [1, 1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-10-13" }, diff --git a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json index da152b463..1195f932b 100644 --- a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json +++ b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json @@ -68,7 +68,7 @@ "min": 6, "max": 81, "n": 32, - "samples": [7, 7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10, 8, 10] + "samples": [7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10, 8, 10, 8] }, "last_updated": "2026-10-13" }, @@ -176,7 +176,7 @@ "min": 4, "max": 32, "n": 32, - "samples": [7, 4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9, 6, 5] + "samples": [4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9, 6, 5, 7] }, "last_updated": "2026-10-13" }, @@ -280,7 +280,7 @@ "min": 6, "max": 19, "n": 32, - "samples": [7, 7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9, 8, 7] + "samples": [7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9, 8, 7, 7] }, "last_updated": "2026-10-13" }, @@ -394,7 +394,7 @@ "min": 6, "max": 18, "n": 32, - "samples": [7, 7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10, 8, 7] + "samples": [7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10, 8, 7, 7] }, "last_updated": "2026-10-13" }, @@ -514,7 +514,7 @@ "min": 6, "max": 31, "n": 32, - "samples": [7, 7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11, 7, 9] + "samples": [7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11, 7, 9, 7] }, "last_updated": "2026-10-13" }, @@ -630,7 +630,7 @@ "min": 3, "max": 25, "n": 32, - "samples": [4, 4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 7] + "samples": [4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 7, 5] }, "last_updated": "2026-10-13" }, @@ -762,7 +762,7 @@ "min": 3, "max": 20, "n": 32, - "samples": [4, 4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7, 4, 5] + "samples": [4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7, 4, 5, 5] }, "last_updated": "2026-10-13" }, diff --git a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json index b5f6146c3..a8b84d774 100644 --- a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json +++ b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json @@ -92,7 +92,7 @@ "min": 8, "max": 24, "n": 32, - "samples": [8, 8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10, 8, 9] + "samples": [8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10, 8, 9, 8] }, "last_updated": "2026-10-13" } @@ -136,7 +136,7 @@ "min": 13, "max": 37, "n": 32, - "samples": [15, 15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15, 15, 15] + "samples": [15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15, 15, 15, 15] }, "last_updated": "2026-10-13" } @@ -224,7 +224,7 @@ "min": 18, "max": 46, "n": 32, - "samples": [20, 21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20, 20, 20] + "samples": [21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20, 20, 20, 20] }, "last_updated": "2026-10-13" } @@ -264,7 +264,7 @@ "min": 121, "max": 225, "n": 32, - "samples": [123, 124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124, 125, 128] + "samples": [124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124, 125, 128, 122] }, "last_updated": "2026-10-13" } @@ -304,7 +304,7 @@ "min": 262, "max": 454, "n": 32, - "samples": [271, 267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272, 268, 265] + "samples": [267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272, 268, 265, 264] }, "last_updated": "2026-10-13" } @@ -341,7 +341,7 @@ "min": 51, "max": 100, "n": 32, - "samples": [56, 58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51, 56, 55] + "samples": [58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51, 56, 55, 55] }, "last_updated": "2026-10-13" } @@ -367,7 +367,7 @@ "min": 51, "max": 103, "n": 32, - "samples": [53, 53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53, 52, 52] + "samples": [53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53, 52, 52, 52] }, "last_updated": "2026-10-13" } @@ -403,7 +403,7 @@ "min": 48, "max": 281, "n": 32, - "samples": [52, 52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48, 52, 51] + "samples": [52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48, 52, 51, 51] }, "last_updated": "2026-10-13" } diff --git a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json index 207595bdc..dd0928205 100644 --- a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json +++ b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json @@ -82,7 +82,7 @@ "min": 6, "max": 23, "n": 32, - "samples": [7, 6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7] + "samples": [6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7, 8] }, "last_updated": "2026-10-13" }, @@ -191,7 +191,7 @@ "min": 9, "max": 22, "n": 32, - "samples": [10, 11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9, 10, 10] + "samples": [11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9, 10, 10, 11] }, "last_updated": "2026-10-13" }, @@ -307,7 +307,7 @@ "min": 6, "max": 14, "n": 32, - "samples": [6, 6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 10, 7] + "samples": [6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 10, 7, 7] }, "last_updated": "2026-10-13" }, diff --git a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json index 9ed2b514c..0cf62d67f 100644 --- a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json +++ b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json @@ -67,7 +67,7 @@ "min": 6, "max": 19, "n": 32, - "samples": [7, 7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7, 9, 7] + "samples": [7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7, 9, 7, 7] }, "last_updated": "2026-10-13" }, @@ -183,7 +183,7 @@ "min": 3, "max": 11, "n": 32, - "samples": [3, 3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 5, 3] + "samples": [3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 5, 3, 3] }, "last_updated": "2026-10-13" }, @@ -292,7 +292,7 @@ "min": 3, "max": 9, "n": 32, - "samples": [3, 3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4, 4, 3] + "samples": [3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4, 4, 3, 3] }, "last_updated": "2026-10-13" }, @@ -400,7 +400,7 @@ "min": 2, "max": 6, "n": 32, - "samples": [2, 2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2] + "samples": [2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3] }, "last_updated": "2026-10-13" }, @@ -514,7 +514,7 @@ "min": 3, "max": 11, "n": 32, - "samples": [3, 3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4, 4, 3] + "samples": [3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4, 4, 3, 3] }, "last_updated": "2026-10-13" }, @@ -630,7 +630,7 @@ "min": 3, "max": 9, "n": 32, - "samples": [3, 3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4, 4, 3] + "samples": [3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4, 4, 3, 3] }, "last_updated": "2026-10-13" }, @@ -744,7 +744,7 @@ "min": 6, "max": 20, "n": 32, - "samples": [6, 7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8, 8, 7] + "samples": [7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8, 8, 7, 7] }, "last_updated": "2026-10-13" }, diff --git a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json index 1d15f0e06..0621a8ab9 100644 --- a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json +++ b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json @@ -107,7 +107,7 @@ "min": 6, "max": 74, "n": 32, - "samples": [11, 6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9, 8, 7] + "samples": [6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9, 8, 7, 7] }, "last_updated": "2026-10-13" } @@ -175,7 +175,7 @@ "min": 6, "max": 115, "n": 32, - "samples": [7, 7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7] + "samples": [7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7] }, "last_updated": "2026-10-13" } diff --git a/test/unit/core/http_loopback.h b/test/unit/core/http_loopback.h new file mode 100644 index 000000000..078dd6fef --- /dev/null +++ b/test/unit/core/http_loopback.h @@ -0,0 +1,74 @@ +#pragma once + +// Loopback helpers the HTTP server tests share: connect a client and let the server accept it, tick until a reply arrives, read a close, make a payload. + +#include "core/system/HttpServerModule.h" +#include "platform/platform.h" + +#include <chrono> +#include <cstddef> +#include <cstdint> +#include <string> +#include <thread> + +namespace http_loopback { + +using CR = mm::platform::TcpConnection::ConnectResult; + +// Connects `client` to the server on `port`, then ticks once so the server accepts it. +inline bool connectAndAccept(mm::HttpServerModule& http, mm::platform::TcpConnection& client, uint16_t port) { + if (!client.connectStart("127.0.0.1", port)) return false; + CR r = CR::Pending; + for (int i = 0; i < 500 && r == CR::Pending; i++) { + r = client.connectPoll(); + if (r == CR::Pending) mm::platform::delayMs(1); + } + if (r != CR::Connected) return false; + mm::platform::delayMs(5); + http.tick20ms(); + return true; +} + +// Ticks the server, accumulating what the client reads, until it holds at least `wantAtLeast` bytes or `maxTicks` is spent. +inline std::string pumpUntil(mm::HttpServerModule& http, mm::platform::TcpConnection& client, + size_t wantAtLeast, int maxTicks = 3000) { + std::string out; + char buf[4096]; + for (int i = 0; i < maxTicks && out.size() < wantAtLeast; i++) { + http.tick20ms(); + const int n = client.read(reinterpret_cast<uint8_t*>(buf), sizeof(buf)); + if (n > 0) out.append(buf, static_cast<size_t>(n)); + else mm::platform::delayMs(1); + } + return out; +} + +// What a read answers after the server closes the socket: -1 while the close is in flight, then 0. +inline int readAfterClose(mm::platform::TcpConnection& client) { + char buf[32]; + int got = -1; + for (int ms = 0; ms < 200 && got == -1; ms++) { + got = client.read(reinterpret_cast<uint8_t*>(buf), sizeof(buf)); + if (got == -1) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + return got; +} + +// Connects `client`, posts `body` to `path` and returns the reply once its status line has arrived. +inline std::string postSmall(mm::HttpServerModule& http, mm::platform::TcpConnection& client, uint16_t port, + const char* path, const std::string& body) { + if (!connectAndAccept(http, client, port)) return {}; + const std::string req = std::string("POST /api/file?path=") + path + " HTTP/1.1\r\nContent-Length: " + + std::to_string(body.size()) + "\r\n\r\n" + body; + if (!client.write(reinterpret_cast<const uint8_t*>(req.data()), req.size())) return {}; + return pumpUntil(http, client, 12); +} + +// A deterministic byte pattern, longer than one 4 KB transfer chunk when `n` is, so an upload spans several ticks. +inline std::string pattern(size_t n) { + std::string s(n, '\0'); + for (size_t i = 0; i < n; i++) s[i] = static_cast<char>('A' + (i % 26)); + return s; +} + +} // namespace http_loopback diff --git a/test/unit/core/unit_HostResolver.cpp b/test/unit/core/unit_HostResolver.cpp index 31d8ef183..57553cb50 100644 --- a/test/unit/core/unit_HostResolver.cpp +++ b/test/unit/core/unit_HostResolver.cpp @@ -58,3 +58,45 @@ TEST_CASE("a host name that never resolves stays waiting, and costs no lookup un CHECK(mm::HostResolver::lookup("nowhere.lan", ip) == mm::HostResolver::State::Waiting); mm::platform::setTestNowMs(0); } + +namespace { +/// A non-blocking lookup that answers after `gStepsToAnswer` steps, as a DNS reply arrives some loops later. +int gSteps = 0; +int gStepsToAnswer = 3; +mm::platform::LookupStep fakeStep(const char* name, uint8_t* ip) { + if (++gSteps < gStepsToAnswer) return mm::platform::LookupStep::Pending; + gSteps = 0; + if (std::strcmp(name, "panel.lan") != 0) return mm::platform::LookupStep::Failed; + ip[0] = 10; ip[1] = 0; ip[2] = 0; ip[3] = 7; + return mm::platform::LookupStep::Resolved; +} +} // namespace + +TEST_CASE("on a board with no worker task, the asking side looks a name up one non-blocking step per ask") { + const mm::HostResolver::LookupForTest resolver(&fakeStep); + gSteps = 0; gStepsToAnswer = 3; + mm::platform::setTestNowMs(1000); + uint8_t ip[4] = {}; + CHECK(mm::HostResolver::lookup("panel.lan", ip) == mm::HostResolver::State::Waiting); // queued + CHECK(mm::HostResolver::lookup("panel.lan", ip) == mm::HostResolver::State::Waiting); // step 1: sent + CHECK(mm::HostResolver::lookup("panel.lan", ip) == mm::HostResolver::State::Waiting); // step 2: no reply yet + CHECK(mm::HostResolver::lookup("panel.lan", ip) == mm::HostResolver::State::Resolved); // step 3: the reply + CHECK(ip[3] == 7); + mm::platform::setTestNowMs(0); +} + +TEST_CASE("on a board with no worker task, a name that fails is retried only once it is due") { + const mm::HostResolver::LookupForTest resolver(&fakeStep); + gSteps = 0; gStepsToAnswer = 1; + mm::platform::setTestNowMs(1000); + uint8_t ip[4] = {}; + mm::HostResolver::lookup("nowhere.lan", ip); + mm::HostResolver::lookup("nowhere.lan", ip); // looked up, failed + gSteps = 0; gStepsToAnswer = 100; // a step now would count + mm::HostResolver::lookup("nowhere.lan", ip); + CHECK(gSteps == 0); // too soon to retry + mm::platform::setTestNowMs(7000); // past the retry interval + mm::HostResolver::lookup("nowhere.lan", ip); + CHECK(gSteps == 1); + mm::platform::setTestNowMs(0); +} diff --git a/test/unit/core/unit_HttpServerModule_sdcard.cpp b/test/unit/core/unit_HttpServerModule_sdcard.cpp index 3459a5e53..79e87b8f9 100644 --- a/test/unit/core/unit_HttpServerModule_sdcard.cpp +++ b/test/unit/core/unit_HttpServerModule_sdcard.cpp @@ -6,6 +6,7 @@ #include "doctest.h" #include "core/system/HttpServerModule.h" #include "platform/platform.h" +#include "http_loopback.h" #include <algorithm> #include <chrono> @@ -19,20 +20,8 @@ namespace { constexpr uint16_t kTestPort = 18091; -using CR = mm::platform::TcpConnection::ConnectResult; - -bool connectAndAccept(mm::HttpServerModule& http, mm::platform::TcpConnection& client) { - if (!client.connectStart("127.0.0.1", kTestPort)) return false; - CR r = CR::Pending; - for (int i = 0; i < 500 && r == CR::Pending; i++) { - r = client.connectPoll(); - if (r == CR::Pending) mm::platform::delayMs(1); - } - if (r != CR::Connected) return false; - mm::platform::delayMs(5); - http.tick20ms(); - return true; -} +using namespace http_loopback; + // Sends a POST of `payload` to `path`: the header, then the whole body. void postFile(mm::platform::TcpConnection& client, const char* path, const std::string& payload) { @@ -43,19 +32,6 @@ void postFile(mm::platform::TcpConnection& client, const char* path, const std:: REQUIRE(client.write(reinterpret_cast<const uint8_t*>(payload.data()), payload.size())); } -// Ticks the server, accumulating whatever the client reads, until `out` holds at least `wantAtLeast` bytes or `maxTicks` is spent; returns what was accumulated. -std::string pumpUntil(mm::HttpServerModule& http, mm::platform::TcpConnection& client, - size_t wantAtLeast, int maxTicks = 3000) { - std::string out; - char buf[4096]; - for (int i = 0; i < maxTicks && out.size() < wantAtLeast; i++) { - http.tick20ms(); - const int n = client.read(reinterpret_cast<uint8_t*>(buf), sizeof(buf)); - if (n > 0) out.append(buf, static_cast<size_t>(n)); - else mm::platform::delayMs(1); - } - return out; -} // Whether a reply holds a whole response: its headers and the Content-Length bytes of body after them. bool wholeResponse(const std::string& reply) { @@ -74,12 +50,6 @@ std::string request(mm::HttpServerModule& http, mm::platform::TcpConnection& cli return reply; } -// A deterministic byte pattern, long enough (> the 4096-byte per-tick chunk) to force several ticks of driveSdTransfers regardless of how fast loopback delivers the bytes. -std::string pattern(size_t n) { - std::string s(n, '\0'); - for (size_t i = 0; i < n; i++) s[i] = static_cast<char>('A' + (i % 26)); - return s; -} // Ticks with a millisecond between them, since Linux loopback delivers a write at once and macOS a moment later; a tick with nothing new is harmless. void tickWhileDelivering(mm::HttpServerModule& http, int ticks = 5) { @@ -89,20 +59,10 @@ void tickWhileDelivering(mm::HttpServerModule& http, int ticks = 5) { } } -// What a read answers after the server closes the socket: -1 while the close is in flight, then 0. -int readAfterClose(mm::platform::TcpConnection& client) { - char buf[32]; - int got = -1; - for (int ms = 0; ms < 200 && got == -1; ms++) { - got = client.read(reinterpret_cast<uint8_t*>(buf), sizeof(buf)); - if (got == -1) std::this_thread::sleep_for(std::chrono::milliseconds(1)); - } - return got; -} // Connects, declares a 10000-byte upload to `path` but sends only 100, then ticks so the slot claims it: in flight, never finished. void claimStuckUpload(mm::HttpServerModule& http, mm::platform::TcpConnection& client, const char* path) { - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); char req[256]; const int reqLen = std::snprintf(req, sizeof(req), "POST /api/file?path=%s HTTP/1.1\r\nContent-Length: 10000\r\n\r\n", path); @@ -115,7 +75,7 @@ void claimStuckUpload(mm::HttpServerModule& http, mm::platform::TcpConnection& c // Connects and GETs `path`, returning the response once at least `wantAtLeast` bytes have arrived. std::string getAndWait(mm::HttpServerModule& http, mm::platform::TcpConnection& client, const char* path, size_t wantAtLeast = 12) { - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); char req[160]; const int n = std::snprintf(req, sizeof(req), "GET /api/file?path=%s HTTP/1.1\r\n\r\n", path); REQUIRE(client.write(reinterpret_cast<const uint8_t*>(req), static_cast<size_t>(n))); @@ -190,7 +150,7 @@ TEST_CASE("an upload to /sd without a mounted card answers \"no card mounted\", http.port = kTestPort; http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const char* req = "POST /api/file?path=/sd/x.bin HTTP/1.1\r\nContent-Length: 2\r\n\r\nhi"; const std::string reply = request(http, client, req); @@ -207,7 +167,7 @@ TEST_CASE("an SD upload larger than one tick's chunk streams across several tick http.port = kTestPort; http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const std::string payload = pattern(10000); postFile(client, "/sd/big.bin", payload); @@ -243,7 +203,7 @@ TEST_CASE("an SD upload spanning several chunks drains in far fewer ticks than c http.port = kTestPort; http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const std::string payload = pattern(20000); // 5 chunks' worth, at the old one-chunk-per-tick rate postFile(client, "/sd/multi.bin", payload); @@ -268,7 +228,7 @@ TEST_CASE("an SD download larger than one tick's chunk streams across several ti http.port = kTestPort; http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const char* req = "GET /api/file?path=/sd/dl.bin HTTP/1.1\r\n\r\n"; REQUIRE(client.write(reinterpret_cast<const uint8_t*>(req), std::strlen(req))); @@ -326,13 +286,8 @@ TEST_CASE("an SD upload that stalls mid-transfer is closed and its slot frees fo for (int i = 0; i < 10; i++) http.tick20ms(); CHECK(readAfterClose(client) == 0); // closed, no response - // The slot is free again: a fresh, small upload still completes normally. mm::platform::TcpConnection client2; - REQUIRE(connectAndAccept(http, client2)); - const char* req2 = "POST /api/file?path=/sd/ok2.bin HTTP/1.1\r\nContent-Length: 2\r\n\r\nhi"; - REQUIRE(client2.write(reinterpret_cast<const uint8_t*>(req2), std::strlen(req2))); - const std::string reply = pumpUntil(http, client2, 12); - CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); + CHECK(postSmall(http, client2, kTestPort, "/sd/ok2.bin", "hi").rfind("HTTP/1.1 200", 0) == 0); // the slot is free again client2.close(); http.release(); @@ -363,7 +318,7 @@ TEST_CASE("an SD upload past the card's free space is refused with 507, immediat http.port = kTestPort; http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const std::string payload = pattern(2000); // bigger than the fictional card postFile(client, "/sd/toobig.bin", payload); @@ -383,7 +338,7 @@ TEST_CASE("an SD upload declaring more than FAT32's 4 GiB-1 ceiling is refused w http.port = kTestPort; http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const char* req = "POST /api/file?path=/sd/huge.bin HTTP/1.1\r\nContent-Length: 5000000000\r\n\r\n"; const std::string reply = request(http, client, req); @@ -407,7 +362,7 @@ TEST_CASE("DELETE /api/dir removes a file and a non-empty folder on the card, vi { mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const char* req = "DELETE /api/dir?path=/sd/lone.bin HTTP/1.1\r\n\r\n"; const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); @@ -417,7 +372,7 @@ TEST_CASE("DELETE /api/dir removes a file and a non-empty folder on the card, vi { mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const char* req = "DELETE /api/dir?path=/sd/tree HTTP/1.1\r\n\r\n"; const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); @@ -439,7 +394,7 @@ TEST_CASE("DELETE /api/dir on /sd itself is refused, leaving the card untouched" http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const char* req = "DELETE /api/dir?path=/sd HTTP/1.1\r\n\r\n"; const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 500", 12) == 0); @@ -458,7 +413,7 @@ TEST_CASE("DELETE /api/dir on the card root spelled with extra slashes is refuse http.setup(); for (const char* req : {"DELETE /api/dir?path=/sd// HTTP/1.1\r\n\r\n", "DELETE /api/dir?path=//sd HTTP/1.1\r\n\r\n"}) { mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) != 0); client.close(); @@ -475,7 +430,7 @@ TEST_CASE("an upload to a relative sd/ path gets the card's size limit") { http.port = kTestPort; http.setup(); mm::platform::TcpConnection client; - REQUIRE(connectAndAccept(http, client)); + REQUIRE(connectAndAccept(http, client, kTestPort)); const char* req = "POST /api/file?path=sd/rel.bin HTTP/1.1\r\nContent-Length: 9000000\r\n\r\n"; const std::string reply = request(http, client, req); CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 507", 12) == 0); diff --git a/test/unit/core/unit_HttpServerModule_upload.cpp b/test/unit/core/unit_HttpServerModule_upload.cpp new file mode 100644 index 000000000..c6ccfc59d --- /dev/null +++ b/test/unit/core/unit_HttpServerModule_upload.cpp @@ -0,0 +1,124 @@ +/// @module HttpServerModule +/// @also FileManagerModule +/// +/// An upload to flash moves through the card's transfer slots, a bounded slice per tick, so a slow client never holds the render loop; driven over loopback. + +#include "doctest.h" +#include "core/system/HttpServerModule.h" +#include "platform/platform.h" +#include "http_loopback.h" + +#include <chrono> +#include <cstdio> +#include <cstring> +#include <filesystem> +#include <string> +#include <thread> + +namespace { + +constexpr uint16_t kTestPort = 18093; // 18090 to 18092 belong to the connections, SD card and subscription tests +using namespace http_loopback; + + +// Sends a POST header declaring `declared` body bytes to `path`, then the first `body` bytes. +void postStart(mm::platform::TcpConnection& client, const char* path, size_t declared, const std::string& body) { + char req[256]; + const int reqLen = std::snprintf(req, sizeof(req), + "POST /api/file?path=%s HTTP/1.1\r\nContent-Length: %zu\r\n\r\n", path, declared); + REQUIRE(client.write(reinterpret_cast<const uint8_t*>(req), static_cast<size_t>(reqLen))); + if (!body.empty()) REQUIRE(client.write(reinterpret_cast<const uint8_t*>(body.data()), body.size())); +} + + + + +// A fresh flash root under a temp dir for one test. +struct FlashFixture { + char root[256]; + FlashFixture() { + static unsigned counter = 0; + std::snprintf(root, sizeof(root), "%s/mm_upload_test_%u", + std::filesystem::temp_directory_path().string().c_str(), counter++); + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + mm::platform::fsSetRoot(root); + } + // NOLINTNEXTLINE(bugprone-exception-escape) + ~FlashFixture() noexcept { + mm::platform::fsSetRoot(""); + std::error_code ec; + std::filesystem::remove_all(root, ec); + } +}; + +} // namespace + +TEST_CASE("a flash upload larger than one chunk streams across ticks and lands whole, answered once it commits") { + FlashFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client, kTestPort)); + + const std::string payload = pattern(20000); + postStart(client, "/big.bin", payload.size(), payload); + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 200", 12) == 0); + + char back[20001]; // fsRead null-terminates + CHECK(mm::platform::fsRead("/big.bin", back, sizeof(back)) == static_cast<int>(payload.size())); + CHECK(std::memcmp(back, payload.data(), payload.size()) == 0); + + client.close(); + http.release(); +} + +// The bug this pins: a flash upload drained its whole body in one tick, so a slow client held the render loop past the 12 s watchdog and rebooted the board. +TEST_CASE("a flash upload whose client stalls never holds a tick, and once the stall guard trips leaves no file and frees its slot") { + FlashFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client, kTestPort)); + postStart(client, "/stuck.bin", 10000, pattern(100)); // declares 10000 bytes, sends 100, then goes silent + + const auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < 20; i++) { + http.tick20ms(); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + const auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start); + CHECK(elapsed.count() < 1000); // twenty ticks with a silent client: nothing waits on it + + struct Clock { ~Clock() { mm::platform::setTestNowMs(0); } } clock; + mm::platform::setTestNowMs(mm::platform::millis() + 60000); // well past the stall guard + for (int i = 0; i < 10; i++) http.tick20ms(); + CHECK(readAfterClose(client) == 0); // closed, no response + CHECK(mm::platform::fsSize("/stuck.bin") < 0); // the partial file never committed + + mm::platform::TcpConnection client2; + CHECK(postSmall(http, client2, kTestPort, "/ok.bin", "hi").rfind("HTTP/1.1 200", 0) == 0); // the slot is free again + + client2.close(); + http.release(); +} + +TEST_CASE("a flash upload over the flash size bound is refused with 413 before any byte lands") { + FlashFixture fix; + mm::HttpServerModule http; + http.port = kTestPort; + http.setup(); + mm::platform::TcpConnection client; + REQUIRE(connectAndAccept(http, client, kTestPort)); + + postStart(client, "/huge.bin", 300 * 1024, ""); + const std::string reply = pumpUntil(http, client, 12); + CHECK(std::strncmp(reply.c_str(), "HTTP/1.1 413", 12) == 0); + CHECK(mm::platform::fsSize("/huge.bin") < 0); + + client.close(); + http.release(); +} From 4d3ed5b6def836ac7696047709efd740ab7f31da Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Wed, 14 Oct 2026 13:39:05 +0200 Subject: [PATCH 2/4] Network streams arrive whole: receive task, paced send, board tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lights streamed over Art-Net, E1.31 or DDP now arrive whole far more often: Network Receive drains packets as they come and reports packets and frames per second, and Network Send spaces its packets so access points and Ethernet receivers keep up. A board receiving a stream no longer loses its network to a stuck latch, and a new mapped_by setting takes frames the sender already mapped (xLights, FSEQ, Network Send). KPI: 256lights | Desktop:2303KB | tick:7/8/7/2/8/7/268/7/7/11us(FPS:142857/125000/142857/500000/125000/142857/3731/142857/142857/90909) | src:325(84036) | test:240(55336) | functions-over:353 Commit: src +504/-147 lines | test +4779/-8 lines | code findings 1632 -> 1629 (blocking call on the render path -2, large stack frame -1) | duplicated lines 3656 -> 3654 (-2) **Core** - TryLock releases with an exchange: on an ESP32 with PSRAM a plain store raced the emulated read-modify-write and left the latch set with no owner, which froze the receive task and took the WiFi stack down with it. - Platform: UdpSocket::waitReadable (select on desktop and ESP32, none on the Teensy) and sleepUs on a worker task (a one-shot esp_timer on the ESP32), so a task waits on sockets and paces below the scheduler tick without busy-waiting. - CONFIG_LWIP_UDP_RECVMBOX_SIZE 6 -> 32 on every board; P4 Ethernet DMA 30 receive buffers of 1600 bytes, transmit kept at 10 (30 rebooted a sending P4). - Teensy: an in-flight host-name lookup is abandoned on an Ethernet drop, so a reconnect resolves afresh. **Light domain** - Network Receive: a receive task on core 1, mapped_by (receiver or sender) with the path in the status ("one copy" or "mapped twice"), read-only packets_per_s and frames_per_s per second of elapsed time. - MappingLUT::gatherFromPhysical places a sender-mapped frame, 7.3 -> 4.5 ms at 12,288 lights on the S3. - Network Send: packet_gap (default 500 us) paces a frame's packets from a send task, clamped to fit the fps period, with a backoff on a full transmit queue; a gap of 0 or a board without tasks sends the burst from the tick. - DdpPacket names the push flag, and its parser reports it. **Scripts/MoonDeck** - run_live_scenario: --board NAME=HOST for scenarios over several boards, steps marked with a device, read values on measure steps, observations keyed by the board combination, every board restored. **Tests** - Receive: sender and receiver mapping on serpentine, 3D and identity layouts, the status path, the counters and their per-second rate. - Send: a gap spaces the packets from a task, a gap of 0 bursts from the tick, a long gap fits the fps period, a correction that grows the buffer under a paced send. - Scenarios: scenario_NetworkReceive_two_boards (sizes, layouts, mapping, layers, protocols) and scenario_NetworkSend_one_sender_many_receivers, run on S3 WiFi and P4 Ethernet in all four pairings and on the Teensy. **Docs/CI** - Cards for mapped_by, the counters and packet_gap; testing.md on several boards; the Streams plan records where it stands, G5 (sender mapping skips the board's mapping), the channel-data sink FSEQ shares, and a backlog from the measurements; backlog-light gains the identity correction. - CLAUDE.md: the contributed pull request size rule, which removes nothing since it states a limit the review process already implied; "Each check runs once per commit" as the gate rule's headline. **Reviews** - 👾 corrected_ reallocated under the running send task: done, resizeCorrected stops and restarts it; regression test added. - 👾 waits reachable from non-blocking ticks: done, the pause and backoff live only in sendFrame<true>, the lock wait only in drainSockets<true>. - 👾 single-core comments contradicted the platform: done. - 👾 mangled include comment: done. - 👾 torn destination address across cores: done, no refresh while the task sends. - 👾 rate window not reset on prepare: done. - 👾 mapped_by without an enum: done, MappedBy. - 👾 two paragraphs run together in testing.md: done. - 👾 dashes in edited files: done, an em-dash in a test name and six ranges in drivers.md. - 👾 the push flag read beside the parser, a stale drain-bound name: done, the parser reports it; kMaxPacketsPerDrain. - 👾 effectiveGap arithmetic: done, the same chunk as sendFrame, the bound commented. - 👾 the passthrough send path undocumented: done. - 👾 the PR-size rule names no subtraction and the stacked-PR sentence had two homes: the rule removes nothing (said above); the plan now links CLAUDE.md. - 👾 a fixed wait in a loopback test: done, it polls on the last packet landing. - 🐇 Teensy lookup survives an Ethernet drop: done. - 🐇 MidiFrame loses repeated hits of one pitch: done, ordered events, tested with two cycles. - 🐇 ToF read could stall the render task: done, a measured read is now a prerequisite. - 🐇 G1 inverts modifiers too: done in the plan; the code inverts the whole mapping, so G5 (next commit) inverts the layout's mapping alone, with rotated and mirrored tests. - Gates: check_prose, check_docgen, check_code and two rate tests failed on the first run (long sentences, over-wide comments, test clones, the rate window against the test clock); fixed and rerun, all below baseline. ESP32 classic, S3 and P4 rebuilt and fresh; the Teensy built and verified on the bench. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 4 +- docs/moonmodules/light/drivers.md | 15 +- docs/moonmodules/light/effects.md | 5 +- docs/reference/metrics/code.md | 28 +- docs/reference/metrics/docgen.md | 78 +- docs/reference/metrics/prose.md | 4 +- docs/reference/metrics/repo-health.json | 110 +- docs/reference/metrics/repo-health.md | 162 +- docs/reference/testing.md | 10 + docs/work/future/backlog-light.md | 6 + ...61013 - Audio reactive live performance.md | 6 +- ...013 - Streams, every frame from outside.md | 87 +- esp32/sdkconfig.defaults | 3 + esp32/sdkconfig.defaults.esp32p4rev1-eth | 25 +- moondeck/MoonDeck.md | 1 + moondeck/scenario/run_live_scenario.py | 150 +- src/core/util/TryLock.h | 20 +- src/light/drivers/NetworkSendDriver.h | 208 +- src/light/effects/NetworkReceiveEffect.h | 273 +- src/light/layers/MappingLUT.h | 59 +- src/light/util/DdpPacket.h | 9 +- src/platform/desktop/platform_desktop.cpp | 26 + src/platform/esp32/platform_esp32.cpp | 18 + src/platform/esp32/platform_esp32_worker.cpp | 23 + src/platform/platform.h | 4 + src/platform/teensy/platform_teensy.cpp | 11 +- .../scenario_NetworkReceive_two_boards.json | 3826 +++++++++++++++++ ...NetworkSend_one_sender_many_receivers.json | 676 +++ test/unit/core/http_loopback.h | 2 +- test/unit/core/unit_platform_worker.cpp | 10 +- test/unit/light/unit_NetworkReceiveEffect.cpp | 182 + ...nit_NetworkSendDriver_no_alloc_in_loop.cpp | 4 +- .../light/unit_NetworkSendDriver_packet.cpp | 87 + 33 files changed, 5738 insertions(+), 394 deletions(-) create mode 100644 test/scenarios/device/scenario_NetworkReceive_two_boards.json create mode 100644 test/scenarios/device/scenario_NetworkSend_one_sender_many_receivers.json diff --git a/CLAUDE.md b/CLAUDE.md index 01a3405e3..e32d72a12 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 and generated files (metrics, images, scenario observations) not counted. 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. A larger topic lands in steps, each its own pull request based on main. 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/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 11ba76a8e..df72867e4 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 <img src="../../assets/light/drivers/RmtLedDriver.png" width="300" alt="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) <img src="../../assets/light/drivers/NetworkSendDriver.png" width="300" alt="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 bursts, for wired receivers. [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..29b894c5e 100644 --- a/docs/moonmodules/light/effects.md +++ b/docs/moonmodules/light/effects.md @@ -1337,10 +1337,13 @@ Detail: [technical](moxygen/DemoReelEffect.md) <img src="../../assets/light/effects/NetworkReceiveEffect.gif" width="300" alt="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 on a layout listed as wired, 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) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index 20b47b93d..b6dd4c1f1 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,7 +4,7 @@ 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. -**1632 finding(s)** across 324 file(s). 2.66% of all lines are duplicated, 3656 lines. +**1629 finding(s)** across 324 file(s). 2.65% of all lines are duplicated, 3654 lines. ## By rule @@ -12,12 +12,12 @@ Every function, block and file over a limit the [coding standards](../../contrib |---|---:| | duplicated block | 754 | | complex function | 301 | -| blocking call on the render path | 193 | +| 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,7 +45,7 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | Area | Findings | |---|---:| | `test` | 560 | -| `light` | 417 | +| `light` | 414 | | `core` | 368 | | `platform` | 174 | | `ui` | 80 | @@ -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 | @@ -128,7 +128,7 @@ The next function worth simplifying is at the top of each file's list. | `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<uint8_t*>(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 | @@ -258,7 +257,7 @@ 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 760-768, also in src/platform/esp32/platform_esp32_usb_midi.cpp:254`, duplicated block 9 | +| `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 | @@ -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:20`, 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 | @@ -293,7 +292,7 @@ The next function worth simplifying is at the top of each file's list. | `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 | +| `moondeck/scenario/run_live_scenario.py` | 1 | `run_live_scenario.py`, large file 1789 | | `mooninstaller/install-orchestrator.js` | 1 | `install-orchestrator.js`, large file 1386 | | `mooninstaller/install.js` | 1 | `install.js`, large file 1553 | | `src/core/module/Control.h` | 1 | `sanitizeHostname`, complex function 16 | @@ -324,6 +323,7 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/GEQEffect.h` | 1 | `GEQEffect::for`, complex function 19 | | `src/light/effects/LavaLampEffect.h` | 1 | `lines 68-78, also in src/light/effects/SpiralEffect.h:79`, duplicated block 11 | | `src/light/effects/MovingHeadEffect.h` | 1 | `MovingHeadEffect::shape`, complex function 11 | +| `src/light/effects/NetworkReceiveEffect.h` | 1 | `NetworkReceiveEffect::drainSockets<false> in tick`, blocking call on the render path 1 | | `src/light/effects/RadialSpectrumEffect.h` | 1 | `RadialSpectrumEffect::for`, deeply nested 4 | | `src/light/effects/SineEffect.h` | 1 | `SineEffect::for`, deeply nested 4 | | `src/light/effects/SphereMoveEffect.h` | 1 | `SphereMoveEffect::for`, deeply nested 4 | @@ -349,8 +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:760`, duplicated block 9 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1445 | +| `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 1449 | | `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 20-41, 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 7b6c0f1b0..f497c7413 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 **3734 warning(s)** across 433 page(s). +**0 error(s)** and **3723 warning(s)** across 434 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. +**5505 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. @@ -17,11 +17,11 @@ The split is temporary. It stages the sweep rather than ranking the two kinds of | Rule | Errors | Warnings | |---|---:|---:| | over-wide comment lines | 0 | 1567 | -| split paragraphs | 0 | 1277 | -| appendix sections nothing refers to | 0 | 355 | +| split paragraphs | 0 | 1274 | +| appendix sections nothing refers to | 0 | 349 | | multi-line comment blocks | 0 | 311 | | over-long sentences | 0 | 81 | -| over-wide doc lines | 0 | 76 | +| over-wide doc lines | 0 | 74 | | 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 | 2031 | +| `(tests, no card)` | 0 | 2029 | | `platform/index.md` | 0 | 658 | -| `light/effects.md` | 0 | 251 | +| `light/effects.md` | 0 | 248 | | `core/system.md` | 0 | 136 | | `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 **2031 warning(s)** across 163 file(s). +**0 error(s)** and **2029 warning(s)** across 165 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 | *153 more warning files, 1282 findings* | | +| 1-44 each | *155 more warning files, 1280 findings* | | -By rule, warnings: 1351 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: 1351 over-wide comment lines, 253 split paragraphs, 196 multi-line comment blocks, 80 over-long sentences, 74 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 **248 warning(s)** across 69 file(s). | Findings | File | | |---:|---|---| @@ -104,9 +104,9 @@ 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 | *59 more warning files, 185 findings* | | -By rule, warnings: 170 split paragraphs, 81 appendix sections nothing refers to. +By rule, warnings: 169 split paragraphs, 79 appendix sections nothing refers to. ### core/system.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..09040a468 100644 --- a/docs/reference/metrics/prose.md +++ b/docs/reference/metrics/prose.md @@ -9,12 +9,12 @@ 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). +**2858 finding(s)** across 364 file(s). | rule | findings | |---|---| | `MoonLight.EmDash` | 1461 | -| `MoonLight.Weasel` | 676 | +| `MoonLight.Weasel` | 675 | | `MoonLight.SentenceLength` | 426 | | `MoonLight.Temporal` | 211 | | `MoonLight.Spelling` | 42 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index ea20c5343..67cd21fb9 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,10 +1,10 @@ { - "commit": "925757c3", + "commit": "c2cf6a4b", "flash": { - "esp32s3-n16r8": 2437888, - "esp32": 2240864, - "esp32p4rev1-eth": 2373392, - "esp32p4rev1-eth-wifi": 2523840, + "esp32s3-n16r8": 2443600, + "esp32": 2249040, + "esp32p4rev1-eth": 2380544, + "esp32p4rev1-eth-wifi": 2623840, "esp32s3-n8r8": 2437792, "esp32s31": 2744032, "esp32-16mb": 2356048, @@ -14,31 +14,31 @@ "esp32p4rev3-eth": 2231792, "esp32s3-zero": 2224816, "esp32-pico": 2107168, - "desktop-macos": 2357000, + "desktop-macos": 2358536, "desktop-windows": 1925120, "esp32c3": 2335072, "desktop-linux": 2548000, "esp32s3-n32r16": 2438176, - "teensy41": 1267692 + "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" + "teensy41": "2026-10-14" }, "perf": { "esp32": { @@ -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, @@ -776,53 +822,53 @@ } }, "loc": { - "core": 30147, - "light": 31265, - "platform": 21509, + "core": 30139, + "light": 31542, + "platform": 21589, "ui": 12471, - "test": 67552, - "moondeck": 31292 + "test": 67801, + "moondeck": 31404 }, "comments": { "core": { - "lines": 6446, + "lines": 6438, "ratio": 0.237 }, "light": { - "lines": 7512, - "ratio": 0.27 + "lines": 7542, + "ratio": 0.268 }, "platform": { - "lines": 4159, - "ratio": 0.217 + "lines": 4167, + "ratio": 0.216 }, "ui": { "lines": 3471, "ratio": 0.295 }, "test": { - "lines": 7131, + "lines": 7150, "ratio": 0.122 }, "moondeck": { - "lines": 4933, - "ratio": 0.18 + "lines": 4941, + "ratio": 0.179 } }, "tests": { - "cases": 2523, - "scenarios": 30 + "cases": 2535, + "scenarios": 32 }, "docs": { "md_files": 160, - "md_lines": 33946, + "md_lines": 33983, "plans_files": 50, - "backlog_lines": 3387, + "backlog_lines": 3393, "lessons_lines": 532, - "claude_md_lines": 285 + "claude_md_lines": 287 }, "complexity": { - "functions": 4996, + "functions": 5009, "over_threshold": 353, "worst_ccn": 128 } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 7a208e469..5b8a9c44c 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `925757c3`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `c2cf6a4b`. 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,188 KB | +127.2 KB (+6.2%) | +| flash desktop-macos | 1,963 KB | 2,303 KB | +340.2 KB (+17.3%) | +| flash esp32 | 2,061 KB | 2,196 KB | +135.1 KB (+6.6%) | | 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,318 KB | +296.0 KB (+14.6%) | -| flash esp32p4rev1-eth-wifi | 2,304 KB | 2,465 KB | +161.1 KB (+7.0%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,325 KB | +303.0 KB (+15.0%) | +| 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,381 KB | +269.3 KB (+12.8%) | +| flash esp32s3-n16r8 | 2,111 KB | 2,386 KB | +274.8 KB (+13.0%) | | flash esp32s3-n32r16 | - | 2,381 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,238 KB | new | +| 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,147 | +7,848 (+35.2%) | -| lines light | 31,054 | 31,265 | +211 (+0.7%) | -| lines platform | 16,798 | 21,509 | +4,711 (+28.0%) | +| lines core | 22,299 | 30,139 | +7,840 (+35.2%) | +| lines light | 31,054 | 31,542 | +488 (+1.6%) | +| lines platform | 16,798 | 21,589 | +4,791 (+28.5%) | | lines ui | 11,394 | 12,471 | +1,077 (+9.5%) | -| lines test | 57,759 | 67,552 | +9,793 (+17.0%) | -| lines moondeck | 29,325 | 31,292 | +1,967 (+6.7%) | -| unit tests | 2,120 | 2,523 | +403 (+19.0%) | -| scenarios | 21 | 30 | +9 (+42.9%) | +| lines test | 57,759 | 67,801 | +10,042 (+17.4%) | +| lines moondeck | 29,325 | 31,404 | +2,079 (+7.1%) | +| unit tests | 2,120 | 2,535 | +415 (+19.6%) | +| 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 (+80 B) ⚠ | - | - | yes | -| desktop-windows | 1,880 KB | - | - | **STALE 13d** | -| esp32 | 2,188 KB (−110 KB) ✓ | 2,496 KB | 88% | yes | -| esp32-16mb | 2,301 KB (+3 KB) ⚠ | 4,096 KB | 56% | yes | -| 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,303 KB (+2 KB) ⚠ | - | - | yes | +| desktop-windows | 1,880 KB | - | - | **STALE 14d** | +| esp32 | 2,196 KB (+8 KB) ⚠ | 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,318 KB (+9 KB) ⚠ | 4,096 KB | 57% | yes | -| 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,381 KB (−896 B) ✓ | 4,096 KB | 58% | yes | -| esp32s3-n32r16 | 2,381 KB (+176 B) ⚠ | 4,096 KB | 58% | yes | -| esp32s3-n8r8 | 2,381 KB (−992 B) ✓ | 3,072 KB | 77% | yes | -| esp32s3-zero | 2,173 KB (−1 KB) ✓ | 2,496 KB | 87% | yes | -| esp32s31 | 2,680 KB (+48 KB) ⚠ | 4,096 KB | 65% | yes | +| esp32c3 | 2,280 KB | 2,496 KB | 91% | carried 4d | +| esp32p4rev1-eth | 2,325 KB (+7 KB) ⚠ | 4,096 KB | 57% | yes | +| esp32p4rev1-eth-wifi | 2,562 KB (+98 KB) ⚠ | 4,096 KB | 63% | yes | +| esp32p4rev3-eth | 2,179 KB | 4,096 KB | 53% | carried 1d | +| esp32s3-n16r8 | 2,386 KB (+6 KB) ⚠ | 4,096 KB | 58% | yes | +| esp32s3-n32r16 | 2,381 KB | 4,096 KB | 58% | carried 1d | +| 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,238 KB (+1 KB) ⚠ | 3,456 KB | 36% | yes | +| teensy41 | 1,242 KB (+4 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,25 +65,17 @@ Against the release `v6.0.0`, the fixed point a user compares with; the tables b | Target | Net | New | Removed | Grown | Shrunk | Other | Since | |---|---:|---:|---:|---:|---:|---:|---| -| esp32 | -109.6 KB | +28.6 KB | -98.1 KB | +11.9 KB | -30.0 KB | -21.8 KB | `925757c3` | -| esp32-16mb | +2.7 KB | +27.4 KB | -11.4 KB | +11.7 KB | -25.2 KB | +0.2 KB | `925757c3` | -| esp32p4rev1-eth | +9.0 KB | +34.2 KB | -15.8 KB | +15.7 KB | -25.1 KB | -0.1 KB | `925757c3` | -| esp32s3-n16r8 | -0.9 KB | +3.1 KB | -3.4 KB | +1.3 KB | -1.9 KB | -0.0 KB | `925757c3` | -| esp32s3-n32r16 | +0.2 KB | +0.3 KB | +0.0 KB | +0.2 KB | -0.3 KB | -0.0 KB | `925757c3` | -| esp32s3-n8r8 | -1.0 KB | +2.2 KB | -2.2 KB | +0.6 KB | -1.6 KB | +0.1 KB | `925757c3` | -| esp32s3-zero | -1.0 KB | +2.2 KB | -2.2 KB | +0.6 KB | -1.6 KB | +0.1 KB | `925757c3` | -| esp32s31 | +48.1 KB | +69.9 KB | -19.8 KB | +16.6 KB | -25.5 KB | +7.0 KB | `925757c3` | +| esp32 | +8.0 KB | +6.4 KB | -0.1 KB | +3.2 KB | -1.5 KB | -0.1 KB | `c2cf6a4b` | +| esp32p4rev1-eth | +7.0 KB | +5.7 KB | +0.0 KB | +2.4 KB | -1.1 KB | -0.1 KB | `c2cf6a4b` | +| esp32p4rev1-eth-wifi | +97.7 KB | +100.2 KB | -14.4 KB | +8.8 KB | -6.5 KB | +9.6 KB | `c2cf6a4b` | +| esp32s3-n16r8 | +5.6 KB | +5.0 KB | -0.1 KB | +2.7 KB | -1.8 KB | -0.0 KB | `c2cf6a4b` | Largest moves: -- **esp32**: `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` -3,419 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` -2,866 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` -2,644 B, `f_mkfs` -2,321 B, `mm::ui::appJs` +2,258 B -- **esp32-16mb**: `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` -3,419 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` -2,866 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` -2,644 B, `mm::ui::appJs` +2,258 B, `mm::HttpServerModule::serveScriptCatalog(mm::platform::TcpCo` -2,105 B -- **esp32p4rev1-eth**: `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` -3,400 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` -2,584 B, `mm::HttpServerModule::serveScriptCatalog(mm::platform::TcpCo` -2,466 B, `mm::moonlive::compileSource(char const*, mm::moonlive::Built` -2,376 B, `mm::ui::appJs` +2,258 B -- **esp32s3-n16r8**: `mm::HttpServerModule::pollWledStateFromWebSockets()` -1,351 B, `_ZN2mm16HttpServerModule14visitOwnLeavesIRZNS0_18baselineLea` -824 B, `mm::HttpServerModule::pollControlClient(int)` +572 B, `mm::HttpServerModule::writeTreeFragment(mm::JsonSink&, mm::H` -399 B, `mm::HttpServerModule::onTextFrame(int, char const*)` +394 B -- **esp32s3-n32r16**: `void mm::detail::blendMapped<mm::detail::CopyMix>(mm::Buffer` +283 B, `mm::blendMap(mm::Buffer const&, mm::Buffer&, mm::MappingLUT ` -128 B, `mm::moonlive::(anonymous namespace)::Parser::structValueAt()` -98 B, `mm::ui::appJs` -71 B, `mm::PwmLightDriver::release()` +67 B -- **esp32s3-n8r8**: `mm::HttpServerModule::pollWledStateFromWebSockets()` -1,351 B, `_ZN2mm16HttpServerModule14visitOwnLeavesIRZNS0_18baselineLea` -824 B, `mm::HttpServerModule::pollControlClient(int)` +592 B, `mm::HttpServerModule::writeTreeFragment(mm::JsonSink&, mm::H` -399 B, `mm::HttpServerModule::onTextFrame(int, char const*)` +398 B -- **esp32s3-zero**: `mm::HttpServerModule::pollWledStateFromWebSockets()` -1,335 B, `_ZN2mm16HttpServerModule14visitOwnLeavesIRZNS0_18baselineLea` -824 B, `mm::HttpServerModule::pollControlClient(int)` +572 B, `mm::HttpServerModule::writeTreeFragment(mm::JsonSink&, mm::H` -399 B, `mm::HttpServerModule::onTextFrame(int, char const*)` +394 B -- **esp32s31**: `mm::SdCardModule::defineControls()` -3,496 B, `spicommon_bus_initialize_io` +3,402 B, `mm::moonlive::(anonymous namespace)::Parser::parseCall(unsig` -3,400 B, `mm::moonlive::(anonymous namespace)::Parser::parsePrimary()` -2,584 B, `mm::HttpServerModule::serveScriptCatalog(mm::platform::TcpCo` -2,506 B +- **esp32**: `void mm::NetworkReceiveEffect::drainSockets<true>()` +1,739 B, `void mm::NetworkReceiveEffect::drainSockets<false>()` +1,735 B, `void mm::NetworkSendDriver::sendFrame<true>(unsigned char co` +1,289 B, `mm::NetworkReceiveEffect::tick()` -571 B, `mm::NetworkSendDriver::resizeCorrected()` +471 B +- **esp32p4rev1-eth**: `void mm::NetworkReceiveEffect::drainSockets<true>()` +1,416 B, `void mm::NetworkSendDriver::sendFrame<true>(unsigned char co` +1,374 B, `void mm::NetworkReceiveEffect::drainSockets<false>()` +1,290 B, `mm::NetworkReceiveEffect::tick()` -632 B, `mm::NetworkSendDriver::resizeCorrected()` +386 B +- **esp32p4rev1-eth-wifi**: `spicommon_bus_initialize_io` +3,128 B, `mm::HttpServerModule::serveScriptCatalog(mm::platform::TcpCo` -2,466 B, `f_mkfs` +2,388 B, `mm::platform::mountCard(mm::platform::SdPinConfig const&, bo` +2,186 B, `spi_bus_add_device` +2,038 B +- **esp32s3-n16r8**: `void mm::NetworkSendDriver::sendFrame<true>(unsigned char co` +1,293 B, `void mm::NetworkReceiveEffect::drainSockets<true>()` +1,179 B, `void mm::NetworkReceiveEffect::drainSockets<false>()` +1,151 B, `mm::NetworkReceiveEffect::tick()` -752 B, `mm::NetworkSendDriver::tick()` -491 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. @@ -98,31 +90,33 @@ What this commit's flash went to, symbol by symbol against the measurement at `S ### 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 | 7 (+3) ⚠ | - | - | 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 | 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 ? | - | - | - | - | - | - | - | - | +| 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) @@ -146,36 +140,36 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 30,147 (+91) ⚠ | 6,446 | 23.7 % | -| light | 31,265 | 7,512 | 27.0 % | -| platform | 21,509 (+391) ⚠ | 4,159 | 21.7 % (−0.2 %) ✓ | -| ui | 12,471 (+1) ⚠ | 3,471 | 29.5 % | -| test | 67,552 (+294) ⚠ | 7,131 | 12.2 % | -| moondeck | 31,292 (+2) ⚠ | 4,933 | 18.0 % | +| core | 30,139 (−8) ✓ | 6,438 | 23.7 % | +| light | 31,542 (+277) ⚠ | 7,542 | 26.8 % (−0.2 %) ✓ | +| platform | 21,589 (+80) ⚠ | 4,167 | 21.6 % (−0.1 %) ✓ | +| ui | 12,471 | 3,471 | 29.5 % | +| test | 67,801 (+249) ⚠ | 7,150 | 12.2 % | +| moondeck | 31,404 (+112) ⚠ | 4,941 | 17.9 % (−0.1 %) ✓ | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,523 (+8) ✓ | -| scenarios | 30 | +| unit cases | 2,535 (+12) ✓ | +| scenarios | 32 (+2) ✓ | ## Complexity | Metric | Value | |---|---:| -| functions | 4,996 (+7) ✓ | -| over threshold | 353 (−1) ✓ | +| functions | 5,009 (+13) ✓ | +| over threshold | 353 | | worst CCN | 128 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 160 (+4) ⚠ | -| markdown lines | 33,946 (+614) ⚠ | -| plan files | 50 (+2) ⚠ | -| backlog lines | 3,387 (−12) ✓ | +| markdown files | 160 | +| markdown lines | 33,983 (+37) ⚠ | +| plan files | 50 | +| backlog lines | 3,393 (+6) ⚠ | | lessons lines | 532 | -| CLAUDE.md lines | 285 | +| CLAUDE.md lines | 287 (+2) ⚠ | 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": "<name>"` runs on that board, as an `apply_state`, a `set_control` or a `measure`. Every step's strings fill `{host}` and `{<name>}` 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"`; `"<name>:"` 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/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-20261013 - Audio reactive live performance.md b/docs/work/present/Plan-20261013 - Audio reactive live performance.md index 72167cc46..4480076a3 100644 --- a/docs/work/present/Plan-20261013 - Audio reactive live performance.md +++ b/docs/work/present/Plan-20261013 - Audio reactive live performance.md @@ -32,7 +32,7 @@ An **Akai MPK261**: 61 semi-weighted keys, 16 RGB-backlit pads, 8 knobs, 8 fader **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); -- which notes started and which ended since the last frame, so two hits between frames are never lost and a note shorter than a frame still fires; +- 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. @@ -48,7 +48,7 @@ Each step ends in something verified on the bench. - `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, a note shorter than a frame, running status, a note-on with velocity 0 read as a note-off. +- 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 @@ -112,7 +112,7 @@ The sensors, from what is listed to what a stage needs: **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. Verify with a person walking the bench stage and the heads following. +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 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 index afeb60547..d07a9f1ef 100644 --- a/docs/work/present/Plan-20261013 - Streams, every frame from outside.md +++ b/docs/work/present/Plan-20261013 - Streams, every frame from outside.md @@ -10,7 +10,7 @@ Every media framework separates the same three questions: GStreamer, FFmpeg, Med |---|---| | **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 in wiring order), a volume (width × height × depth) | +| **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: @@ -35,7 +35,7 @@ The three axes describe any stream, and they do not make every stream one module | 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 in wiring order | width × height pixels | +| 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. @@ -44,7 +44,7 @@ The three axes describe any stream, and they do not make every stream one module **What the families share, and only that:** -1. **Light-list placement:** writing values in wiring order into the layer through the layout. FSEQ needs it, and so does Network Receive for senders that send in wiring order (below). One helper, two users. +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. @@ -53,22 +53,22 @@ Two rules keep the picture side small enough for a microcontroller, where GStrea - **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. -## Image order and wiring order +## 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 the order the sender's data is in, and the industry uses two conventions: +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 | |---|---|---| -| **Image order:** row by row, like a picture, 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 | -| **Wiring order:** arranged in the order the lights are wired, 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 | +| **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 orders coincide, which is why the difference stays invisible on simple setups. +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 `data order` setting,** image or wiring: +**So Network Receive gains a `mapped_by` setting,** `receiver` or `sender`, naming who maps the frame onto the wiring, `receiver` being the default: -- **Image order** is today's copy, unchanged, so nothing changes for anyone using it now. -- **Wiring order** places channel k through the light-list placement, which is the inverse of the layer's mapping: composed, the two cancel, so channel k reaches physical light k while still blending and taking modifiers. -- **FSEQ is always wiring order,** since xLights writes it that way, so the FSEQ effect uses the placement without a setting. +- **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): @@ -117,11 +117,11 @@ The textbook name is inverse mapping: compose every transform into one coordinat **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.** Ambilight stops being an effect of its own and becomes the picture effect's area sampler. Network Receive keeps its protocol family and gains wiring order 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. +**What it removes.** Ambilight 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["<b>Channel data</b>: wiring order"] + subgraph CH["<b>Channel data</b>: mapped by the sender"] direction LR nr["<b>Network Receive</b><br/>Art-Net, sACN, DDP"] fseq["<b>FSEQ playback</b><br/>from /sd<br/><i>TouchMyLight</i>"] @@ -230,7 +230,7 @@ A live browser source costs bandwidth, lights × 3 bytes × fps: about 60 KB/s f TouchMyLight's cube is the first user, so 3D works from the start. -- **A light list carries wiring order, not 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, wiring-order data is mapped twice wherever the layer's order differs from the wiring, as on a cube (see image order and wiring order 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 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. @@ -249,8 +249,8 @@ The P4's hardware video codec encodes H.264 but does not decode it; its JPEG cod | 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 effect beside Network Receive, sharing the light-list placement and the universe settings | -| Network Receive (shipped) | kept as the pixel-protocol family, gaining a `data order` setting: image order as today, wiring order through the light-list placement | +| 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 @@ -287,13 +287,14 @@ What MoonLight builds first, so that every contribution after it is one source i | # | Piece | Where | What it does | |---|---|---|---| -| G1 | **Light-list placement** | a helper in `src/light/layers/` | at prepare, maps light k to the logical cell it lands in, from the layer's own physical-to-logical fold, with no table when the orders coincide; per frame, writes values in wiring order into the layer buffer | -| G2 | **Network Receive's `data order`** | `NetworkReceiveEffect` | image order as today, wiring order through G1; the first user of G1 and the path FSEQ copies; gated by the before-and-after measurement | +| G1 | **Light-list placement** ✅ built | `MappingLUT::gatherFromPhysical` | the mapping's own inverse, needing no table of its own: each logical cell takes the value its first physical light was sent, read from the CSR runs the layer already builds; one copy on the identity path ; it inverts the whole mapping today, modifiers included, so a mirror or a rotation on a sender-mapped stream cancels instead of applying: G5 inverts the layout's mapping alone, accepted on a rotated layer | +| 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** | 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** | 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 | **A contributor page** | docs | how to add a channel-data source and how to add a picture source, with the test pattern as the template | +| G5 | **Sender mapping skips the board's mapping** | `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 | **A contributor page** | docs | how to add a channel-data source and how to add a picture source, with the test pattern as the template | -G1 and G2 come first: they add the second convention every wiring-order sender needs, and wait on no naming. G3 and G4 take #138's core part, credited to Gohnnyman. Before code: the double mapping confirmed on the bench (a serpentine grid fed by xLights, expected to show every other row reversed) and the open questions below decided. +G1 and G2 come first: they add the second convention every mapping sender needs, and wait on no naming. G3 and G4 take #138's core part, credited to Gohnnyman. Before code: the double mapping confirmed on the bench (a serpentine grid fed by xLights, expected to show every other row reversed) and the open questions below decided. ## How contributions arrive @@ -303,20 +304,56 @@ Each later piece is a small pull request, one topic, based on current main, with |---|---|---| | Backlight wiring, today's #140 resubmitted on main | Gohnnyman | nothing; it can come now | | Color trim, split from #139 | Gohnnyman | nothing; a separate idea | -| FSEQ playback from SD: uncompressed v2, sparse ranges, the frame index from the clock, verified on a 3D layout | TouchMyLight | G1, G2 | +| FSEQ playback from SD: uncompressed v2, sparse ranges, the frame index from the clock, verified on a 3D layout. Its first step extracts Network Receive's channel-data core (staging, hold-last-frame, `mapped_by`, the placement, the counters) into one sink that Network Receive and the FSEQ player both use, each a thin effect with its own settings (decided 2026-10-14) | TouchMyLight | G1, G2; G5 makes it cheap on a 3D layout, where a sender-mapped frame is otherwise mapped back and forward again (4 ms against 1.6 ms on the S3 at 12,288 lights) | | Border sampling: edge depth, smoothing | Gohnnyman | G4 | | GIF decoder, PPM as a test format | open | G3 | | USB capture with its MJPEG decoder, P4, with Teensy stubs | Gohnnyman | G3 | | Browser pages: converter, baking to FSEQ, a live tab source | open | G1 to G4 | | Recording, HDMI out on the ESP32-P4-PC, clock sync | open | FSEQ playback | -#138 and #139 close with thanks once G3 and G4 land, their good parts credited in the groundwork; a stacked pull request from a fork always shows its parents, so it is reviewed only after the one below it merges. +#138 and #139 close with thanks once G3 and G4 land, their good parts credited in the groundwork; a stacked pull request waits for the one below it, as [CLAUDE.md](../../../CLAUDE.md) says. + +## Where it stands (2026-10-14) + +Built and uncommitted: G1 and G2 (`mapped_by`), and around them what measuring Network Receive on real boards turned up. + +- **Network Receive** drains its sockets in a receive task on core 1, shows its path in the status ("one copy" or "mapped twice"), and counts `packets_per_s` and `frames_per_s`. +- **TryLock** releases with an exchange: in PSRAM a plain store raced the emulated read-modify-write, left the latch set with no owner, and took the WiFi stack down with the stuck receive task. +- **The gather** runs in 4.5 ms instead of 7.3 ms at 12,288 lights on the S3. +- **Defaults:** a UDP mailbox of 32 on every board, and 30 Ethernet receive buffers of 1600 bytes on the P4, transmit kept at 10. +- **Scenarios:** `scenario_NetworkReceive_two_boards` and `scenario_NetworkSend_one_sender_many_receivers` drive several boards (`--board name=host`), recorded per combination of boards. + +Received at 12,288 lights, 40 frames/s sent (1,040 packets/s), mapped by the receiver: + +| Sender to receiver | Packets/s | Frames/s | Receiver tick | +|---|---|---|---| +| S3 WiFi to S3 WiFi | 600 to 980 | 20 to 23 | 6.8 to 8.2 ms | +| P4 Ethernet to S3 WiFi | 616 | 23 | 5.8 ms | +| S3 WiFi to P4 Ethernet | 984 | 32 | 0.3 ms | +| P4 Ethernet to P4 Ethernet | 790 to 846 | 8 to 19 | 0.8 ms | + +P4 to P4 started the day at about 200 packets/s and 0 to 1 frames. What remains is the sender's burst: the access point drops it on WiFi, and on Ethernet the push packet at its end is the one most often lost. Network Send pacing its packets is the next step (a 0.5 ms gap recovered 96% on WiFi), then G5, which removes the double mapping that sender mapping costs on a layout that is not the identity (4 ms against 1.6 ms on the S3). The rest is in the backlog below. + +## Backlog, found while measuring + +- **A blocking HTTP write freezes the render task under a stream.** `serveState` writes the full `/api/state` (about 35 KB) with a blocking `lwip_write` on the render task; with the S3 receiving 12,288 lights over WiFi the client's acknowledgements slow, the send buffer fills, and the write holds the render task for seconds until the task watchdog reboots the board (backtrace: `handleConnection` → `serveState` → `JsonSink::flushStage` → `lwip_write`, 2026-10-14). The UI no longer reads the full state since state by subscription, so the trigger was the scenario runner's reads at every measure step; only the diagnostic bundle, assign mode and a backup restore still read it, each on a user's explicit action. The textbook fix: write without blocking and resume a partial write on the next tick, as uploads already stream. Check whether the WebSocket state pushes, smaller TCP writes on the same task, can block the same way. +- **The WiFi connect grace counts from STA init, not from association.** `NetworkModule::kStaGraceMs` (10 s) starts when the station starts; a router taking 7.3 s to lease an address plus one missed scan (reason 201, about 2.4 s) runs it out, and the board falls back to its access point for about 80 s while already joined. The lease wait belongs after association, which `kDhcpWaitMs` already names. +- **No log on the S3's native USB port.** The S3 builds keep IDF's default console, UART0 primary with USB-Serial-JTAG as the secondary mirror, yet nothing arrives on the native port, so a board connected by that port alone (the testbench, an S3-Zero) shows no log and loses any crash reason. Suspected, not verified: Improv installs its own USB-Serial-JTAG driver for provisioning, which takes the peripheral from the mirror, where [platform_esp32_improv.cpp](../../../src/platform/esp32/platform_esp32_improv.cpp) expects the console to win. With USB-Serial-JTAG as the primary console a pyserial read with DTR and RTS off receives the log, while MoonDeck's `monitor_esp32` still receives nothing. The fix is either the mirror and Improv sharing the port, or USB-Serial-JTAG as the primary console on native-USB S3 boards; check too whether the installer's post-flash address read depends on it. +- **The UDP mailbox of 32 and the paced send task on boards without PSRAM.** `CONFIG_LWIP_UDP_RECVMBOX_SIZE=32` is in the base defaults for every board. It costs about 128 bytes per socket, and on WiFi the queued packets are WiFi's own receive buffers, capped at 32. On Ethernet without PSRAM they come from the internal heap at about 1.6 KB each, so a full queue holds about 50 KB on a classic board with 100 to 170 KB free, which could starve the HTTP server during a burst. Measure it with `scenario_NetworkReceive_two_boards` on a classic Ethernet board (the Olimex at 192.168.1.210) receiving from a P4, reading free heap and the HTTP answers; if it hurts, the 4 MB classic fragments override it with a smaller value. Measure Network Send's paced task there too, with the classic as the sender: about 5.5 KB of internal RAM per driver (a 4 KB stack, the 1.45 KB packet buffer, a timer), and on a single-core chip such as the C3 the send task outranks the frame loop, so each packet briefly interrupts it. +- **The two-board scenarios on every platform.** `scenario_NetworkReceive_two_boards` and `scenario_NetworkSend_one_sender_many_receivers` run today on S3 boards over WiFi and P4 boards over Ethernet, in all four pairings. Add the Teensy 4.1 (Ethernet), desktop builds over WiFi, and the NanoPi over Ethernet, as sender and as receiver, so every platform's receive cost and loss sits in the same table. A desktop or NanoPi board joins with `--board name=<ip>:8080`. The Teensy build runs bare-metal with no scheduler, so Network Send and Network Receive work inside their tick (`spawnPinnedTask` returns false); QNEthernet fills its receive buffers in an interrupt, so a late drain loses less there. Its runs show whether threads (TeensyThreads or a FreeRTOS port, with stacks in its tight RAM and shared structures made thread-safe) are worth it. +- **E1.31 multicast never arrives.** Network Receive binds 5568 but never joins the multicast groups, so both receivers in `scenario_NetworkSend_one_sender_many_receivers` read 0 packets while Network Send multicast; `UdpSocket::membership` exists in the platform layer and goes unused. Join the groups of the universes the layer covers, from `universe_start` on. +- **The Art-Net broadcast counts do not add up.** In the same scenario at 64x64 (about 1,000 packets/s sent, 40 frames), the S3 counted 1,212 packets/s and 96 frames/s while the P4 counted 488 and 39. Another bench board broadcasting, or duplicates over WiFi; a packet capture on port 6454 settles it. +- **A sending P4 reboots with 30 Ethernet transmit buffers.** With 30 x 1600 bytes for transmit a P4 rebooted about 15 s into sending; IDF takes every Ethernet DMA buffer from internal RAM, about 96 KB with 30 + 30, so internal-heap exhaustion is the suspect, unverified. troyhacks raised and reverted the same values within 26 minutes ([bdf6c17e](https://github.com/troyhacks/esp32-arduino-libs/commit/bdf6c17e), [c8a43b7e](https://github.com/troyhacks/esp32-arduino-libs/commit/c8a43b7e)). Transmit stays at 10. +- **The P4's HTTP server answers "incomplete request body" under a stream.** Two `set_control` writes and one `apply_state` failed this way while the P4 received DDP over Ethernet; the same short body window backlog-core already names. +- **`CONFIG_ETH_IRAM_OPTIMIZATION=y` on the P4.** The one Ethernet change troyhacks kept; measure it with `scenario_NetworkReceive_two_boards`, P4 to P4. +- **A UDP mailbox of 64 on the P4.** Espressif's own P4 Arduino default; 32 against 64 measured the same on the S3 over WiFi, so measure it P4 to P4 over Ethernet, where bursts arrive at wire speed. +- **The receive task's priority on Ethernet.** Ours runs at 3 on core 1, below the driver task (`mmEncode`, 5); troyhacks' Art-Net receive runs at 20 on core 0, blocking in `recvfrom()`, above lwIP's own thread (`tcpip`, 18), which a busy drain could starve. On the S3 over WiFi 6 against 3 changed nothing, the loss being at the access point. Measure 6 (above the driver task) and 19 (above lwIP) P4 to P4. +- **Network Send paces its packets.** Back-to-back bursts are what the access point drops (a 0.5 ms gap recovered 96% on WiFi) and what loses the push packet at the end of an Ethernet burst. Spread a frame's packets over part of the frame period, and back off on a full transmit queue (`ENOMEM`) rather than adding buffers, as Espressif advises ([esp-idf#13602](https://github.com/espressif/esp-idf/issues/13602)) and troyhacks' raw Ethernet driver does. +- **The live runner restores no board when a board fails mid-run.** A two-board run whose receiver rebooted left the sender's scenario canvas and its Network Send on P4 .139, still streaming to the S3. The restore of every board belongs in a `finally`, so an exception or a dead board still puts the others back. ## Open questions - The names: `PictureService` and `PictureEffect`, or names closer to the industry's (media source, pixel map)? -- FSEQ as its own effect beside Network Receive (proposed), or a second source of one channel-data effect, which would rename Network Receive? -- Where the light-list placement lives: a helper beside the layer's mapping build, or a method of the layer itself? - Does a recording capture the layer's input (before mapping) or the drivers' output (after correction)? xLights expects the output. - How far video goes on a board without a hardware decoder: GIF and image sequences, or a software decoder measured in flash first. - How HDMI out is wired on the ESP32-P4-PC: its sources name HDMI and MIPI-DSI but no bridge chip; Olimex's separate P4 HDMI adapter uses Lontium's LT8912B (MIPI-DSI to HDMI, up to 1080p60), so check the schematic. diff --git a/esp32/sdkconfig.defaults b/esp32/sdkconfig.defaults index 24d6c241f..4c5a9e799 100644 --- a/esp32/sdkconfig.defaults +++ b/esp32/sdkconfig.defaults @@ -58,6 +58,9 @@ CONFIG_LWIP_AUTOIP_TRIES=4 # avoiding partial sends that would drop and reconnect the preview connection. CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520 +# A received frame is a burst of UDP packets (26 DDP packets for 12,288 lights) arriving faster than any task wakes, and a socket holding 6 drops the rest: measured on the S3 and the P4, mailbox 6 completed 4 to 10 of 40 frames/s at 4,096 lights where 32 completed 22 to 38 (scenario_NetworkReceive_two_boards). +CONFIG_LWIP_UDP_RECVMBOX_SIZE=32 + # A browser page-load opens the HTML + several JS/CSS files + the persistent WebSocket all at once, and # every refresh churns that whole set. The default 10-socket ceiling can't hold one burst (6 files + WS + # the listen socket + WiFi/mDNS internals) once a few closed sockets sit in TIME_WAIT, so lwIP RSTs the diff --git a/esp32/sdkconfig.defaults.esp32p4rev1-eth b/esp32/sdkconfig.defaults.esp32p4rev1-eth index 13f6027ca..60549f299 100644 --- a/esp32/sdkconfig.defaults.esp32p4rev1-eth +++ b/esp32/sdkconfig.defaults.esp32p4rev1-eth @@ -1,17 +1,17 @@ -# Waveshare ESP32-P4-NANO — Ethernet-only build (IP101 PHY). +# Waveshare ESP32-P4-NANO: Ethernet-only build (IP101 PHY). # Board: https://www.waveshare.com/wiki/ESP32-P4-Nano-StartPage # Schematic / pin map confirmed against the Waveshare wiki + the ESPHome # device page (devices.esphome.io/devices/waveshare-esp32-p4-nano). # # Layered onto sdkconfig.defaults by moondeck/build/build_esp32.py. # -# The RMII / PHY *pins* are NOT set here — they live in C (the per-target +# The RMII / PHY *pins* are NOT set here: they live in C (the per-target # ethPins struct in src/platform/esp32/platform_config.h, read by ethInit), # so the pin map stays in one place. This fragment only enables the EMAC + the # RMII interface + flash / PSRAM / partition layout. (IP101 addr 1, MDC 31, -# MDIO 52, reset 51, external RMII clock IN on GPIO 50 — see the struct.) +# MDIO 52, reset 51, external RMII clock IN on GPIO 50; see the struct.) -# Custom partition table — shared 16 MB layout (app0/app1 = 4 MB each, spiffs +# Custom partition table: shared 16 MB layout (app0/app1 = 4 MB each, spiffs # ~7.9 MB). Chip-independent flash map; same file for classic-16mb and S3-N16R8. CONFIG_PARTITION_TABLE_CUSTOM=y CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_16mb.csv" @@ -21,7 +21,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y # Target the earlier P4 silicon (rev <3.0). The v6.1 IDF defaults the minimum P4 # revision to v3.1 (ESP32P4_REV_MIN_301), which refuses to boot on the v0.x/v1.x -# P4 chips already in the field (e.g. a v1.x P4-NANO) — the bootloader rejects the +# P4 chips already in the field (e.g. a v1.x P4-NANO): the bootloader rejects the # chip at startup. IDF gates the sub-v3 revisions behind SELECTS_REV_LESS_V3 # (rev <3.0 and >=3.0 are "huge hardware difference", so a binary targets one or # the other, not both); enable it, then pin the minimum to v0 to run on any @@ -34,9 +34,9 @@ CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y # supports revisions 0.0 through 1.99 and will refuse to boot on a v3.x chip. v3.x is what a # board bought today carries (Espressif calls v1.x "engineering samples" and does not recommend # it for new designs); this image exists for the v1.x boards already in the field and on our -# bench, which is the only silicon we can test on. A v3.x board needs its own variant — those now +# bench, which is the only silicon we can test on. A v3.x board needs its own variant; those now # exist as esp32p4rev3-eth and esp32p4rev3-eth-wifi (sdkconfig.defaults.esp32p4rev3 layers -# the revision over this file) — not a flag flipped here. +# the revision over this file), not a flag flipped here. CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y CONFIG_ESP32P4_REV_MIN_0=y @@ -45,17 +45,18 @@ CONFIG_ESP32P4_REV_MIN_0=y # later rounds but not relied on at boot. CONFIG_SPIRAM=y -# Ethernet — on-chip EMAC + RMII. Pins are runtime config (ethConfig_ in C), not +# Ethernet: on-chip EMAC + RMII. Pins are runtime config (ethConfig_ in C), not # sdkconfig. RMII is the EMAC's only interface and the IDF default, so no -# CONFIG_ETH_PHY_INTERFACE_RMII (a non-existent symbol in IDF v6 — warned-on). +# CONFIG_ETH_PHY_INTERFACE_RMII (a non-existent symbol in IDF v6, warned on). CONFIG_ETH_USE_ESP32_EMAC=y -CONFIG_ETH_DMA_BUFFER_SIZE=512 -CONFIG_ETH_DMA_RX_BUFFER_NUM=10 +# A received frame is a burst of packets at wire speed (26 for 12,288 lights), which 10 buffers of 512 bytes, about 3 packets, cannot hold: 30 buffers of 1600 bytes hold a whole burst, measured P4 to P4 at 128x96 as 480 to 610 of 1040 packets/s against about 280 (scenario_NetworkReceive_two_boards). Transmit stays at 10, since 30 rebooted a sending P4 within seconds. +CONFIG_ETH_DMA_BUFFER_SIZE=1600 +CONFIG_ETH_DMA_RX_BUFFER_NUM=30 CONFIG_ETH_DMA_TX_BUFFER_NUM=10 # MoonLive native codegen needs an executable heap (allocExec → MALLOC_CAP_EXEC). Like the # S3, the P4 has hardware memory protection (PMP) that IDF couples to disabling the exec -# heap, so disable memprot to make MALLOC_CAP_EXEC available — the standard ESP32-JIT config. +# heap, so disable memprot to make MALLOC_CAP_EXEC available, the standard ESP32-JIT config. # Safety for scripted code is the staged bounds/watchdog checks, not the hardware W^X wall. # (Same rationale as sdkconfig.defaults.esp32s3-n16r8.) CONFIG_ESP_SYSTEM_MEMPROT_FEATURE=n diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index f261e8580..6a1a78e27 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -864,6 +864,7 @@ uv run moondeck/scenario/run_live_scenario.py --host 192.168.1.210 uv run moondeck/scenario/run_live_scenario.py --name scenario_Network_hardware_reconfigures_live # one scenario uv run moondeck/scenario/run_live_scenario.py --name scenario_perf_light,scenario_Fluid_solver # several, comma-separated uv run moondeck/scenario/run_live_scenario.py --host MM-Bench.local --network Home --name scenario_AccessPoint_first_setup_from_its_own_network # first setup, this computer as the phone +uv run moondeck/scenario/run_live_scenario.py --host 192.168.1.158 --board sender=192.168.1.156 --name scenario_NetworkReceive_two_boards # two boards: the sender board sends uv run moondeck/scenario/run_live_scenario.py --update-baseline # save baseline uv run moondeck/scenario/run_live_scenario.py --compare-baseline # detect regressions ``` diff --git a/moondeck/scenario/run_live_scenario.py b/moondeck/scenario/run_live_scenario.py index 0eddbe26d..a5e871a58 100644 --- a/moondeck/scenario/run_live_scenario.py +++ b/moondeck/scenario/run_live_scenario.py @@ -506,6 +506,39 @@ def _reboot_and_wait(client, target: str, timeout_s: float = 60.0) -> str: return f"did not answer within {timeout_s:.0f}s" +def _read_values(client, metrics: dict, names: list, boards: dict) -> dict: + """The values a measure step names: "fps" and "Module.tick" from /api/system, "Module.control" from /api/state; "board:" in front reads another board.""" + ticks = {m.get("name"): m.get("us") for m in metrics.get("modules", []) or []} + out = {} + state = None + for name in names: + board, _, rest = name.rpartition(":") + if board: + try: + out[name] = _read_values(boards[board], boards[board].get("/api/system"), [rest], {})[rest] + except Exception as e: + out[name] = f"? ({type(e).__name__})" + continue + if name == "fps": + out[name] = metrics.get("fps", "-") + continue + mod, _, key = name.partition(".") + if key == "tick": + out[name] = ticks.get(mod, "-") + continue + if state is None: + state = client.get("/api/state") + out[name] = "-" + stack = list(state.get("modules", [])) + while stack: + m = stack.pop() + if m.get("name") == mod: + out[name] = _control_value(m, key) + break + stack.extend(m.get("children", []) or []) + return out + + def _sum_dynamic_bytes(state: dict) -> int: """Sum dynamicBytes across the live module tree. Returned by /api/state per module; the sum is the model's prediction for how much heap the tree owns. @@ -588,7 +621,7 @@ def _fixture_canvas(fixture: list) -> list: def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, update_contract: bool = False, update_reason: str | None = None, named: bool = True, - network: str | None = None) -> dict: + network: str | None = None, boards: dict | None = None) -> dict: """Run a scenario against a live device and return results. Mode handling (see docs/reference/testing.md § Scenario modes): @@ -629,6 +662,15 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, print("\n SKIP (on_request: runs only when named, since it takes the host off its network)") results["skipped"] = True return results + # A scenario over several boards names them in `boards`; the command line maps each name to a device with --board. + boards = boards or {} + wanted = scenario.get("boards", []) + absent_boards = [b for b in wanted if b not in boards] + if absent_boards: + print(f"\n SKIP (boards: runs only with {', '.join(absent_boards)} named by --board NAME=HOST)") + results["skipped"] = True + return results + boards = {b: boards[b] for b in wanted} if mode == "construct": print("\n SKIP (mode=construct: runs in-process only; the live device's " "main.cpp owns the top-level shape)") @@ -660,6 +702,8 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, reachable.update(_typed_names(step.get("document"))) missing = [] for step in scenario.get("steps", []): + if step.get("device"): + continue # its ids live on another board, which the pre-flight does not read sid = step.get("id") opn = step.get("op") if opn == "apply_state": @@ -716,6 +760,25 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, # Snapshot the containers a scenario may clear or rebuild, as documents, so the board ends the run as found. prior_controls: dict = {} # (module, control) -> the value it held before this scenario wrote it snapshot_docs = _snapshot_documents(client) + # Every other board is restored the same way, from its own snapshot, controls and created modules. + home = (client, prior_controls, created_modules) + board_state = {b: (c, {}, []) for b, c in boards.items()} + board_snapshots = {b: _snapshot_documents(c) for b, c in boards.items()} + obs_target = target + if boards: + # A step names a board by its address as `{host}` or `{<name>}`, as a sender's `hosts` does. + ctx["host"] = urllib.parse.urlsplit(home_base).hostname or "" + for b, c in boards.items(): + ctx[b] = urllib.parse.urlsplit(c.base).hostname or "" + # The measurements belong to the combination, so wifi-to-wifi and ethernet-to-wifi keep apart. + kinds = [f"host={target}"] + for b, c in boards.items(): + try: + kinds.append(f"{b}={_detect_target(c.get('/api/state'), c.local)}") + except Exception: + kinds.append(f"{b}=unknown") + obs_target = " ".join(kinds) + print(f" Boards: {obs_target}") # The fixture is the canvas the steps expect, on a bench that may carry another effect or grid. try: @@ -785,6 +848,14 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5, fstep = _fill(step, ctx) if ctx else step # a filled copy, so credentials never reach the file the run writes back step_result = {"name": step_name, "op": op} + # A step naming a board runs there, with that board's restore bookkeeping. + dev = step.get("device") + if dev and (dev not in board_state or op not in ("apply_state", "set_control", "measure")): + print(f" FAIL {step_name}: device {dev!r} is not in `boards`, or {op} does not run on another board") + results["passed"] = False + break + client, prior_controls, created_modules = board_state[dev] if dev else home + # An optional measure/control on a module whose optional add was skipped is a # no-op: the module isn't there to measure. Skip before any REST call. if step.get("optional") and step.get("id") in skipped_ids and op in ("measure", "set_control"): @@ -1036,8 +1107,8 @@ def _as_written(v): results["passed"] = False elif op == "set_control": - data = {"module": step["id"], "control": step["key"], - "value": step["value"]} + data = {"module": fstep["id"], "control": fstep["key"], + "value": fstep["value"]} _remember_control(prior_controls, client, step["id"], step["key"]) try: resp = client.post("/api/control", data) @@ -1257,6 +1328,10 @@ def redirect_request(self, *args, **kwargs): results["steps"].append(step_result) break # stop step loop; cleanup runs below step_result["metrics"] = metrics + # `read` names extra values to report: "Module.control" from the state, or "Module.tick" for a module's tick time. + if step.get("read"): + step_result["read"] = _read_values(client, metrics, step["read"], {b: s[0] for b, s in board_state.items()}) + print(" READ " + " ".join(f"{k}={v}" for k, v in step_result["read"].items())) tick_us = metrics.get("tickTimeUs", 0) fps = 1000000 // tick_us if tick_us > 0 else metrics.get("fps", 0) heap = metrics.get("freeHeap", 0) @@ -1270,7 +1345,7 @@ def redirect_request(self, *args, **kwargs): # "set_by": "YYYY-MM-DD", "reason": "..." } } } # Contracts are hand-set promises: see docs/reference/testing.md § Performance # contracts. `--update-contract --reason "..."` rewrites them. - contract_block = step.get("contract", {}).get(target) if step.get("contract") else None + contract_block = step.get("contract", {}).get(obs_target) if step.get("contract") else None if contract_block: # Defaults reflect run-to-run variance, not "I don't care": # pc-* : multi-process OS jitter, 20% pct + 200us absolute @@ -1357,14 +1432,14 @@ def redirect_request(self, *args, **kwargs): "free_heap": int(heap), "max_alloc_block": int(max_block), } - existing_obs = step.get("observed", {}).get(target) + existing_obs = step.get("observed", {}).get(obs_target) if update_contract: new_obs = _observed.reset(sample, _today_iso()) obs_changed = True else: new_obs, obs_changed = _observed.widen(existing_obs, sample, _today_iso()) if obs_changed: - step.setdefault("observed", {})[target] = new_obs + step.setdefault("observed", {})[obs_target] = new_obs wrote_observations[0] = True # --update-contract: rewrite the contract in the scenario JSON for the @@ -1382,10 +1457,10 @@ def redirect_request(self, *args, **kwargs): # Key by step INDEX, not the step dict: a dict is unhashable, so # `(step, target)` as a key raised TypeError (this whole path is # only reached with --update-contract, which the gates don't pass). - existing = step.get("contract", {}).get(target, {}) - if (step_index, target) not in pending_contract_originals: + existing = step.get("contract", {}).get(obs_target, {}) + if (step_index, obs_target) not in pending_contract_originals: # Deep enough copy: existing is a flat dict of scalars. - pending_contract_originals[(step_index, target)] = ( + pending_contract_originals[(step_index, obs_target)] = ( dict(existing) if existing else None ) new_block = { @@ -1403,7 +1478,7 @@ def redirect_request(self, *args, **kwargs): # path: keep both files in sync if you change one. if "max_alloc_block" in existing: new_block["max_alloc_block"] = int(max_block) - step.setdefault("contract", {})[target] = new_block + step.setdefault("contract", {})[obs_target] = new_block # Check bounds bounds = step.get("bounds", {}) @@ -1450,8 +1525,26 @@ def redirect_request(self, *args, **kwargs): # The host back on its own network before anything else talks to the device, which is then there. if host.home: host.restore() + client, prior_controls, created_modules = home client.base = home_base + # The other boards first, so a sender stops before this device is put back. + for b, (bc, b_prior, b_created) in board_state.items(): + for module_id in reversed(b_created): + try: + bc.delete(_mod_path(module_id)) + print(f" - {module_id} ({b} cleanup)") + except Exception: + pass + for doc in board_snapshots[b]: + try: + bc.patch("/api/state", doc) + except Exception as e: + print(f" WARN: couldn't restore {b}'s {next(iter(doc), '?')}: {e}") + put_back = _restore_controls(bc, b_prior) + if put_back: + print(f" restored {put_back} control(s) on {b}") + # Cleanup: delete modules that were created by this scenario for module_id in reversed(created_modules): try: @@ -1498,24 +1591,34 @@ def redirect_request(self, *args, **kwargs): # JSON write below (for observed) doesn't leak them to disk. for step_index, step in enumerate(scenario.get("steps", [])): contract = step.get("contract") - if contract and target in contract: - if (step_index, target) in pending_contract_originals: - orig = pending_contract_originals[(step_index, target)] + if contract and obs_target in contract: + if (step_index, obs_target) in pending_contract_originals: + orig = pending_contract_originals[(step_index, obs_target)] if orig is None: - del contract[target] + del contract[obs_target] else: - contract[target] = orig - print(f" contract[{target}] NOT written (run failed; observed still saved)") + contract[obs_target] = orig + print(f" contract[{obs_target}] NOT written (run failed; observed still saved)") if wrote_observations[0] or contract_safe_to_write: _observed.save_scenario(scenario_path, scenario) what = [] if wrote_observations[0]: - what.append(f"observed[{target}]") + what.append(f"observed[{obs_target}]") if contract_safe_to_write: - what.append(f"contract[{target}]") + what.append(f"contract[{obs_target}]") print(f" WROTE {scenario_path.name} ({' + '.join(what)})") + # The values each measure step read, one row per step, for comparing configurations at a glance. + rows = [s for s in results["steps"] if s.get("read")] + if rows: + keys = list(dict.fromkeys(k for s in rows for k in s["read"])) + width = max(len(s["name"]) for s in rows) + print("\n " + "step".ljust(width) + " tick_us " + " ".join(keys)) + for s in rows: + tick = (s.get("metrics") or {}).get("tickTimeUs", "?") + print(" " + s["name"].ljust(width) + f" {str(tick):>7} " + " ".join(str(s["read"].get(k, "-")).rjust(len(k)) for k in keys)) + # Summary print("\n---") if results["passed"]: @@ -1586,6 +1689,8 @@ def main(): help="Filter to scenarios whose top-level module / also matches.") parser.add_argument("--network", default=None, help="Registry network (moondeck.json) the host and device share, for a scenario that moves the host's WiFi.") + parser.add_argument("--board", action="append", default=[], metavar="NAME=HOST", + help="Another board a scenario names in `boards`, such as sender=192.168.1.156; repeat for each.") parser.add_argument("--settle", type=float, default=3.0, help="Settle time in seconds between step and measurement") parser.add_argument("--update-baseline", action="store_true", @@ -1609,6 +1714,12 @@ def main(): "(e.g. --reason 'tightened after Layer optimization')") client = Client(args.host) + boards = {} + for spec in args.board: + bname, sep, bhost = spec.partition("=") + if not sep or not bname or not bhost: + parser.error(f"--board takes NAME=HOST, got {spec!r}") + boards[bname] = Client(bhost) # Verify connection try: @@ -1653,7 +1764,8 @@ def main(): continue result = run_scenario(client, path, args.settle, update_contract=args.update_contract, - update_reason=args.reason, named=bool(args.name), network=args.network) + update_reason=args.reason, named=bool(args.name), network=args.network, + boards=boards) all_results[result["name"]] = result if not result["passed"]: all_passed = False diff --git a/src/core/util/TryLock.h b/src/core/util/TryLock.h index 3e751bb19..8089b8c9b 100644 --- a/src/core/util/TryLock.h +++ b/src/core/util/TryLock.h @@ -11,25 +11,25 @@ namespace mm { /// ## Only try-acquire, never a blocking lock /// /// The hot-path rule forbids a render or encode thread blocking on a peer, so a caller that loses the race skips its slot rather than waiting. -/// That single constraint is what lets this be a test-and-set on the one type the standard guarantees lock-free, rather than an operating-system mutex. -/// With no waiting there is nothing to sleep on, nothing to wake, and no priority to inherit. -/// So it costs one atomic read-modify-write with no system call, and needs no lifecycle. +/// With nothing to sleep on or wake, each side is one atomic read-modify-write, with no mutex, no lifecycle and no recursion. /// -/// It is not recursive: a thread already holding it must not re-acquire, which the test would refuse. +/// ## Release is an exchange too, not a store +/// +/// On an ESP32 with PSRAM, a read-modify-write on external memory is emulated as a read and a write under a lock that a plain store bypasses. +/// A release landing between the two is overwritten, leaving the latch set with no owner, so release is an exchange, under the same lock. /// /// ## Why it lives in core rather than the platform layer /// -/// It is portable code with no hardware backing, so it fails that layer's charter, which is the one place hardware interfaces are allowed. -/// Domain-neutral and reusable, so any module coordinating two threads over one resource can take it. +/// It is portable code with no hardware behind it, so it fails the platform layer's charter, and any module coordinating two threads can take it. class TryLock { public: /// Take the latch when it is free, false when another thread holds it. - bool tryAcquire() { return !flag_.test_and_set(std::memory_order_acquire); } - /// Give it back. - void release() { flag_.clear(std::memory_order_release); } + bool tryAcquire() { return !flag_.exchange(true, std::memory_order_acquire); } + /// Give it back, as a read-modify-write: @xref{release-is-an-exchange-too-not-a-store}. + void release() { flag_.exchange(false, std::memory_order_release); } private: - std::atomic_flag flag_ = ATOMIC_FLAG_INIT; + std::atomic<bool> flag_{false}; }; /// A scope guard releasing on exit and doing nothing when the latch was busy, the standard try-to-lock shape. diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index eb0b076af..bbf1d6151 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -12,6 +12,8 @@ #include "light/drivers/PinList.h" // assignCounts: the same window-split idiom as ledsPerPin #include "platform/platform.h" +#include <atomic> + namespace mm { @@ -25,13 +27,14 @@ namespace mm { /// /// ## Why unicast is the default /// -/// Art-Net 4 requires unicast; broadcast survives here as legacy compatibility. A universe number lives in the payload rather than a header, so a receiver parses every packet before discarding the universes it lacks. Broadcast makes each host pay for all of them. The practical ceiling is near fifteen universes, measured on the bench where a large grid starved an ESP32's network stack. -/// -/// Unicast duplicates nothing when each node owns a different slice, which is the normal case. The sender emits as many packets as a broadcast stream would, each reaching only its owner. +/// Art-Net 4 requires unicast, broadcast surviving as legacy: a receiver parses every broadcast packet before dropping the universes it lacks, which starved an ESP32 near fifteen universes. +/// Unicast duplicates nothing when each node owns its own slice, and a failed send drops only that packet, so one dark tube stalls nothing. /// -/// ## Liveness +/// ## Paced packets, sent from a task /// -/// UDP is fire and forget, so a dead receiver is invisible. The send loop tolerates one rather than pretending to detect it. A failed send drops that packet, so one dark tube stalls nothing. +/// A burst loses packets at an access point and at a wire-speed receiver, so `packet_gap` spaces them, the textbook traffic shaping. Over WiFi that received 96% where a burst lost a third. +/// Waiting would stall the driver stage, so the tick hands the frame to a task, as an LED driver hands its frame to DMA, and skips frames meanwhile. +/// A full transmit queue gets a backoff rather than more buffers. A gap of 0, a board without tasks, or an unwired correction sends the burst from the tick. /// /// @card NetworkSendDriver.png class NetworkSendDriver : public DriverBase { @@ -82,6 +85,8 @@ class NetworkSendDriver : public DriverBase { uint16_t universeStart = 0; /// Send-rate ceiling (Hz); tick() rate-limits to this so a fast render tick doesn't flood the LAN. uint8_t fps = 50; + /// Microseconds between a frame's packets, shortened so a frame fits its `fps` period; 0 sends the frame as one burst. @xref{paced-packets-sent-from-a-task} + uint16_t packetGap = 500; /// Bind the protocol, the destinations, the universe offset, the window and the rate cap. void defineDriverControls() override { @@ -99,6 +104,7 @@ class NetworkSendDriver : public DriverBase { controls_.addControl("universe_start", universeStart); addWindowControls(); // start / count: the slice of the shared buffer this sink sends controls_.addControl("fps", fps, 1, 120); + controls_.addControl("packet_gap", packetGap, 0, 5000); } /// Which controls route through the prepare sweep, so the corrected buffer is re-sized. @@ -106,6 +112,7 @@ class NetworkSendDriver : public DriverBase { // Both are PARSED in prepare, so a change must re-run the sweep to re-derive the table. return std::strcmp(name, "hosts") == 0 || std::strcmp(name, "lightsPerHost") == 0 || std::strcmp(name, "protocol") == 0 || std::strcmp(name, "addressing") == 0 + || std::strcmp(name, "packet_gap") == 0 // starts or stops the send task || isWindowControl(name) || isCorrectionControl(name); } @@ -128,8 +135,12 @@ class NetworkSendDriver : public DriverBase { platform::getMacAddress(cid_ + 10); } - /// Close the socket on release, then chain to the base to clear any status this driver set. + /// A task still running would read a destroyed object, so the destructor stops it too. + ~NetworkSendDriver() override { stopSendTask(); } + + /// Stop the send task and close the socket on release, then chain to the base to clear any status this driver set. void release() override { + stopSendTask(); socket_.close(); nDest_ = 0; // re-derived by prepare() on the next enable DriverBase::release(); @@ -144,6 +155,7 @@ class NetworkSendDriver : public DriverBase { // All the parsing happens HERE, so tick is a bare walk of two small arrays. /// Open the socket, resolve the destinations and their window slices, and size the buffer. void prepare() override { + stopSendTask(); // the task reads the destinations and the corrected buffer, both rebuilt below socket_.open(); // idempotent: no-op if already open resizeCorrected(); @@ -179,6 +191,7 @@ class NetworkSendDriver : public DriverBase { nDest_ = n; prepareWarn_ = warn; refreshHosts(); + if (packetGap > 0) startSendTask(); } /// Re-size the corrected buffer when the fixture profile changes the output channel count. @@ -192,6 +205,8 @@ class NetworkSendDriver : public DriverBase { // No destination means idle: it never falls back to broadcasting the whole LAN. if (nDest_ == 0) return; + // The task still sending the last frame owns the corrected buffer, so this frame is skipped. + if (sendTask_.impl && frameState_.load(std::memory_order_acquire) != kFrameIdle) return; // FPS limiting if (fps == 0) return; @@ -227,13 +242,124 @@ class NetworkSendDriver : public DriverBase { totalBytes = static_cast<size_t>(nLights) * srcCh; } - // Rounded DOWN to whole fixtures: one straddling two universes reads a neighbor's channels. - size_t chunk = (protocol == kProtoDdp) ? DDP_MAX_PAYLOAD : MAX_CHANNELS_PER_UNIVERSE; - uint8_t packet[DDP_HEADER_SIZE + DDP_MAX_PAYLOAD]; // 1450 B covers all three - const uint16_t port = protocolPort(protocol); const uint8_t bytesPerLight = (data == corrected_.data() && correction_.outChannels) ? correction_.outChannels : sourceBuffer_->channelsPerLight(); + // Paced from the task when it runs and the frame sits in the corrected buffer, which the task owns until it is done. + if (sendTask_.impl && data == corrected_.data()) { + frame_ = {data, totalBytes, bytesPerLight}; + frameState_.exchange(kFrameReady, std::memory_order_acq_rel); + platform::notifyTask(sendTask_); + return; + } + sendFrame<false>(data, totalBytes, bytesPerLight, 0); + } + + // The packet builds and their inverse parses are shared with the receiver, defined once. + + /// Test-only accessor for the corrected buffer, pinning the no-allocation contract. + const Buffer& correctedBuffer() const { return corrected_; } + + /// The gap, shortened so a frame's packets fit in 80% of its `fps` period, leaving room for the next. + uint32_t effectiveGap(size_t totalBytes, uint8_t bytesPerLight) const { + // The chunk sendFrame uses: whole fixtures per universe, a full universe when a fixture is wider. + const size_t whole = bytesPerLight ? (MAX_CHANNELS_PER_UNIVERSE / bytesPerLight) * bytesPerLight : 0; + const size_t chunk = protocol == kProtoDdp ? DDP_MAX_PAYLOAD : (whole ? whole : MAX_CHANNELS_PER_UNIVERSE); + // An upper bound: one partial packet per destination on top of the whole ones. + const uint32_t packets = static_cast<uint32_t>((totalBytes + chunk - 1) / chunk) + nDest_; + const uint32_t budget = fps ? 800000u / fps : 0; + const uint32_t fit = packets > 1 ? budget / (packets - 1) : budget; + return packetGap < fit ? packetGap : fit; + } + + /// Whether a task paces the packets, rather than the tick sending a burst: the test seam for the fallback. + bool sendsInTask() const { return sendTask_.impl != nullptr; } + + /// How many receivers the parse derived, for a test pinning the fan-out arithmetic. + uint8_t destinationCount() const { return nDest_; } + /// The address of destination `i`, 0.0.0.0 for a name not resolved yet. + const uint8_t* destinationAt(uint8_t i) const { return dest_[i].ip; } + /// How many lights of the window destination `i` owns. + nrOfLightsType lightsAt(uint8_t i) const { return destCounts_[i]; } + +private: + /// The send socket, reused for every destination and closed on release. + platform::UdpSocket socket_; + /// The shared frame this driver reads its window from; borrowed, not owned. + Buffer* sourceBuffer_ = nullptr; + /// Owned: source bytes after brightness/order/white. Sized off the hot path (resizeCorrected). + Buffer corrected_; + /// Art-Net/E1.31 per-frame sequence counter; wraps at 255, which both protocols expect. + uint8_t sequence_ = 0; + /// millis() of the last frame sent: the `fps` rate limiter's reference point. + uint32_t lastSendTime_ = 0; + /// E1.31 component id, built once in setup() from the MAC so it is stable per device. + uint8_t cid_[E131_CID_LENGTH] = {}; + /// Destination hosts, derived in prepare() (never in tick()); a name's address is refreshed each second. + Host dest_[kMaxDestinations] = {}; + /// Each destination's slice of the window, index-aligned with `dest_`. + nrOfLightsType destCounts_[kMaxDestinations] = {}; + /// How many entries of `dest_` / `destCounts_` are live. + uint8_t nDest_ = 0; + /// The unresolved-names warning, which the status points at. + char statusBuf_[32] = {}; + /// The light-count warning prepare found, restored once the names resolve. + const char* prepareWarn_ = nullptr; + + // Every write to the frame state is a read-modify-write, since a module lives in PSRAM, as TryLock explains. + static constexpr uint8_t kFrameIdle = 0; ///< the tick may correct the next frame + static constexpr uint8_t kFrameReady = 1; ///< a corrected frame waits for the task + static constexpr uint8_t kFrameSending = 2; ///< the task is sending it + static constexpr uint8_t kSendRetries = 3; ///< tries after a failed send, while the transmit queue drains + static constexpr uint32_t kRetryBackoffUs = 500; ///< the wait before each of them + /// The task that paces a frame's packets, running while `packetGap` is above 0. + platform::WorkerTask sendTask_; + /// Who owns the corrected buffer: the tick while idle, the task while ready or sending. + std::atomic<uint8_t> frameState_{kFrameIdle}; + /// Set to end the send task, read before each paced packet. + std::atomic<bool> sendStop_{false}; + /// One packet being built, a member so the send task's stack stays small; the tick and the task never send at once. + uint8_t packet_[DDP_HEADER_SIZE + DDP_MAX_PAYLOAD] = {}; // 1450 B covers all three protocols + /// The frame the tick hands over: where its bytes are, how many, and their stride. + struct Frame { const uint8_t* data; size_t totalBytes; uint8_t bytesPerLight; } frame_ = {nullptr, 0, 0}; + + static void sendTrampoline(void* self) { static_cast<NetworkSendDriver*>(self)->runSendLoop(); } + + /// Wait for a frame from the tick, then send it paced, until release or prepare stops the task. + void runSendLoop() { + while (!sendStop_.load(std::memory_order_acquire)) { + uint8_t ready = kFrameReady; + if (!frameState_.compare_exchange_strong(ready, kFrameSending, std::memory_order_acq_rel)) { + platform::waitNotify(sendTask_, 100); + continue; + } + sendFrame<true>(frame_.data, frame_.totalBytes, frame_.bytesPerLight, effectiveGap(frame_.totalBytes, frame_.bytesPerLight)); + frameState_.exchange(kFrameIdle, std::memory_order_acq_rel); + } + } + + // Core 1, beside the receive task; a board that cannot start it sends from the tick, unpaced. + void startSendTask() { + if (sendTask_.impl) return; + sendStop_.store(false, std::memory_order_release); + frameState_.exchange(kFrameIdle, std::memory_order_acq_rel); + platform::spawnPinnedTask(sendTask_, "mmSend", &sendTrampoline, this, 4096, 3, 1); + } + + void stopSendTask() { + if (!sendTask_.impl) return; + sendStop_.store(true, std::memory_order_release); + platform::stopPinnedTask(sendTask_); + frameState_.exchange(kFrameIdle, std::memory_order_acq_rel); + } + + /// Chunk a frame into packets and send them, `gapUs` apart from the task (`kPaced`) and back to back from the tick, whose instance holds no wait. + template <bool kPaced> + void sendFrame(const uint8_t* data, size_t totalBytes, uint8_t bytesPerLight, uint32_t gapUs) { + // Rounded DOWN to whole fixtures: one straddling two universes reads a neighbor's channels. + size_t chunk = (protocol == kProtoDdp) ? DDP_MAX_PAYLOAD : MAX_CHANNELS_PER_UNIVERSE; + uint8_t* packet = packet_; + const uint16_t port = protocolPort(protocol); if (protocol != kProtoDdp && bytesPerLight > 1) { const size_t whole = (chunk / bytesPerLight) * bytesPerLight; // A fixture wider than a universe keeps the full one, so the failure is visible. @@ -241,6 +367,7 @@ class NetworkSendDriver : public DriverBase { } const Addressing sendMode = mode(); + uint32_t packets = 0; // sent so far this frame, so the first goes out at once size_t offset = 0; // byte cursor into `data`, walking destination by destination for (uint8_t d = 0; d < nDest_ && offset < totalBytes; d++) { // This destination's run: its light count × the wire stride, clipped to what's left. @@ -270,11 +397,22 @@ class NetworkSendDriver : public DriverBase { src, static_cast<uint16_t>(n)); break; } - // A failed send drops that packet and continues: one dark tube stalls no other. + // A pause before every packet but the first, then a failed send retried after a backoff when paced, else dropped: one dark tube stalls no other. + if constexpr (kPaced) { + if (gapUs && packets++ > 0) platform::sleepUs(sendTask_, gapUs); + if (sendStop_.load(std::memory_order_acquire)) return; + } uint8_t grp[4]; e131MulticastAddr(universe, grp); - sendAddressed(sendMode, Traffic::FrameRate, &dest_[d], 1, grp, - [&](const uint8_t ip[4]) { socket_.sendToAddr(ip, port, packet, packetLen); }); + sendAddressed(sendMode, Traffic::FrameRate, &dest_[d], 1, grp, [&](const uint8_t ip[4]) { + bool ok = socket_.sendToAddr(ip, port, packet, packetLen); + if constexpr (kPaced) { + for (uint8_t r = 0; !ok && r < kSendRetries; r++) { + platform::sleepUs(sendTask_, kRetryBackoffUs); + ok = socket_.sendToAddr(ip, port, packet, packetLen); + } + } + }); sent += n; universe++; } @@ -284,42 +422,6 @@ class NetworkSendDriver : public DriverBase { sequence_++; } - // The packet builds and their inverse parses are shared with the receiver, defined once. - - /// Test-only accessor for the corrected buffer, pinning the no-allocation contract. - const Buffer& correctedBuffer() const { return corrected_; } - - /// How many receivers the parse derived, for a test pinning the fan-out arithmetic. - uint8_t destinationCount() const { return nDest_; } - /// The address of destination `i`, 0.0.0.0 for a name not resolved yet. - const uint8_t* destinationAt(uint8_t i) const { return dest_[i].ip; } - /// How many lights of the window destination `i` owns. - nrOfLightsType lightsAt(uint8_t i) const { return destCounts_[i]; } - -private: - /// The send socket, reused for every destination and closed on release. - platform::UdpSocket socket_; - /// The shared frame this driver reads its window from; borrowed, not owned. - Buffer* sourceBuffer_ = nullptr; - /// Owned: source bytes after brightness/order/white. Sized off the hot path (resizeCorrected). - Buffer corrected_; - /// Art-Net/E1.31 per-frame sequence counter; wraps at 255, which both protocols expect. - uint8_t sequence_ = 0; - /// millis() of the last frame sent: the `fps` rate limiter's reference point. - uint32_t lastSendTime_ = 0; - /// E1.31 component id, built once in setup() from the MAC so it is stable per device. - uint8_t cid_[E131_CID_LENGTH] = {}; - /// Destination hosts, derived in prepare() (never in tick()); a name's address is refreshed each second. - Host dest_[kMaxDestinations] = {}; - /// Each destination's slice of the window, index-aligned with `dest_`. - nrOfLightsType destCounts_[kMaxDestinations] = {}; - /// How many entries of `dest_` / `destCounts_` are live. - uint8_t nDest_ = 0; - /// The unresolved-names warning, which the status points at. - char statusBuf_[32] = {}; - /// The light-count warning prepare found, restored once the names resolve. - const char* prepareWarn_ = nullptr; - /// The UDP port for a protocol index: each wire format has its own registered port. static uint16_t protocolPort(uint8_t p) { return p == kProtoE131 ? E131_PORT : p == kProtoDdp ? DDP_PORT : ARTNET_PORT; @@ -336,7 +438,9 @@ class NetworkSendDriver : public DriverBase { uint8_t ip[4]; const HostResolver::State st = HostResolver::lookup(name, ip); if (st == HostResolver::State::Busy) return; // the lookup task held the cache this instant: keep the status as it is - if (st == HostResolver::State::Resolved || st == HostResolver::State::Stale) std::memcpy(dest_[d].ip, ip, 4); + // Not while the task sends: an address it reads mid-copy would be half old, half new. + const bool sending = sendTask_.impl && frameState_.load(std::memory_order_acquire) != kFrameIdle; + if (!sending && (st == HostResolver::State::Resolved || st == HostResolver::State::Stale)) std::memcpy(dest_[d].ip, ip, 4); if (st == HostResolver::State::Waiting || st == HostResolver::State::Full) waiting++; } if (waiting) { @@ -359,7 +463,11 @@ class NetworkSendDriver : public DriverBase { const uint8_t ch = correction_.outChannels; if (n == 0 || ch == 0) return; if (corrected_.count() >= n && corrected_.channelsPerLight() >= ch) return; + // The send task reads the buffer while it paces a frame, so a reallocation waits it out and restarts it. + const bool wasSending = sendTask_.impl != nullptr; + stopSendTask(); corrected_.allocate(n, ch); + if (wasSending) startSendTask(); } }; diff --git a/src/light/effects/NetworkReceiveEffect.h b/src/light/effects/NetworkReceiveEffect.h index bfbbdad23..22005d1e6 100644 --- a/src/light/effects/NetworkReceiveEffect.h +++ b/src/light/effects/NetworkReceiveEffect.h @@ -1,5 +1,6 @@ #pragma once +#include "core/util/TryLock.h" // the staging buffer, shared by the receive task and the render copy #include "core/util/format.h" // formatTo: nonblocking formatting into a fixed buffer #include "light/effects/EffectBase.h" @@ -9,15 +10,17 @@ #include "light/layers/Layer.h" // Layer::bufferGen: is the held frame still ours? #include "platform/platform.h" // platform::UdpSocket: the three receive sockets +#include <atomic> +#include <cstring> + namespace mm { + // Author: MoonLight original (E1.31 / Art-Net receive) /// Effect that paints the layer from received Art-Net/E1.31/DDP pixels. /// @card NetworkReceiveEffect.gif /// -/// External light data is another module writing into the layer buffer. -/// So it composes with modifiers and blending like any generated effect. -/// This is the end-to-end pair with NetworkSendDriver, and the receive side for industry senders. +/// External light data written into the layer buffer, so it composes with modifiers and blending; the receive side for NetworkSendDriver and industry senders. /// /// Prior art: MoonLight's D_NetworkIn, WLED's realtime UDP input, and MoonLight v1's ArtNetInModule. /// @@ -25,17 +28,17 @@ namespace mm { /// /// ## Three protocols at once /// -/// The effect binds ArtNet 6454, E1.31 5568 and DDP 4048, validating each packet against its format. -/// There is deliberately no protocol control: whatever a sender speaks works. -/// The status field then shows which protocol is arriving and from where. -/// Controllers find the device by broadcasting ArtPoll, which this answers with ArtPollReply. +/// It binds Art-Net 6454, E1.31 5568 and DDP 4048 with no protocol control, so whatever a sender speaks works; the status names it, and an ArtPoll gets an ArtPollReply. +/// +/// ## Mapped by the receiver or by the sender /// -/// ## Hold-last-frame, through a staging buffer +/// A receiver-mapped frame arrives row by row, and the layer applies this board's wiring; a sender-mapped one (xLights, FSEQ, Network Send) arrives in wiring order. +/// On an identity mapping that is one copy, at any number of layers. Any other mapping sends the frame back to the grid and forward again, 4.5 ms against 1.1 ms on an S3 at 12,288 lights. /// -/// Packets drain into an owned staging buffer, which is copied to the layer each tick. -/// Without that the lights would strobe black between frames. -/// The drain is non-blocking and bounded per tick, so a packet flood cannot wedge the render loop. -/// Sequence fields and DDP's push flag are ignored, so the last write into staging wins. +/// ## Held frames, drained by a task +/// +/// A frame is a burst of packets that lwIP holds in a small mailbox, so a task on core 1 drains them into staging as they arrive. +/// The render tick copies a changed frame into the layer, so the lights hold the last frame rather than strobe black. class NetworkReceiveEffect : public EffectBase { public: /// Catalog tags: network input, of MoonLight and v1 lineage. @@ -47,15 +50,53 @@ class NetworkReceiveEffect : public EffectBase { uint16_t universeStart = 0; /// Bytes a universe maps to, which also clamps the payload against bleeding into the next. uint16_t channelsPerUniverse = static_cast<uint16_t>(MAX_CHANNELS_PER_UNIVERSE); + /// Who maps a frame onto the wiring, in the `mapped_by` options' order: row by row with this board applying its wiring, or in the order the lights are wired. + enum MappedBy : uint8_t { kMappedByReceiver, kMappedBySender, kMappedByCount }; + /// The `mapped_by` option names. + static constexpr const char* kMappers[kMappedByCount] = {"receiver", "sender"}; + /// Who maps the frame onto the wiring. @xref{mapped-by-the-receiver-or-by-the-sender} + uint8_t mappedBy = kMappedByReceiver; + /// Packets received in the last second, of any protocol. + uint16_t packetsPerS = 0; + /// Frames received in the last second: a DDP push, or the first universe of an Art-Net or E1.31 frame. + uint16_t framesPerS = 0; - /// Publish the universe start and the per-universe stride. + /// Publish the universe start, the per-universe stride and who maps. void defineControls() override { controls_.addControl("universe_start", universeStart); controls_.addControl("channels_per_universe", channelsPerUniverse); + controls_.addSelect("mapped_by", mappedBy, kMappers, kMappedByCount); + controls_.addControl("packets_per_s", packetsPerS, 0, 65535); + controls_.setReadOnly(controls_.count() - 1, true); + controls_.setLive(controls_.count() - 1); + controls_.addControl("frames_per_s", framesPerS, 0, 65535); + controls_.setReadOnly(controls_.count() - 1, true); + controls_.setLive(controls_.count() - 1); } - /// Close the three sockets, then chain so the staging buffer is freed too. + /// Publish what arrived since the last call as a per-second rate, so loss shows against what the sender sent. + void tick1s() MM_NONBLOCKING override { + const uint32_t now = platform::millis(); + const uint32_t span = now - rateSinceMs_; + rateSinceMs_ = now; + const uint32_t packets = packets_.exchange(0, std::memory_order_relaxed); + const uint32_t frames = frames_.exchange(0, std::memory_order_relaxed); + if (span > 0) { + packetsPerS = saturate16(static_cast<uint32_t>(uint64_t{packets} * 1000 / span)); + framesPerS = saturate16(static_cast<uint32_t>(uint64_t{frames} * 1000 / span)); + } + EffectBase::tick1s(); + } + + /// Who maps sizes staging, by logical cells or by physical lights, so only it rebuilds. + bool affectsPrepare(const char* name) const override { return std::strcmp(name, "mapped_by") == 0; } + + /// A task still running would read a destroyed object, so the destructor stops it too. + ~NetworkReceiveEffect() override { stopReceiveTask(); } + + /// Stop the receive task, close the three sockets, then chain so the staging buffer is freed too. void release() override { + stopReceiveTask(); // Chaining is required, or the registered staging buffer leaks on disable. artnetSocket_.close(); e131Socket_.close(); @@ -64,8 +105,9 @@ class NetworkReceiveEffect : public EffectBase { clearStatus(); } - /// Bind the three receive sockets independently, and size the staging buffer to the layer. + /// Bind the three receive sockets independently, and size the staging buffer to the layer. @xref{three-protocols-at-once} void prepare() override { + stopReceiveTask(); // the task writes into staging, which is resized below const bool artnetOk = artnetSocket_.open() && artnetSocket_.bind(ARTNET_PORT); const bool e131Ok = e131Socket_.open() && e131Socket_.bind(E131_PORT); const bool ddpOk = ddpSocket_.open() && ddpSocket_.bind(DDP_PORT); @@ -74,86 +116,96 @@ class NetworkReceiveEffect : public EffectBase { } else { setStatus(kBindFailMsg, Severity::Error); } - // One byte per channel byte, zero-filled so a fresh grid starts dark. - staging_.resize(static_cast<size_t>(nrOfLights()) * channelsPerLight()); - dirty_ = true; // a resize (or a re-enable) must repaint the layer from staging once + // One byte per channel byte, zero-filled so a fresh grid starts dark; a sender-mapped frame is indexed by physical light. + const auto* layer = static_cast<const Layer*>(parent()); + const size_t lights = mappedBy == kMappedBySender && layer ? layer->physicalLightCount() : nrOfLights(); + staging_.resize(lights * channelsPerLight()); + dirty_.store(true, std::memory_order_release); // a resize (or a re-enable) must repaint the layer from staging once + packets_.exchange(0, std::memory_order_relaxed); // the rates restart with the stream, not from boot + frames_.exchange(0, std::memory_order_relaxed); + rateSinceMs_ = platform::millis(); + startReceiveTask(); } - /// Drain all three sockets, then copy staging to the layer when anything changed. + /// Drain all three sockets, then copy staging to the layer when anything changed. @xref{held-frames-drained-by-a-task} void tick() MM_NONBLOCKING override { if (!staging_) return; - // Bounded per socket, so a flood waits in the socket buffers rather than wedging the tick. - uint16_t universe = 0, dataLen = 0; - uint32_t byteOffset = 0; - const uint8_t* data = nullptr; - uint8_t srcIp[4]; - for (int i = 0; i < kMaxPacketsPerTick; i++) { - const int n = artnetSocket_.recvFrom(pkt_, sizeof(pkt_), srcIp); - if (n <= 0) break; - if (parseArtDmxPacket(pkt_, static_cast<size_t>(n), universe, data, dataLen)) { - applyDmx(universe, data, dataLen); - noteReceiving("Art-Net", srcIp); - } else if (isArtPoll(pkt_, static_cast<size_t>(n))) { - replyToPoll(srcIp); // make the device show up in controller node lists - } - } - for (int i = 0; i < kMaxPacketsPerTick; i++) { - const int n = e131Socket_.recvFrom(pkt_, sizeof(pkt_), srcIp); - if (n <= 0) break; - if (parseE131Packet(pkt_, static_cast<size_t>(n), universe, data, dataLen)) { - applyDmx(universe, data, dataLen); - noteReceiving("E1.31", srcIp); - } - } - for (int i = 0; i < kMaxPacketsPerTick; i++) { - const int n = ddpSocket_.recvFrom(pkt_, sizeof(pkt_), srcIp); - if (n <= 0) break; - if (parseDdpPacket(pkt_, static_cast<size_t>(n), byteOffset, data, dataLen)) { - applyBytes(byteOffset, data, dataLen); - noteReceiving("DDP", srcIp); - } - } + if (!receiveTask_.impl) drainSockets<false>(); // a board without tasks, the Teensy, drains here as packets wait + const auto* layer = static_cast<const Layer*>(parent()); + // A sender-mapped frame through a mapping that is not the identity is mapped back and forward again, which the status shows. + const bool sender = mappedBy == kMappedBySender && layer; + publishSender(sender ? (layer->lut().hasLUT() ? kPathTwice : kPathOneCopy) : kPathNone); // An unchanged buffer needs no copy, so the layer's write generation decides. uint8_t* buf = buffer(); if (!buf) return; - const auto* layer = static_cast<const Layer*>(parent()); const uint32_t gen = layer ? layer->bufferGen() : 0; - if (!dirty_ && layer && gen == lastGen_) return; - dirty_ = false; + if (!dirty_.load(std::memory_order_acquire) && layer && gen == lastGen_) return; + LockGuard guard(stagingLock_); + if (!guard) return; // the task is writing a packet: copy on the next tick + dirty_.store(false, std::memory_order_release); const size_t bufBytes = static_cast<size_t>(nrOfLights()) * channelsPerLight(); - std::memcpy(buf, staging_.data(), staging_.bytes() < bufBytes ? staging_.bytes() : bufBytes); + if (sender) { + layer->lut().gatherFromPhysical(buf, bufBytes, staging_.data(), staging_.bytes(), channelsPerLight()); + } else { + std::memcpy(buf, staging_.data(), staging_.bytes() < bufBytes ? staging_.bytes() : bufBytes); + } // The Layer bumps the generation after this tick, so record what our own copy produces. lastGen_ = gen + 1; } - /// Place one universe's payload, clamped to its stride and ignoring universes outside the buffer. + /// Place one universe's payload, clamped to its stride and ignoring universes outside the buffer; `kWait` as for applyBytes. + template <bool kWait = true> void applyDmx(uint16_t universe, const uint8_t* data, uint16_t len) { if (universe < universeStart || channelsPerUniverse == 0) return; if (len > channelsPerUniverse) len = channelsPerUniverse; - applyBytes(static_cast<size_t>(universe - universeStart) * channelsPerUniverse, - data, len); + applyBytes<kWait>(static_cast<size_t>(universe - universeStart) * channelsPerUniverse, data, len); } /// The one clamped write into staging, bound-checked before any addition against an overflow. + template <bool kWait = true> void applyBytes(size_t offset, const uint8_t* data, uint16_t len) { if (!staging_ || offset >= staging_.bytes()) return; size_t n = len; if (offset + n > staging_.bytes()) n = staging_.bytes() - offset; + // The task waits out the render copy, one frame at most; the tick drains only where no task runs, so it never finds the lock taken. + if constexpr (kWait) { + while (!stagingLock_.tryAcquire()) platform::yield(); + } else if (!stagingLock_.tryAcquire()) { + return; + } std::memcpy(staging_.data() + offset, data, n); - dirty_ = true; // the one write into staging, which is what tick() copies out + stagingLock_.release(); + dirty_.store(true, std::memory_order_release); // the one write into staging, which is what tick() copies out } + /// Whether a receive task drains the sockets, rather than the render tick: the test seam for the fallback. + bool receivesInTask() const { return receiveTask_.impl != nullptr; } + /// Test seam: the staging bytes, so a test can pin the buffer's lifecycle. const uint8_t* stagingData() const { return staging_.data(); } /// Test seam: how many staging bytes are allocated. size_t stagingBytes() const { return staging_.bytes(); } + /// Note the last sender, per packet from the receive path; a test calls it to stand in for one. + void recordSender(const char* proto, const uint8_t ip[4]) { + senderIp_.store(static_cast<uint32_t>(ip[0]) << 24 | static_cast<uint32_t>(ip[1]) << 16 | + static_cast<uint32_t>(ip[2]) << 8 | ip[3], std::memory_order_relaxed); + senderProto_.store(proto, std::memory_order_release); + } + private: - static constexpr int kMaxPacketsPerTick = 128; + static constexpr int kMaxPacketsPerDrain = 128; ///< packets one drain takes per socket, so a flood cannot hold the caller + static constexpr uint32_t kWaitMs = 20; ///< how long the task waits for a packet before it checks for a stop static constexpr const char* kBindFailMsg = "UDP bind failed, port in use?"; // A member rather than a stack buffer: setStatus holds the pointer, so the string must outlive it. - char recvStatus_[40] = ""; ///< "receiving <protocol> from <ip>", sized for the longest form + char recvStatus_[56] = ""; ///< "receiving <protocol> from <ip>, <path>", sized for the longest form const char* lastProto_ = nullptr; ///< the last protocol literal the status was built from + uint8_t lastPath_ = 0; ///< the last path the status was built from + /// What a frame costs on its way to the drivers: no note for receiver mapping, which maps once as every layer does. + static constexpr uint8_t kPathNone = 0; + static constexpr uint8_t kPathOneCopy = 1; ///< sender mapping through an identity mapping + static constexpr uint8_t kPathTwice = 2; ///< sender mapping mapped back to the grid and forward again + static constexpr const char* kPathNotes[3] = {"", ", one copy", ", mapped twice"}; uint8_t lastIp_[4] = {}; ///< the last sender IP the status was built from platform::UdpSocket artnetSocket_; ///< ArtNet, port 6454 @@ -162,21 +214,110 @@ class NetworkReceiveEffect : public EffectBase { uint8_t pkt_[1500] = {}; ///< one datagram of any protocol, DDP reaching 1450 ScratchBuffer<uint8_t> staging_{*this}; ///< the held frame, sized to the layer /// Staging changed since the last copy, starting true so a fresh buffer paints once. - bool dirty_ = true; + std::atomic<bool> dirty_{true}; + std::atomic<uint32_t> packets_{0}; ///< packets since the last tick1s, counted by the receive path + std::atomic<uint32_t> frames_{0}; ///< frames since the last tick1s, counted by the receive path + uint32_t rateSinceMs_ = 0; ///< when the counts last restarted, which the rates divide by + TryLock stagingLock_; ///< staging, between the receive task's writes and the render copy + platform::WorkerTask receiveTask_; ///< drains the sockets as packets arrive, on core 1 + std::atomic<bool> receiveStop_{false}; + std::atomic<const char*> senderProto_{nullptr}; ///< the last sender's protocol, which the render tick publishes + std::atomic<uint32_t> senderIp_{0}; ///< and its address, as the four octets in order + + static void receiveTrampoline(void* self) { static_cast<NetworkReceiveEffect*>(self)->runReceiveLoop(); } + + /// Wait for packets and drain them, until release or prepare stops the task. + void runReceiveLoop() { + platform::UdpSocket* sockets[3] = {&artnetSocket_, &e131Socket_, &ddpSocket_}; + while (!receiveStop_.load(std::memory_order_acquire)) { + if (platform::UdpSocket::waitReadable(sockets, 3, kWaitMs) > 0) drainSockets<true>(); + } + } + + // Core 1, away from the render loop; a single-core chip runs it unpinned, and a board without tasks drains in the tick. + void startReceiveTask() { + receiveStop_.store(false, std::memory_order_release); + platform::spawnPinnedTask(receiveTask_, "mmReceive", &receiveTrampoline, this, 4096, 3, 1); + } + + void stopReceiveTask() { + if (!receiveTask_.impl) return; + receiveStop_.store(true, std::memory_order_release); + platform::stopPinnedTask(receiveTask_); + } + + /// Drain all three sockets into staging, bounded per socket so a flood cannot hold the caller; `kWait` when the receive task drains. + template <bool kWait> + void drainSockets() { + uint16_t universe = 0, dataLen = 0; + uint32_t byteOffset = 0; + const uint8_t* data = nullptr; + uint8_t srcIp[4]; + for (int i = 0; i < kMaxPacketsPerDrain; i++) { + const int n = artnetSocket_.recvFrom(pkt_, sizeof(pkt_), srcIp); + if (n <= 0) break; + if (parseArtDmxPacket(pkt_, static_cast<size_t>(n), universe, data, dataLen)) { + countPacket(universe == universeStart); // before the write, so a frame seen in the layer is already counted + applyDmx<kWait>(universe, data, dataLen); + recordSender("Art-Net", srcIp); + } else if (isArtPoll(pkt_, static_cast<size_t>(n))) { + replyToPoll(srcIp); // make the device show up in controller node lists + } + } + for (int i = 0; i < kMaxPacketsPerDrain; i++) { + const int n = e131Socket_.recvFrom(pkt_, sizeof(pkt_), srcIp); + if (n <= 0) break; + if (parseE131Packet(pkt_, static_cast<size_t>(n), universe, data, dataLen)) { + countPacket(universe == universeStart); + applyDmx<kWait>(universe, data, dataLen); + recordSender("E1.31", srcIp); + } + } + for (int i = 0; i < kMaxPacketsPerDrain; i++) { + const int n = ddpSocket_.recvFrom(pkt_, sizeof(pkt_), srcIp); + if (n <= 0) break; + bool push = false; + if (parseDdpPacket(pkt_, static_cast<size_t>(n), byteOffset, data, dataLen, &push)) { + countPacket(push); + applyBytes<kWait>(byteOffset, data, dataLen); + recordSender("DDP", srcIp); + } + } + } + + // Read-modify-writes on both sides, since a module lives in PSRAM, as TryLock explains. + /// Count one packet, and a frame when it starts or ends one. + void countPacket(bool frame) { + packets_.fetch_add(1, std::memory_order_relaxed); + if (frame) frames_.fetch_add(1, std::memory_order_relaxed); + } + /// A per-second count as its 16-bit control, held at the top rather than wrapping. + static uint16_t saturate16(uint32_t v) { return v > 0xFFFF ? 0xFFFF : static_cast<uint16_t>(v); } + + /// Show the last sender in the status, from the render side, which owns the status. + void publishSender(uint8_t path) { + const char* proto = senderProto_.load(std::memory_order_acquire); + if (!proto) return; + const uint32_t v = senderIp_.load(std::memory_order_relaxed); + const uint8_t ip[4] = {static_cast<uint8_t>(v >> 24), static_cast<uint8_t>(v >> 16), + static_cast<uint8_t>(v >> 8), static_cast<uint8_t>(v)}; + noteReceiving(proto, ip, path); + } /// The layer's write generation as of our last copy, which is what makes skipping it safe. uint32_t lastGen_ = 0; /// Update the receiving diagnostic, short-circuiting the common case since this is the hot path. - void noteReceiving(const char* proto, const uint8_t ip[4]) { + void noteReceiving(const char* proto, const uint8_t ip[4], uint8_t path) { const char* s = status(); if (s != nullptr && s != recvStatus_) return; // a bind error (or foreign status) wins - // Already showing this source, so skip both the format and the setStatus. - if (s == recvStatus_ && proto == lastProto_ && std::memcmp(ip, lastIp_, 4) == 0) return; + // Already showing this source and path, so skip both the format and the setStatus. + if (s == recvStatus_ && proto == lastProto_ && path == lastPath_ && std::memcmp(ip, lastIp_, 4) == 0) return; lastProto_ = proto; + lastPath_ = path; std::memcpy(lastIp_, ip, 4); - mm::formatTo(recvStatus_, sizeof(recvStatus_), "receiving %s from %u.%u.%u.%u", proto, + mm::formatTo(recvStatus_, sizeof(recvStatus_), "receiving %s from %u.%u.%u.%u%s", proto, static_cast<unsigned>(ip[0]), static_cast<unsigned>(ip[1]), - static_cast<unsigned>(ip[2]), static_cast<unsigned>(ip[3])); + static_cast<unsigned>(ip[2]), static_cast<unsigned>(ip[3]), kPathNotes[path]); setStatus(recvStatus_, Severity::Status); } diff --git a/src/light/layers/MappingLUT.h b/src/light/layers/MappingLUT.h index 416f283a7..74e48542f 100644 --- a/src/light/layers/MappingLUT.h +++ b/src/light/layers/MappingLUT.h @@ -7,31 +7,21 @@ namespace mm { -/// The table mapping each logical light to the physical lights it drives. -/// -/// A logical light can map to one physical light, to none, or to many. -/// The sequential identity case needs no table at all and is the fast path. +/// The table mapping each logical light to the physical lights it drives: one, none or many, with no table at all on the sequential identity fast path. /// /// @moreinfo /// /// ## Four mapping kinds /// -/// Identity means the logical index is the physical index, which a plain grid gives. -/// A shuffled map reorders, as a serpentine grid does. -/// An unmapped logical light has no physical output, which a sparse layout produces. -/// A multimap drives several physical lights from one logical light, which mirroring produces. -/// The last three need a table. +/// Identity (a plain grid) needs no table; a shuffled map (serpentine), an unmapped light (a sparse layout) and a multimap (mirroring) each need one. /// /// ## Compressed sparse row /// -/// Two arrays: one indexes each logical light into a run, the other holds the flat destinations. -/// The container supplies the total, so every destination is in bounds by construction. +/// One array indexes each logical light into a run, the other holds the flat destinations, and the container supplies the total, so every destination is in bounds. /// /// ## Paged destinations /// -/// A large map on a board without PSRAM can exceed the largest contiguous block while heap remains. -/// The destinations then split into power-of-two pages that each fit a fragmented heap. -/// Paging is the exception: output is identical either way, so it stays an allocation detail. +/// A large map on a board without PSRAM can outgrow the largest free block, so the destinations split into power-of-two pages; the output is identical either way. class MappingLUT { public: /// An empty table, in identity mode until one is built. @@ -121,7 +111,7 @@ class MappingLUT { overwrites_ = true; } - /// Whether a table is allocated, rather than the identity fast path. + /// Whether a table is allocated, rather than the identity fast path. @xref{four-mapping-kinds} bool hasLUT() const { return !identity_; } /// Whether the destinations are split into pages. bool isPaged() const { return paged_; } @@ -149,7 +139,7 @@ class MappingLUT { + static_cast<size_t>(maxDest) * sizeof(nrOfLightsType); } - /// Walk the physical destinations of one logical light, on the hot path. + /// Walk the physical destinations of one logical light, on the hot path. @xref{compressed-sparse-row} template<typename F> void forEachDestination(nrOfLightsType logicalIdx, F&& callback) const { if (identity_) { @@ -173,8 +163,43 @@ class MappingLUT { } } + /// Write a wiring-order frame into the logical buffer, inverting this mapping: each cell takes its first physical light's value; identity is one copy. + void gatherFromPhysical(uint8_t* logical, size_t logicalBytes, const uint8_t* physical, size_t physicalBytes, + uint8_t channelsPerLight) const { + if (identity_) { + std::memcpy(logical, physical, logicalBytes < physicalBytes ? logicalBytes : physicalBytes); + return; + } + if (!offsets_ || channelsPerLight == 0) return; + if (paged_) gatherWalk(logical, logicalBytes, physical, physicalBytes, channelsPerLight, + [this](nrOfLightsType s) { return pages_[s >> kPageShift][s & kPageMask]; }); + else gatherWalk(logical, logicalBytes, physical, physicalBytes, channelsPerLight, + [this](nrOfLightsType s) { return destinations_[s]; }); + } + private: - // Tier the destinations allocation. Returns false only on tier 3. + /// The gather loop, one instance per storage tier so the page test leaves the loop; each offset is read once. + template <class Dest> + void gatherWalk(uint8_t* logical, size_t logicalBytes, const uint8_t* physical, size_t physicalBytes, + uint8_t channelsPerLight, Dest dest) const { + const size_t cpl = channelsPerLight; + const size_t physLights = physicalBytes / cpl; + const size_t cells = logicalBytes / cpl < logicalCount_ ? logicalBytes / cpl : logicalCount_; + nrOfLightsType start = offsets_[0]; + for (size_t li = 0; li < cells; li++) { + const nrOfLightsType here = start; + start = offsets_[li + 1]; + if (here == start) continue; // a cell no light shows keeps its value + const size_t first = dest(here); + if (first >= physLights) continue; + const uint8_t* s = physical + first * cpl; + uint8_t* d = logical + li * cpl; + if (cpl == 3) { d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; } + else std::memcpy(d, s, cpl); + } + } + + // Tier the destinations allocation, false only on tier 3. @xref{paged-destinations} bool allocateDestinations(size_t destBytes, nrOfLightsType maxDestinations) { if (destBytes == 0) { paged_ = false; return true; } // nothing to map diff --git a/src/light/util/DdpPacket.h b/src/light/util/DdpPacket.h index 1def28ffc..896db6c54 100644 --- a/src/light/util/DdpPacket.h +++ b/src/light/util/DdpPacket.h @@ -29,11 +29,13 @@ namespace mm { constexpr uint16_t DDP_PORT = 4048; constexpr size_t DDP_HEADER_SIZE = 10; constexpr size_t DDP_MAX_PAYLOAD = 1440; // 480 RGB / 360 RGBW lights; divisible by 3 and 4 +/// The flags bit marking a frame's last packet. +constexpr uint8_t DDP_FLAG_PUSH = 0x01; /// Build a data packet; `push` marks a frame's last packet, which a double-buffering receiver shows on. inline size_t buildDdpPacket(uint8_t* outBuf, uint32_t offset, bool push, const uint8_t* data, uint16_t dataLen) { - outBuf[0] = static_cast<uint8_t>(0x40 | (push ? 0x01 : 0x00)); + outBuf[0] = static_cast<uint8_t>(0x40 | (push ? DDP_FLAG_PUSH : 0x00)); outBuf[1] = 0; // sequence unused outBuf[2] = 0x01; // RGB outBuf[3] = 0x01; // default display @@ -47,9 +49,9 @@ inline size_t buildDdpPacket(uint8_t* outBuf, uint32_t offset, bool push, return DDP_HEADER_SIZE + dataLen; } -/// Parse and validate a data packet, zero-copy; everything but the version and length is ignored. +/// Parse and validate a data packet, zero-copy, reporting its push flag when asked; the version and length are the only checks. inline bool parseDdpPacket(const uint8_t* pkt, size_t len, uint32_t& offsetOut, - const uint8_t*& dataOut, uint16_t& dataLenOut) { + const uint8_t*& dataOut, uint16_t& dataLenOut, bool* pushOut = nullptr) { if (!pkt || len < DDP_HEADER_SIZE) return false; if ((pkt[0] & 0xC0) != 0x40) return false; // version must be 01 const uint16_t dataLen = static_cast<uint16_t>((pkt[8] << 8) | pkt[9]); @@ -58,6 +60,7 @@ inline bool parseDdpPacket(const uint8_t* pkt, size_t len, uint32_t& offsetOut, | (static_cast<uint32_t>(pkt[6]) << 8) | pkt[7]; dataOut = pkt + DDP_HEADER_SIZE; dataLenOut = dataLen; + if (pushOut) *pushOut = (pkt[0] & DDP_FLAG_PUSH) != 0; return true; } diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 7ba00083d..b0b2bcbe2 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -175,6 +175,7 @@ #include <winhttp.h> // https POST: Windows' own TLS, so the .exe needs no bundled library #else #include <sys/socket.h> +#include <sys/select.h> // select: a receive task's wait over its UDP sockets #include <netinet/in.h> #include <arpa/inet.h> #include <netdb.h> // getaddrinfo: hostname resolution for TcpConnection::connect @@ -511,6 +512,13 @@ bool waitNotify(WorkerTask& t, uint32_t timeoutMs) { return true; // woken by a notify OR stop; the fn re-checks its stop flag } +void sleepUs(WorkerTask& t, uint32_t us) { + auto* w = static_cast<DesktopWorker*>(t.impl); + if (!w) return; + std::unique_lock<std::mutex> lk(w->mtx); + w->cv.wait_for(lk, std::chrono::microseconds(us), [w] { return w->stop; }); +} + void stopPinnedTask(WorkerTask& t) { auto* w = static_cast<DesktopWorker*>(t.impl); if (!w) return; @@ -2139,6 +2147,24 @@ int UdpSocket::recvFrom(uint8_t* buf, size_t maxLen, uint8_t srcIp[4], uint16_t* return static_cast<int>(n); } +// select() over the bound sockets, the textbook wait for a receive task; the render loop keeps polling recvFrom instead. +int UdpSocket::waitReadable(UdpSocket* const* sockets, int count, uint32_t timeoutMs) { + fd_set readable; + FD_ZERO(&readable); + int maxFd = -1; + for (int i = 0; i < count; i++) { + if (!sockets[i] || sockets[i]->fd_ < 0) continue; + FD_SET(sock(sockets[i]->fd_), &readable); + if (sockets[i]->fd_ > maxFd) maxFd = sockets[i]->fd_; + } + if (maxFd < 0) { delayMs(timeoutMs); return 0; } // nothing open: wait out the period rather than spin + timeval tv{}; + tv.tv_sec = static_cast<decltype(tv.tv_sec)>(timeoutMs / 1000); + tv.tv_usec = static_cast<decltype(tv.tv_usec)>((timeoutMs % 1000) * 1000); + const int r = ::select(maxFd + 1, &readable, nullptr, nullptr, &tv); + return r < 0 ? 0 : r; +} + // Test override forcing a multicast join to fail, since a test cannot take the host's network away. static std::atomic<bool> testJoinFails{false}; void setTestJoinFails(bool fail) { testJoinFails.store(fail, std::memory_order_relaxed); } diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 4c2804507..e9ed36769 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -1987,6 +1987,24 @@ int UdpSocket::recvFrom(uint8_t* buf, size_t maxLen, uint8_t srcIp[4], uint16_t* return static_cast<int>(n); } +// select() over the bound sockets, the textbook wait for a receive task; the render loop keeps polling recvFrom instead. +int UdpSocket::waitReadable(UdpSocket* const* sockets, int count, uint32_t timeoutMs) { + fd_set readable; + FD_ZERO(&readable); + int maxFd = -1; + for (int i = 0; i < count; i++) { + if (!sockets[i] || sockets[i]->fd_ < 0) continue; + FD_SET(sockets[i]->fd_, &readable); + if (sockets[i]->fd_ > maxFd) maxFd = sockets[i]->fd_; + } + if (maxFd < 0) { delayMs(timeoutMs); return 0; } // nothing open: wait out the period rather than spin + timeval tv{}; + tv.tv_sec = static_cast<decltype(tv.tv_sec)>(timeoutMs / 1000); + tv.tv_usec = static_cast<decltype(tv.tv_usec)>((timeoutMs % 1000) * 1000); + const int r = ::select(maxFd + 1, &readable, nullptr, nullptr, &tv); + return r < 0 ? 0 : r; +} + // Join an IPv4 multicast group so the bound socket receives datagrams sent to it (WLED audio sync multicasts to 239.0.0.1). INADDR_ANY as the interface lets lwip pick the default route's netif. bool UdpSocket::membership(const char* group, bool join) { if (fd_ < 0 || !group) return false; diff --git a/src/platform/esp32/platform_esp32_worker.cpp b/src/platform/esp32/platform_esp32_worker.cpp index 2dc3a9261..dfd439c3c 100644 --- a/src/platform/esp32/platform_esp32_worker.cpp +++ b/src/platform/esp32/platform_esp32_worker.cpp @@ -37,6 +37,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_task_wdt.h" // the render loop and the encode worker each subscribe themselves so a wedge self-heals (reboot) not hangs +#include "esp_timer.h" // sleepUs: a one-shot timer wakes the task, finer than the scheduler tick #include <atomic> #include <new> @@ -56,12 +57,19 @@ struct EspWorker { std::atomic<bool> finished{false}; // Ownership handshake for the timeout path: normally stopPinnedTask waits for `finished` and frees `w`. If it TIMES OUT (the worker couldn't be scheduled, a same-core teardown), it hands ownership to the trampoline by setting `detached`, and whichever of the two runs last frees `w`. The atomic-exchange below makes exactly one side win, so `w` is freed once and never used-after-free. std::atomic<bool> detached{false}; + /// The one-shot timer sleepUs arms, created on the task's first sleep and deleted as the task ends. + esp_timer_handle_t sleepTimer = nullptr; }; // The trampoline: run the caller's function, which owns its own loop and returns once it sees the stop flag, then delete the task. The function manages its own watchdog membership, subscribing at loop start and unsubscribing before returning; one that never subscribes leaves the feed a no-op, so this stays generic. void workerTrampoline(void* arg) { auto* w = static_cast<EspWorker*>(arg); w->fn(w->user); // runs until stopPinnedTask flips w->stop and wakes it + if (w->sleepTimer) { + esp_timer_stop(w->sleepTimer); + esp_timer_delete(w->sleepTimer); + w->sleepTimer = nullptr; + } w->finished.store(true, std::memory_order_release); // If stopPinnedTask already timed out and detached, IT is gone and won't free `w`, we own it now. The exchange makes exactly one side observe `detached==true` first: whoever sees it frees `w`. if (w->detached.exchange(true, std::memory_order_acq_rel)) delete w; @@ -98,6 +106,21 @@ bool waitNotify(WorkerTask& t, uint32_t timeoutMs) { return ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(timeoutMs)) != 0; } +// A one-shot esp_timer notifies the task, so a gap below the 10 ms scheduler tick costs no busy-wait; a stop's notification ends it early, as it should. +void sleepUs(WorkerTask& t, uint32_t us) { + auto* w = static_cast<EspWorker*>(t.impl); + if (!w || w->stop.load(std::memory_order_acquire)) return; + if (!w->sleepTimer) { + const esp_timer_create_args_t args = { + .callback = [](void* arg) { xTaskNotifyGive(static_cast<EspWorker*>(arg)->handle); }, + .arg = w, .dispatch_method = ESP_TIMER_TASK, .name = "sleepUs", .skip_unhandled_events = true}; + if (esp_timer_create(&args, &w->sleepTimer) != ESP_OK) { w->sleepTimer = nullptr; vTaskDelay(1); return; } + } + if (esp_timer_start_once(w->sleepTimer, us) != ESP_OK) { vTaskDelay(1); return; } + ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(us / 1000 + 20)); // bounded, so a lost wake costs one gap and no hang + esp_timer_stop(w->sleepTimer); // a stop that woke it early leaves the timer armed +} + void stopPinnedTask(WorkerTask& t) { auto* w = static_cast<EspWorker*>(t.impl); if (!w) return; diff --git a/src/platform/platform.h b/src/platform/platform.h index 6a5d1d62b..3e2ead9af 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -157,6 +157,8 @@ bool spawnPinnedTask(WorkerTask& t, const char* name, WorkerFn fn, void* user, void notifyTask(WorkerTask& t); /// Block inside the spawned fn until notifyTask fires or `timeoutMs` elapses; false on timeout. bool waitNotify(WorkerTask& t, uint32_t timeoutMs); +/// Block inside the spawned fn for `us` microseconds, finer than the scheduler tick and leaving the core free; a stop wakes it early. +void sleepUs(WorkerTask& t, uint32_t us); /// Signal stop and wake, then block until the worker fn has returned and the task is torn down. void stopPinnedTask(WorkerTask& t); /// Subscribe THIS task to the watchdog, so a wedge reboots instead of hanging silently. @@ -845,6 +847,8 @@ class UdpSocket { // A datagram longer than `maxLen` is truncated; `srcIp` and `srcPort` also answer who sent it, which a reply is addressed to. /// Receive one datagram without blocking: bytes copied, or -1 when nothing is pending. int recvFrom(uint8_t* buf, size_t maxLen, uint8_t srcIp[4] = nullptr, uint16_t* srcPort = nullptr); + /// Block until any of `sockets` has a datagram waiting or `timeoutMs` passes: how many are readable, 0 on a timeout, -1 where a platform cannot wait. + static int waitReadable(UdpSocket* const* sockets, int count, uint32_t timeoutMs); /// Send once to an explicit address. bool sendToAddr(const uint8_t ip[4], uint16_t port, const uint8_t* data, size_t len); // Without the membership the OS never delivers those datagrams, however correct the port. diff --git a/src/platform/teensy/platform_teensy.cpp b/src/platform/teensy/platform_teensy.cpp index 58be1ae1c..726433c8a 100644 --- a/src/platform/teensy/platform_teensy.cpp +++ b/src/platform/teensy/platform_teensy.cpp @@ -127,6 +127,7 @@ const char* renderTaskName() { return ""; } bool spawnPinnedTask(WorkerTask&, const char*, WorkerFn, void*, size_t, uint8_t, int) { return false; } void notifyTask(WorkerTask&) {} bool waitNotify(WorkerTask&, uint32_t) { return false; } +void sleepUs(WorkerTask&, uint32_t) {} void stopPinnedTask(WorkerTask&) {} // --- Watchdog: WDOG1 on the one loop, which feeds it every tick -------------------------------- @@ -498,7 +499,12 @@ DnsStep g_dns; } // namespace LookupStep resolveHostStep(const char* name, uint8_t ip[4]) { - if (!name || !ethConnected() || std::strlen(name) >= sizeof(g_dns.name)) return LookupStep::Failed; + // A lookup the lost connection carried is abandoned, so a reconnect starts afresh and its late answer is ignored. + if (!ethConnected()) { + if (g_dns.state != DnsState::Idle) { ++g_dns.generation; g_dns.state = DnsState::Idle; } + return LookupStep::Failed; + } + if (!name || std::strlen(name) >= sizeof(g_dns.name)) return LookupStep::Failed; if (g_dns.state == DnsState::Idle || std::strcmp(g_dns.name, name) != 0) { std::strcpy(g_dns.name, name); const uint32_t generation = ++g_dns.generation; @@ -603,6 +609,9 @@ bool UdpSocket::bind(uint16_t port, const uint8_t[4]) { return false; } +// One loop and no tasks: the receive side polls recvFrom from the render loop, so there is nothing to wait in. +int UdpSocket::waitReadable(UdpSocket* const*, int, uint32_t) { return -1; } + int UdpSocket::recvFrom(uint8_t* buf, size_t maxLen, uint8_t srcIp[4], uint16_t* srcPort) { if (fd_ < 0) return -1; EthernetUDP& udp = g_udp[fd_]; diff --git a/test/scenarios/device/scenario_NetworkReceive_two_boards.json b/test/scenarios/device/scenario_NetworkReceive_two_boards.json new file mode 100644 index 000000000..83382f727 --- /dev/null +++ b/test/scenarios/device/scenario_NetworkReceive_two_boards.json @@ -0,0 +1,3826 @@ +{ + "name": "scenario_NetworkReceive_two_boards", + "module": "NetworkReceiveEffect", + "also": [ + "NetworkSendDriver", + "Layer", + "GridLayout" + ], + "mode": "mutate", + "live_only": true, + "boards": [ + "sender" + ], + "description": "Two boards: the sender board sends with Network Send, this device receives with Network Receive. Sweeps grid size, identity against serpentine layout, mapped by the receiver against the sender, one layer against two, and the three protocols, reading Network Receive's tick and its packets and frames per second against what the sender sent.", + "top": [ + "Layouts", + "Effects" + ], + "steps": [ + { + "name": "sender-clear-layouts", + "device": "sender", + "op": "apply_state", + "document": { + "Layouts": { + "$patch": "replace" + } + }, + "description": "The sender's canvas: one grid, one layer with a changing effect, and a Network Send aimed at the receiver." + }, + { + "name": "sender-clear-effects", + "device": "sender", + "op": "apply_state", + "document": { + "Effects": { + "$patch": "replace" + } + } + }, + { + "name": "sender-grid", + "device": "sender", + "op": "apply_state", + "document": { + "Layouts": { + "Grid": { + "type": "GridLayout", + "width": 16, + "height": 16 + } + } + } + }, + { + "name": "sender-layer", + "device": "sender", + "op": "apply_state", + "document": { + "Effects": { + "Layer": { + "type": "Layer", + "Rainbow": { + "type": "RainbowEffect" + } + } + } + } + }, + { + "name": "sender-send", + "device": "sender", + "op": "apply_state", + "document": { + "Drivers": { + "Send": { + "type": "NetworkSendDriver", + "hosts": "{host}", + "protocol": 2, + "fps": 40 + } + } + } + }, + { + "name": "receiver-clear-layouts", + "op": "apply_state", + "document": { + "Layouts": { + "$patch": "replace" + } + }, + "description": "The receiver's canvas: one grid and one layer holding Network Receive; its own drivers stay, so the forward mapping is the real one." + }, + { + "name": "receiver-clear-effects", + "op": "apply_state", + "document": { + "Effects": { + "$patch": "replace" + } + } + }, + { + "name": "receiver-grid", + "op": "apply_state", + "document": { + "Layouts": { + "Grid": { + "type": "GridLayout", + "width": 16, + "height": 16 + } + } + } + }, + { + "name": "receiver-layer", + "op": "apply_state", + "document": { + "Effects": { + "Layer": { + "type": "Layer", + "NetworkReceive": { + "type": "NetworkReceiveEffect" + } + } + } + } + }, + { + "name": "sender-16x16-width", + "device": "sender", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "sender-16x16-height", + "device": "sender", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "receiver-16x16-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16 + }, + { + "name": "receiver-16x16-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16 + }, + { + "name": "16x16-identity", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": false + }, + { + "name": "16x16-identity-mapped-by-receiver-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "16x16-identity-receiver", + "description": "256 lights, identity layout, mapped by the receiver, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2853, + "p95": 3234, + "min": 2853, + "max": 3234, + "n": 2, + "samples": [3234, 2853] + }, + "free_heap": { + "p50": 8460931, + "p95": 8463743, + "min": 8460931, + "max": 8463743, + "n": 2, + "samples": [8460931, 8463743] + }, + "max_alloc_block": { + "p50": 32768, + "p95": 38912, + "min": 32768, + "max": 38912, + "n": 2, + "samples": [38912, 32768] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 64, + "p95": 65, + "min": 64, + "max": 65, + "n": 2, + "samples": [64, 65] + }, + "free_heap": { + "p50": 33857139, + "p95": 33917587, + "min": 33857139, + "max": 33917587, + "n": 2, + "samples": [33917587, 33857139] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 253952, + "min": 192512, + "max": 253952, + "n": 2, + "samples": [253952, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2925, + "p95": 3229, + "min": 2740, + "max": 3229, + "n": 4, + "samples": [2925, 3031, 2740, 3229] + }, + "free_heap": { + "p50": 8464491, + "p95": 8464907, + "min": 8459951, + "max": 8464907, + "n": 4, + "samples": [8464491, 8459951, 8464883, 8464907] + }, + "max_alloc_block": { + "p50": 47104, + "p95": 49152, + "min": 40960, + "max": 49152, + "n": 4, + "samples": [47104, 47104, 40960, 49152] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 70, + "p95": 83, + "min": 64, + "max": 83, + "n": 6, + "samples": [64, 78, 80, 70, 83, 65] + }, + "free_heap": { + "p50": 33857083, + "p95": 33917559, + "min": 33828655, + "max": 33917559, + "n": 6, + "samples": [33917559, 33912323, 33855979, 33828655, 33857083, 33857099] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 249856, + "min": 159744, + "max": 249856, + "n": 6, + "samples": [249856, 249856, 192512, 159744, 192512, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2144, + "p95": 2144, + "min": 2144, + "max": 2144, + "n": 1, + "samples": [2144] + }, + "free_heap": { + "p50": 298520, + "p95": 298520, + "min": 298520, + "max": 298520, + "n": 1, + "samples": [298520] + }, + "max_alloc_block": { + "p50": 236328, + "p95": 236328, + "min": 236328, + "max": 236328, + "n": 1, + "samples": [236328] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2147, + "p95": 2163, + "min": 2147, + "max": 2163, + "n": 2, + "samples": [2147, 2163] + }, + "free_heap": { + "p50": 297928, + "p95": 299048, + "min": 297928, + "max": 299048, + "n": 2, + "samples": [297928, 299048] + }, + "max_alloc_block": { + "p50": 181024, + "p95": 274432, + "min": 181024, + "max": 274432, + "n": 2, + "samples": [181024, 274432] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "16x16-identity-mapped-by-sender-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 1 + }, + { + "name": "16x16-identity-sender", + "description": "256 lights, identity layout, mapped by the sender, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2859, + "p95": 2908, + "min": 2859, + "max": 2908, + "n": 2, + "samples": [2908, 2859] + }, + "free_heap": { + "p50": 8457291, + "p95": 8459071, + "min": 8457291, + "max": 8459071, + "n": 2, + "samples": [8457291, 8459071] + }, + "max_alloc_block": { + "p50": 38912, + "p95": 43008, + "min": 38912, + "max": 43008, + "n": 2, + "samples": [38912, 43008] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 64, + "p95": 68, + "min": 64, + "max": 68, + "n": 2, + "samples": [64, 68] + }, + "free_heap": { + "p50": 33857139, + "p95": 33917599, + "min": 33857139, + "max": 33917599, + "n": 2, + "samples": [33917599, 33857139] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 253952, + "min": 192512, + "max": 253952, + "n": 2, + "samples": [253952, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2746, + "p95": 2831, + "min": 2745, + "max": 2831, + "n": 4, + "samples": [2746, 2831, 2805, 2745] + }, + "free_heap": { + "p50": 8464495, + "p95": 8464883, + "min": 8463579, + "max": 8464883, + "n": 4, + "samples": [8464495, 8463579, 8464883, 8464883] + }, + "max_alloc_block": { + "p50": 40960, + "p95": 49152, + "min": 34816, + "max": 49152, + "n": 4, + "samples": [47104, 34816, 40960, 49152] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 64, + "p95": 76, + "min": 64, + "max": 76, + "n": 6, + "samples": [64, 64, 66, 76, 64, 65] + }, + "free_heap": { + "p50": 33856435, + "p95": 33917575, + "min": 33829071, + "max": 33917575, + "n": 6, + "samples": [33917575, 33912307, 33853199, 33829071, 33856435, 33857119] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 249856, + "min": 163840, + "max": 249856, + "n": 6, + "samples": [249856, 249856, 188416, 163840, 192512, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2154, + "p95": 2154, + "min": 2154, + "max": 2154, + "n": 1, + "samples": [2154] + }, + "free_heap": { + "p50": 298616, + "p95": 298616, + "min": 298616, + "max": 298616, + "n": 1, + "samples": [298616] + }, + "max_alloc_block": { + "p50": 236328, + "p95": 236328, + "min": 236328, + "max": 236328, + "n": 1, + "samples": [236328] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2150, + "p95": 2155, + "min": 2150, + "max": 2155, + "n": 2, + "samples": [2155, 2150] + }, + "free_heap": { + "p50": 298192, + "p95": 298872, + "min": 298192, + "max": 298872, + "n": 2, + "samples": [298872, 298192] + }, + "max_alloc_block": { + "p50": 181024, + "p95": 274432, + "min": 181024, + "max": 274432, + "n": 2, + "samples": [181024, 274432] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "16x16-serpentine", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": true + }, + { + "name": "16x16-serpentine-mapped-by-receiver-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "16x16-serpentine-receiver", + "description": "256 lights, serpentine layout, mapped by the receiver, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3296, + "p95": 3391, + "min": 3296, + "max": 3391, + "n": 2, + "samples": [3296, 3391] + }, + "free_heap": { + "p50": 8450231, + "p95": 8455807, + "min": 8450231, + "max": 8455807, + "n": 2, + "samples": [8450231, 8455807] + }, + "max_alloc_block": { + "p50": 38912, + "p95": 43008, + "min": 38912, + "max": 43008, + "n": 2, + "samples": [38912, 43008] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 101, + "p95": 107, + "min": 101, + "max": 107, + "n": 2, + "samples": [101, 107] + }, + "free_heap": { + "p50": 33854539, + "p95": 33914091, + "min": 33854539, + "max": 33914091, + "n": 2, + "samples": [33914091, 33854539] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 249856, + "min": 192512, + "max": 249856, + "n": 2, + "samples": [249856, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2847, + "p95": 3554, + "min": 2839, + "max": 3554, + "n": 4, + "samples": [2839, 3554, 2850, 2847] + }, + "free_heap": { + "p50": 8457411, + "p95": 8461427, + "min": 8456627, + "max": 8461427, + "n": 4, + "samples": [8457411, 8456627, 8461427, 8461419] + }, + "max_alloc_block": { + "p50": 47104, + "p95": 49152, + "min": 40960, + "max": 49152, + "n": 4, + "samples": [47104, 47104, 40960, 49152] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 110, + "p95": 125, + "min": 101, + "max": 125, + "n": 6, + "samples": [101, 125, 125, 111, 110, 106] + }, + "free_heap": { + "p50": 33853799, + "p95": 33914951, + "min": 33821775, + "max": 33914951, + "n": 6, + "samples": [33914951, 33909687, 33850159, 33821775, 33853799, 33854519] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 249856, + "min": 159744, + "max": 249856, + "n": 6, + "samples": [249856, 249856, 192512, 159744, 192512, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2165, + "p95": 2165, + "min": 2165, + "max": 2165, + "n": 1, + "samples": [2165] + }, + "free_heap": { + "p50": 296312, + "p95": 296312, + "min": 296312, + "max": 296312, + "n": 1, + "samples": [296312] + }, + "max_alloc_block": { + "p50": 236328, + "p95": 236328, + "min": 236328, + "max": 236328, + "n": 1, + "samples": [236328] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2143, + "p95": 2170, + "min": 2143, + "max": 2170, + "n": 2, + "samples": [2170, 2143] + }, + "free_heap": { + "p50": 294680, + "p95": 295880, + "min": 294680, + "max": 295880, + "n": 2, + "samples": [294680, 295880] + }, + "max_alloc_block": { + "p50": 181024, + "p95": 274432, + "min": 181024, + "max": 274432, + "n": 2, + "samples": [181024, 274432] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "16x16-serpentine-mapped-by-sender-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 1 + }, + { + "name": "16x16-serpentine-sender", + "description": "256 lights, serpentine layout, mapped by the sender, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2945, + "p95": 3020, + "min": 2945, + "max": 3020, + "n": 2, + "samples": [2945, 3020] + }, + "free_heap": { + "p50": 8448551, + "p95": 8461059, + "min": 8448551, + "max": 8461059, + "n": 2, + "samples": [8461059, 8448551] + }, + "max_alloc_block": { + "p50": 38912, + "p95": 40960, + "min": 38912, + "max": 40960, + "n": 2, + "samples": [38912, 40960] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 111, + "p95": 120, + "min": 111, + "max": 120, + "n": 2, + "samples": [111, 120] + }, + "free_heap": { + "p50": 33854527, + "p95": 33914991, + "min": 33854527, + "max": 33914991, + "n": 2, + "samples": [33914991, 33854527] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 253952, + "min": 192512, + "max": 253952, + "n": 2, + "samples": [253952, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2927, + "p95": 3209, + "min": 2846, + "max": 3209, + "n": 4, + "samples": [2846, 2927, 3184, 3209] + }, + "free_heap": { + "p50": 8459743, + "p95": 8461403, + "min": 8457811, + "max": 8461403, + "n": 4, + "samples": [8461059, 8459743, 8461403, 8457811] + }, + "max_alloc_block": { + "p50": 47104, + "p95": 49152, + "min": 40960, + "max": 49152, + "n": 4, + "samples": [47104, 47104, 40960, 49152] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 114, + "p95": 116, + "min": 111, + "max": 116, + "n": 6, + "samples": [115, 111, 114, 114, 111, 116] + }, + "free_heap": { + "p50": 33854163, + "p95": 33914623, + "min": 33817971, + "max": 33914623, + "n": 6, + "samples": [33914623, 33913715, 33850103, 33817971, 33854511, 33854163] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 249856, + "min": 155648, + "max": 249856, + "n": 6, + "samples": [249856, 249856, 188416, 155648, 188416, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2146, + "p95": 2146, + "min": 2146, + "max": 2146, + "n": 1, + "samples": [2146] + }, + "free_heap": { + "p50": 295464, + "p95": 295464, + "min": 295464, + "max": 295464, + "n": 1, + "samples": [295464] + }, + "max_alloc_block": { + "p50": 236328, + "p95": 236328, + "min": 236328, + "max": 236328, + "n": 1, + "samples": [236328] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2142, + "p95": 2155, + "min": 2142, + "max": 2155, + "n": 2, + "samples": [2142, 2155] + }, + "free_heap": { + "p50": 294944, + "p95": 295624, + "min": 294944, + "max": 295624, + "n": 2, + "samples": [295624, 294944] + }, + "max_alloc_block": { + "p50": 181024, + "p95": 274432, + "min": 181024, + "max": 274432, + "n": 2, + "samples": [181024, 274432] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "sender-64x64-width", + "device": "sender", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "sender-64x64-height", + "device": "sender", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64 + }, + { + "name": "receiver-64x64-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "receiver-64x64-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64 + }, + { + "name": "64x64-identity", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": false + }, + { + "name": "64x64-identity-mapped-by-receiver-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "64x64-identity-receiver", + "description": "4096 lights, identity layout, mapped by the receiver, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3462, + "p95": 4383, + "min": 3462, + "max": 4383, + "n": 2, + "samples": [3462, 4383] + }, + "free_heap": { + "p50": 8391303, + "p95": 8419323, + "min": 8391303, + "max": 8419323, + "n": 2, + "samples": [8419323, 8391303] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 2, + "samples": [38912, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 107, + "p95": 111, + "min": 107, + "max": 111, + "n": 2, + "samples": [107, 111] + }, + "free_heap": { + "p50": 33810315, + "p95": 33872371, + "min": 33810315, + "max": 33872371, + "n": 2, + "samples": [33872371, 33810315] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 253952, + "min": 192512, + "max": 253952, + "n": 2, + "samples": [253952, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 3316, + "p95": 4221, + "min": 3227, + "max": 4221, + "n": 4, + "samples": [3316, 4221, 3425, 3227] + }, + "free_heap": { + "p50": 8412323, + "p95": 8419047, + "min": 8398727, + "max": 8419047, + "n": 4, + "samples": [8414843, 8398727, 8412323, 8419047] + }, + "max_alloc_block": { + "p50": 40960, + "p95": 49152, + "min": 31744, + "max": 49152, + "n": 4, + "samples": [47104, 31744, 40960, 49152] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 115, + "p95": 146, + "min": 105, + "max": 146, + "n": 6, + "samples": [105, 146, 141, 117, 115, 108] + }, + "free_heap": { + "p50": 33811007, + "p95": 33872331, + "min": 33775623, + "max": 33872331, + "n": 6, + "samples": [33872331, 33858195, 33796351, 33775623, 33811207, 33811007] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 249856, + "min": 155648, + "max": 249856, + "n": 6, + "samples": [249856, 237568, 180224, 155648, 192512, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2436, + "p95": 2436, + "min": 2436, + "max": 2436, + "n": 1, + "samples": [2436] + }, + "free_heap": { + "p50": 256168, + "p95": 256168, + "min": 256168, + "max": 256168, + "n": 1, + "samples": [256168] + }, + "max_alloc_block": { + "p50": 191208, + "p95": 191208, + "min": 191208, + "max": 191208, + "n": 1, + "samples": [191208] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2513, + "p95": 2526, + "min": 2513, + "max": 2526, + "n": 2, + "samples": [2526, 2513] + }, + "free_heap": { + "p50": 253176, + "p95": 257712, + "min": 253176, + "max": 257712, + "n": 2, + "samples": [253176, 257712] + }, + "max_alloc_block": { + "p50": 181024, + "p95": 192336, + "min": 181024, + "max": 192336, + "n": 2, + "samples": [181024, 192336] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "64x64-identity-mapped-by-sender-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 1 + }, + { + "name": "64x64-identity-sender", + "description": "4096 lights, identity layout, mapped by the sender, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3588, + "p95": 3994, + "min": 3588, + "max": 3994, + "n": 2, + "samples": [3588, 3994] + }, + "free_heap": { + "p50": 8405431, + "p95": 8411811, + "min": 8405431, + "max": 8411811, + "n": 2, + "samples": [8411811, 8405431] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 2, + "samples": [38912, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 107, + "p95": 113, + "min": 107, + "max": 113, + "n": 2, + "samples": [107, 113] + }, + "free_heap": { + "p50": 33811887, + "p95": 33872367, + "min": 33811887, + "max": 33872367, + "n": 2, + "samples": [33872367, 33811887] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 249856, + "min": 192512, + "max": 249856, + "n": 2, + "samples": [249856, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 3355, + "p95": 3714, + "min": 3233, + "max": 3714, + "n": 4, + "samples": [3233, 3714, 3405, 3355] + }, + "free_heap": { + "p50": 8415659, + "p95": 8419447, + "min": 8415307, + "max": 8419447, + "n": 4, + "samples": [8415307, 8417775, 8419447, 8415659] + }, + "max_alloc_block": { + "p50": 40960, + "p95": 47104, + "min": 31744, + "max": 47104, + "n": 4, + "samples": [47104, 43008, 40960, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 109, + "p95": 119, + "min": 106, + "max": 119, + "n": 6, + "samples": [109, 119, 118, 113, 106, 109] + }, + "free_heap": { + "p50": 33808863, + "p95": 33869527, + "min": 33768571, + "max": 33869527, + "n": 6, + "samples": [33864351, 33869527, 33808863, 33768571, 33811863, 33808403] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 249856, + "min": 147456, + "max": 249856, + "n": 6, + "samples": [241664, 249856, 188416, 147456, 188416, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2433, + "p95": 2433, + "min": 2433, + "max": 2433, + "n": 1, + "samples": [2433] + }, + "free_heap": { + "p50": 265000, + "p95": 265000, + "min": 265000, + "max": 265000, + "n": 1, + "samples": [265000] + }, + "max_alloc_block": { + "p50": 191208, + "p95": 191208, + "min": 191208, + "max": 191208, + "n": 1, + "samples": [191208] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2502, + "p95": 2505, + "min": 2502, + "max": 2505, + "n": 2, + "samples": [2505, 2502] + }, + "free_heap": { + "p50": 256448, + "p95": 257384, + "min": 256448, + "max": 257384, + "n": 2, + "samples": [257384, 256448] + }, + "max_alloc_block": { + "p50": 181024, + "p95": 192336, + "min": 181024, + "max": 192336, + "n": 2, + "samples": [181024, 192336] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "64x64-serpentine", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": true + }, + { + "name": "64x64-serpentine-mapped-by-receiver-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "64x64-serpentine-receiver", + "description": "4096 lights, serpentine layout, mapped by the receiver, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 5678, + "p95": 5954, + "min": 5678, + "max": 5954, + "n": 2, + "samples": [5954, 5678] + }, + "free_heap": { + "p50": 8328391, + "p95": 8346095, + "min": 8328391, + "max": 8346095, + "n": 2, + "samples": [8346095, 8328391] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 2, + "samples": [38912, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 857, + "p95": 1143, + "min": 857, + "max": 1143, + "n": 2, + "samples": [857, 1143] + }, + "free_heap": { + "p50": 33750383, + "p95": 33810483, + "min": 33750383, + "max": 33810483, + "n": 2, + "samples": [33810483, 33750383] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 245760, + "min": 192512, + "max": 245760, + "n": 2, + "samples": [245760, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 6268, + "p95": 7297, + "min": 5767, + "max": 7297, + "n": 4, + "samples": [6268, 5767, 7297, 6484] + }, + "free_heap": { + "p50": 8353319, + "p95": 8357303, + "min": 8324719, + "max": 8357303, + "n": 4, + "samples": [8353319, 8324719, 8353791, 8357303] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 47104, + "min": 31744, + "max": 47104, + "n": 4, + "samples": [47104, 31744, 40960, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 930, + "p95": 1034, + "min": 821, + "max": 1034, + "n": 6, + "samples": [829, 1034, 985, 968, 821, 930] + }, + "free_heap": { + "p50": 33750315, + "p95": 33810479, + "min": 33708383, + "max": 33810479, + "n": 6, + "samples": [33810479, 33809039, 33734987, 33708383, 33750315, 33750355] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 249856, + "min": 151552, + "max": 249856, + "n": 6, + "samples": [249856, 249856, 180224, 151552, 192512, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2475, + "p95": 2475, + "min": 2475, + "max": 2475, + "n": 1, + "samples": [2475] + }, + "free_heap": { + "p50": 206360, + "p95": 206360, + "min": 206360, + "max": 206360, + "n": 1, + "samples": [206360] + }, + "max_alloc_block": { + "p50": 178904, + "p95": 178904, + "min": 178904, + "max": 178904, + "n": 1, + "samples": [178904] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2512, + "p95": 2516, + "min": 2512, + "max": 2516, + "n": 2, + "samples": [2512, 2516] + }, + "free_heap": { + "p50": 212168, + "p95": 215344, + "min": 212168, + "max": 215344, + "n": 2, + "samples": [212168, 215344] + }, + "max_alloc_block": { + "p50": 168720, + "p95": 180032, + "min": 168720, + "max": 180032, + "n": 2, + "samples": [168720, 180032] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "64x64-serpentine-mapped-by-sender-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 1 + }, + { + "name": "64x64-serpentine-sender", + "description": "4096 lights, serpentine layout, mapped by the sender, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 5924, + "p95": 6309, + "min": 5924, + "max": 6309, + "n": 2, + "samples": [5924, 6309] + }, + "free_heap": { + "p50": 8323455, + "p95": 8356839, + "min": 8323455, + "max": 8356839, + "n": 2, + "samples": [8356839, 8323455] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 2, + "samples": [38912, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 1103, + "p95": 1292, + "min": 1103, + "max": 1292, + "n": 2, + "samples": [1103, 1292] + }, + "free_heap": { + "p50": 33748791, + "p95": 33810827, + "min": 33748791, + "max": 33810827, + "n": 2, + "samples": [33810827, 33748791] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 253952, + "min": 192512, + "max": 253952, + "n": 2, + "samples": [253952, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 5816, + "p95": 6237, + "min": 5683, + "max": 6237, + "n": 4, + "samples": [5683, 6237, 5816, 6035] + }, + "free_heap": { + "p50": 8357267, + "p95": 8357715, + "min": 8355167, + "max": 8357715, + "n": 4, + "samples": [8355167, 8357715, 8357267, 8357315] + }, + "max_alloc_block": { + "p50": 40960, + "p95": 49152, + "min": 31744, + "max": 49152, + "n": 4, + "samples": [47104, 31744, 40960, 49152] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 1057, + "p95": 1276, + "min": 799, + "max": 1276, + "n": 6, + "samples": [1063, 1276, 1057, 799, 1019, 1214] + }, + "free_heap": { + "p50": 33750327, + "p95": 33810803, + "min": 33718011, + "max": 33810803, + "n": 6, + "samples": [33810803, 33807955, 33749715, 33718011, 33750327, 33750351] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 253952, + "min": 159744, + "max": 253952, + "n": 6, + "samples": [253952, 249856, 192512, 159744, 188416, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2544, + "p95": 2544, + "min": 2544, + "max": 2544, + "n": 1, + "samples": [2544] + }, + "free_heap": { + "p50": 209944, + "p95": 209944, + "min": 209944, + "max": 209944, + "n": 1, + "samples": [209944] + }, + "max_alloc_block": { + "p50": 178904, + "p95": 178904, + "min": 178904, + "max": 178904, + "n": 1, + "samples": [178904] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2502, + "p95": 2600, + "min": 2502, + "max": 2600, + "n": 2, + "samples": [2600, 2502] + }, + "free_heap": { + "p50": 212432, + "p95": 214984, + "min": 212432, + "max": 214984, + "n": 2, + "samples": [214984, 212432] + }, + "max_alloc_block": { + "p50": 168720, + "p95": 180032, + "min": 168720, + "max": 180032, + "n": 2, + "samples": [168720, 180032] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "sender-128x96-width", + "device": "sender", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "sender-128x96-height", + "device": "sender", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 96 + }, + { + "name": "receiver-128x96-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "receiver-128x96-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 96 + }, + { + "name": "128x96-identity", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": false + }, + { + "name": "128x96-identity-mapped-by-receiver-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "128x96-identity-receiver", + "description": "12288 lights, identity layout, mapped by the receiver, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 5855, + "p95": 8160, + "min": 5855, + "max": 8160, + "n": 2, + "samples": [8160, 5855] + }, + "free_heap": { + "p50": 8283407, + "p95": 8315687, + "min": 8283407, + "max": 8315687, + "n": 2, + "samples": [8315687, 8283407] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 2, + "samples": [38912, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 305, + "p95": 1579, + "min": 305, + "max": 1579, + "n": 2, + "samples": [305, 1579] + }, + "free_heap": { + "p50": 33713591, + "p95": 33814627, + "min": 33713591, + "max": 33814627, + "n": 2, + "samples": [33814627, 33713591] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 245760, + "min": 192512, + "max": 245760, + "n": 2, + "samples": [245760, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 6464, + "p95": 6632, + "min": 5826, + "max": 6632, + "n": 3, + "samples": [5826, 6464, 6632] + }, + "free_heap": { + "p50": 8320995, + "p95": 8321399, + "min": 8313403, + "max": 8321399, + "n": 3, + "samples": [8320995, 8313403, 8321399] + }, + "max_alloc_block": { + "p50": 43008, + "p95": 49152, + "min": 40960, + "max": 49152, + "n": 3, + "samples": [43008, 40960, 49152] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 642, + "p95": 1803, + "min": 441, + "max": 1803, + "n": 6, + "samples": [441, 1167, 642, 618, 756, 1803] + }, + "free_heap": { + "p50": 33712011, + "p95": 33773723, + "min": 33678311, + "max": 33773723, + "n": 6, + "samples": [33773723, 33767207, 33687611, 33678311, 33713543, 33712011] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 249856, + "min": 151552, + "max": 249856, + "n": 6, + "samples": [249856, 237568, 151552, 159744, 192512, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2789, + "p95": 2789, + "min": 2789, + "max": 2789, + "n": 1, + "samples": [2789] + }, + "free_heap": { + "p50": 173712, + "p95": 173712, + "min": 173712, + "max": 173712, + "n": 1, + "samples": [173712] + }, + "max_alloc_block": { + "p50": 110640, + "p95": 110640, + "min": 110640, + "max": 110640, + "n": 1, + "samples": [110640] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 5022, + "p95": 6680, + "min": 5022, + "max": 6680, + "n": 2, + "samples": [5022, 6680] + }, + "free_heap": { + "p50": 181312, + "p95": 187736, + "min": 181312, + "max": 187736, + "n": 2, + "samples": [181312, 187736] + }, + "max_alloc_block": { + "p50": 139176, + "p95": 144144, + "min": 139176, + "max": 144144, + "n": 2, + "samples": [144144, 139176] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "128x96-identity-mapped-by-sender-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 1 + }, + { + "name": "128x96-identity-sender", + "description": "12288 lights, identity layout, mapped by the sender, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 5713, + "p95": 5931, + "min": 5713, + "max": 5931, + "n": 2, + "samples": [5931, 5713] + }, + "free_heap": { + "p50": 8293355, + "p95": 8313563, + "min": 8293355, + "max": 8313563, + "n": 2, + "samples": [8313563, 8293355] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 2, + "samples": [38912, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 238, + "p95": 1361, + "min": 238, + "max": 1361, + "n": 2, + "samples": [238, 1361] + }, + "free_heap": { + "p50": 33713819, + "p95": 33823831, + "min": 33713819, + "max": 33823831, + "n": 2, + "samples": [33823831, 33713819] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 249856, + "min": 192512, + "max": 249856, + "n": 2, + "samples": [249856, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 6798, + "p95": 6800, + "min": 5390, + "max": 6800, + "n": 3, + "samples": [5390, 6798, 6800] + }, + "free_heap": { + "p50": 8319699, + "p95": 8320811, + "min": 8302479, + "max": 8320811, + "n": 3, + "samples": [8320811, 8302479, 8319699] + }, + "max_alloc_block": { + "p50": 47104, + "p95": 49152, + "min": 31744, + "max": 49152, + "n": 3, + "samples": [47104, 31744, 49152] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 692, + "p95": 1855, + "min": 573, + "max": 1855, + "n": 6, + "samples": [573, 692, 849, 619, 773, 1855] + }, + "free_heap": { + "p50": 33699547, + "p95": 33775951, + "min": 33673223, + "max": 33775951, + "n": 6, + "samples": [33775951, 33772723, 33673223, 33678627, 33699547, 33710431] + }, + "max_alloc_block": { + "p50": 159744, + "p95": 253952, + "min": 151552, + "max": 253952, + "n": 6, + "samples": [253952, 249856, 151552, 159744, 159744, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2714, + "p95": 2714, + "min": 2714, + "max": 2714, + "n": 1, + "samples": [2714] + }, + "free_heap": { + "p50": 177504, + "p95": 177504, + "min": 177504, + "max": 177504, + "n": 1, + "samples": [177504] + }, + "max_alloc_block": { + "p50": 110640, + "p95": 110640, + "min": 110640, + "max": 110640, + "n": 1, + "samples": [110640] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2667, + "p95": 9569, + "min": 2667, + "max": 9569, + "n": 2, + "samples": [9569, 2667] + }, + "free_heap": { + "p50": 179856, + "p95": 180160, + "min": 179856, + "max": 180160, + "n": 2, + "samples": [180160, 179856] + }, + "max_alloc_block": { + "p50": 144144, + "p95": 146352, + "min": 144144, + "max": 146352, + "n": 2, + "samples": [144144, 146352] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "128x96-serpentine", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": true + }, + { + "name": "128x96-serpentine-mapped-by-receiver-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "128x96-serpentine-receiver", + "description": "12288 lights, serpentine layout, mapped by the receiver, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 15141, + "p95": 19005, + "min": 15141, + "max": 19005, + "n": 2, + "samples": [15141, 19005] + }, + "free_heap": { + "p50": 8118775, + "p95": 8131755, + "min": 8118775, + "max": 8131755, + "n": 2, + "samples": [8131755, 8118775] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 2, + "samples": [38912, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 220, + "p95": 10027, + "min": 220, + "max": 10027, + "n": 2, + "samples": [220, 10027] + }, + "free_heap": { + "p50": 33528679, + "p95": 33823835, + "min": 33528679, + "max": 33823835, + "n": 2, + "samples": [33823835, 33528679] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 253952, + "min": 192512, + "max": 253952, + "n": 2, + "samples": [253952, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 17380, + "p95": 17707, + "min": 11817, + "max": 17707, + "n": 3, + "samples": [11817, 17380, 17707] + }, + "free_heap": { + "p50": 8128427, + "p95": 8133703, + "min": 8128283, + "max": 8133703, + "n": 3, + "samples": [8128283, 8133703, 8128427] + }, + "max_alloc_block": { + "p50": 43008, + "p95": 47104, + "min": 40960, + "max": 47104, + "n": 3, + "samples": [47104, 40960, 43008] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 7118, + "p95": 9483, + "min": 6352, + "max": 9483, + "n": 6, + "samples": [7118, 6496, 8005, 7296, 6352, 9483] + }, + "free_heap": { + "p50": 33525467, + "p95": 33589531, + "min": 33481595, + "max": 33589531, + "n": 6, + "samples": [33589531, 33574931, 33524287, 33481595, 33527943, 33525467] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 249856, + "min": 147456, + "max": 249856, + "n": 6, + "samples": [249856, 237568, 192512, 147456, 188416, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2806, + "p95": 2806, + "min": 2806, + "max": 2806, + "n": 1, + "samples": [2806] + }, + "free_heap": { + "p50": 130232, + "p95": 130232, + "min": 130232, + "max": 130232, + "n": 1, + "samples": [130232] + }, + "max_alloc_block": { + "p50": 37160, + "p95": 37160, + "min": 37160, + "max": 37160, + "n": 1, + "samples": [37160] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2695, + "p95": 2888, + "min": 2695, + "max": 2888, + "n": 2, + "samples": [2888, 2695] + }, + "free_heap": { + "p50": 157368, + "p95": 182608, + "min": 157368, + "max": 182608, + "n": 2, + "samples": [157368, 182608] + }, + "max_alloc_block": { + "p50": 107264, + "p95": 131024, + "min": 107264, + "max": 131024, + "n": 2, + "samples": [107264, 131024] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "128x96-serpentine-mapped-by-sender-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 1 + }, + { + "name": "128x96-serpentine-sender", + "description": "12288 lights, serpentine layout, mapped by the sender, DDP at 40 fps.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 14975, + "p95": 17066, + "min": 14975, + "max": 17066, + "n": 2, + "samples": [17066, 14975] + }, + "free_heap": { + "p50": 8104315, + "p95": 8131827, + "min": 8104315, + "max": 8131827, + "n": 2, + "samples": [8131827, 8104315] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 2, + "samples": [38912, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 240, + "p95": 11754, + "min": 240, + "max": 11754, + "n": 2, + "samples": [240, 11754] + }, + "free_heap": { + "p50": 33526107, + "p95": 33811451, + "min": 33526107, + "max": 33811451, + "n": 2, + "samples": [33811451, 33526107] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 241664, + "min": 192512, + "max": 241664, + "n": 2, + "samples": [241664, 192512] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 17814, + "p95": 18405, + "min": 13366, + "max": 18405, + "n": 3, + "samples": [13366, 18405, 17814] + }, + "free_heap": { + "p50": 8131899, + "p95": 8134983, + "min": 8127959, + "max": 8134983, + "n": 3, + "samples": [8134983, 8131899, 8127959] + }, + "max_alloc_block": { + "p50": 45056, + "p95": 47104, + "min": 40960, + "max": 47104, + "n": 3, + "samples": [47104, 40960, 45056] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 9044, + "p95": 13022, + "min": 8119, + "max": 13022, + "n": 6, + "samples": [8119, 9745, 9737, 9044, 8159, 13022] + }, + "free_heap": { + "p50": 33516363, + "p95": 33588763, + "min": 33493455, + "max": 33588763, + "n": 6, + "samples": [33588763, 33587655, 33516363, 33493455, 33513107, 33527975] + }, + "max_alloc_block": { + "p50": 180224, + "p95": 249856, + "min": 159744, + "max": 249856, + "n": 6, + "samples": [249856, 249856, 180224, 159744, 176128, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2573, + "p95": 2573, + "min": 2573, + "max": 2573, + "n": 1, + "samples": [2573] + }, + "free_heap": { + "p50": 170224, + "p95": 170224, + "min": 170224, + "max": 170224, + "n": 1, + "samples": [170224] + }, + "max_alloc_block": { + "p50": 104792, + "p95": 104792, + "min": 104792, + "max": 104792, + "n": 1, + "samples": [104792] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2340, + "p95": 2489, + "min": 2340, + "max": 2489, + "n": 2, + "samples": [2489, 2340] + }, + "free_heap": { + "p50": 152456, + "p95": 160616, + "min": 152456, + "max": 160616, + "n": 2, + "samples": [160616, 152456] + }, + "max_alloc_block": { + "p50": 32656, + "p95": 33488, + "min": 32656, + "max": 33488, + "n": 2, + "samples": [33488, 32656] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "second-layer", + "op": "apply_state", + "document": { + "Effects": { + "Layer2": { + "type": "Layer", + "Solid": { + "type": "SolidEffect" + } + } + } + }, + "description": "A second layer on top, at the largest size, so the drivers blend." + }, + { + "name": "two-layers-identity", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": false + }, + { + "name": "two-layers-identity-mapped-by-receiver-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "two-layers-identity-receiver", + "description": "12288 lights on two layers, identity layout, mapped by the receiver.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 15955, + "p95": 17511, + "min": 15955, + "max": 17511, + "n": 2, + "samples": [15955, 17511] + }, + "free_heap": { + "p50": 8255203, + "p95": 8278235, + "min": 8255203, + "max": 8278235, + "n": 2, + "samples": [8278235, 8255203] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 32768, + "min": 31744, + "max": 32768, + "n": 2, + "samples": [32768, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2925, + "p95": 7845, + "min": 2925, + "max": 7845, + "n": 2, + "samples": [2925, 7845] + }, + "free_heap": { + "p50": 33674863, + "p95": 33792107, + "min": 33674863, + "max": 33792107, + "n": 2, + "samples": [33792107, 33674863] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 245760, + "min": 188416, + "max": 245760, + "n": 2, + "samples": [245760, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 13197, + "p95": 17495, + "min": 13185, + "max": 17495, + "n": 3, + "samples": [13185, 13197, 17495] + }, + "free_heap": { + "p50": 8275107, + "p95": 8282175, + "min": 8260723, + "max": 8282175, + "n": 3, + "samples": [8260723, 8282175, 8275107] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 40960, + "min": 31744, + "max": 40960, + "n": 3, + "samples": [31744, 31744, 40960] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 5880, + "p95": 8906, + "min": 5506, + "max": 8906, + "n": 6, + "samples": [5506, 7261, 7776, 5880, 5739, 8906] + }, + "free_heap": { + "p50": 33672403, + "p95": 33734995, + "min": 33629295, + "max": 33734995, + "n": 6, + "samples": [33734995, 33733939, 33667219, 33629295, 33674591, 33672403] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 249856, + "min": 147456, + "max": 249856, + "n": 6, + "samples": [249856, 249856, 192512, 147456, 163840, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2621, + "p95": 2621, + "min": 2621, + "max": 2621, + "n": 1, + "samples": [2621] + }, + "free_heap": { + "p50": 153744, + "p95": 153744, + "min": 153744, + "max": 153744, + "n": 1, + "samples": [153744] + }, + "max_alloc_block": { + "p50": 56232, + "p95": 56232, + "min": 56232, + "max": 56232, + "n": 1, + "samples": [56232] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2956, + "p95": 3086, + "min": 2956, + "max": 3086, + "n": 2, + "samples": [2956, 3086] + }, + "free_heap": { + "p50": 108384, + "p95": 112544, + "min": 108384, + "max": 112544, + "n": 2, + "samples": [112544, 108384] + }, + "max_alloc_block": { + "p50": 70384, + "p95": 72592, + "min": 70384, + "max": 72592, + "n": 2, + "samples": [70384, 72592] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "two-layers-identity-mapped-by-sender-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 1 + }, + { + "name": "two-layers-identity-sender", + "description": "12288 lights on two layers, identity layout, mapped by the sender.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 12343, + "p95": 12684, + "min": 12343, + "max": 12684, + "n": 2, + "samples": [12343, 12684] + }, + "free_heap": { + "p50": 8254203, + "p95": 8282035, + "min": 8254203, + "max": 8282035, + "n": 2, + "samples": [8282035, 8254203] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 32768, + "min": 31744, + "max": 32768, + "n": 2, + "samples": [32768, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2926, + "p95": 9864, + "min": 2926, + "max": 9864, + "n": 2, + "samples": [2926, 9864] + }, + "free_heap": { + "p50": 33674851, + "p95": 33794311, + "min": 33674851, + "max": 33794311, + "n": 2, + "samples": [33794311, 33674851] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 245760, + "min": 188416, + "max": 245760, + "n": 2, + "samples": [245760, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 11963, + "p95": 14760, + "min": 11702, + "max": 14760, + "n": 3, + "samples": [11963, 11702, 14760] + }, + "free_heap": { + "p50": 8280143, + "p95": 8284487, + "min": 8274991, + "max": 8284487, + "n": 3, + "samples": [8280143, 8284487, 8274991] + }, + "max_alloc_block": { + "p50": 34816, + "p95": 40960, + "min": 31744, + "max": 40960, + "n": 3, + "samples": [34816, 31744, 40960] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 5908, + "p95": 11332, + "min": 5431, + "max": 11332, + "n": 6, + "samples": [5464, 5431, 8511, 5920, 5908, 11332] + }, + "free_heap": { + "p50": 33673615, + "p95": 33735275, + "min": 33643619, + "max": 33735275, + "n": 6, + "samples": [33735275, 33727675, 33673615, 33643619, 33674575, 33673059] + }, + "max_alloc_block": { + "p50": 163840, + "p95": 249856, + "min": 151552, + "max": 249856, + "n": 6, + "samples": [249856, 237568, 163840, 151552, 163840, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 3402, + "p95": 3402, + "min": 3402, + "max": 3402, + "n": 1, + "samples": [3402] + }, + "free_heap": { + "p50": 125376, + "p95": 125376, + "min": 125376, + "max": 125376, + "n": 1, + "samples": [125376] + }, + "max_alloc_block": { + "p50": 56232, + "p95": 56232, + "min": 56232, + "max": 56232, + "n": 1, + "samples": [56232] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3086, + "p95": 3187, + "min": 3086, + "max": 3187, + "n": 2, + "samples": [3187, 3086] + }, + "free_heap": { + "p50": 108384, + "p95": 114000, + "min": 108384, + "max": 114000, + "n": 2, + "samples": [114000, 108384] + }, + "max_alloc_block": { + "p50": 70384, + "p95": 72592, + "min": 70384, + "max": 72592, + "n": 2, + "samples": [70384, 72592] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "two-layers-serpentine", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": true + }, + { + "name": "two-layers-serpentine-mapped-by-receiver-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "two-layers-serpentine-receiver", + "description": "12288 lights on two layers, serpentine layout, mapped by the receiver.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 27983, + "p95": 29558, + "min": 27983, + "max": 29558, + "n": 2, + "samples": [29558, 27983] + }, + "free_heap": { + "p50": 7986755, + "p95": 7990423, + "min": 7986755, + "max": 7990423, + "n": 2, + "samples": [7990423, 7986755] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 32768, + "min": 31744, + "max": 32768, + "n": 2, + "samples": [32768, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 10036, + "p95": 18778, + "min": 10036, + "max": 18778, + "n": 2, + "samples": [10036, 18778] + }, + "free_heap": { + "p50": 33389439, + "p95": 33607119, + "min": 33389439, + "max": 33607119, + "n": 2, + "samples": [33607119, 33389439] + }, + "max_alloc_block": { + "p50": 184320, + "p95": 245760, + "min": 184320, + "max": 245760, + "n": 2, + "samples": [245760, 184320] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 26122, + "p95": 29137, + "min": 24020, + "max": 29137, + "n": 3, + "samples": [24020, 29137, 26122] + }, + "free_heap": { + "p50": 7987047, + "p95": 7995311, + "min": 7985827, + "max": 7995311, + "n": 3, + "samples": [7987047, 7995311, 7985827] + }, + "max_alloc_block": { + "p50": 34816, + "p95": 38912, + "min": 31744, + "max": 38912, + "n": 3, + "samples": [34816, 31744, 38912] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 13954, + "p95": 18171, + "min": 13117, + "max": 18171, + "n": 6, + "samples": [13117, 15544, 16165, 13897, 13954, 18171] + }, + "free_heap": { + "p50": 33378951, + "p95": 33451463, + "min": 33354651, + "max": 33451463, + "n": 6, + "samples": [33451463, 33443755, 33378951, 33354651, 33360111, 33389347] + }, + "max_alloc_block": { + "p50": 155648, + "p95": 249856, + "min": 151552, + "max": 249856, + "n": 6, + "samples": [249856, 237568, 151552, 151552, 155648, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2623, + "p95": 2623, + "min": 2623, + "max": 2623, + "n": 1, + "samples": [2623] + }, + "free_heap": { + "p50": 121456, + "p95": 121456, + "min": 121456, + "max": 121456, + "n": 1, + "samples": [121456] + }, + "max_alloc_block": { + "p50": 56232, + "p95": 56232, + "min": 56232, + "max": 56232, + "n": 1, + "samples": [56232] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3268, + "p95": 6608, + "min": 3268, + "max": 6608, + "n": 2, + "samples": [6608, 3268] + }, + "free_heap": { + "p50": 101400, + "p95": 105696, + "min": 101400, + "max": 105696, + "n": 2, + "samples": [101400, 105696] + }, + "max_alloc_block": { + "p50": 70384, + "p95": 72592, + "min": 70384, + "max": 72592, + "n": 2, + "samples": [70384, 72592] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "two-layers-serpentine-mapped-by-sender-set", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 1 + }, + { + "name": "two-layers-serpentine-sender", + "description": "12288 lights on two layers, serpentine layout, mapped by the sender.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 30612, + "p95": 33261, + "min": 30612, + "max": 33261, + "n": 2, + "samples": [30612, 33261] + }, + "free_heap": { + "p50": 7985299, + "p95": 7995931, + "min": 7985299, + "max": 7995931, + "n": 2, + "samples": [7995931, 7985299] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 32768, + "min": 31744, + "max": 32768, + "n": 2, + "samples": [32768, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 11930, + "p95": 21562, + "min": 11930, + "max": 21562, + "n": 2, + "samples": [11930, 21562] + }, + "free_heap": { + "p50": 33388063, + "p95": 33607139, + "min": 33388063, + "max": 33607139, + "n": 2, + "samples": [33607139, 33388063] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 245760, + "min": 188416, + "max": 245760, + "n": 2, + "samples": [245760, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 37919, + "p95": 38723, + "min": 31174, + "max": 38723, + "n": 3, + "samples": [31174, 38723, 37919] + }, + "free_heap": { + "p50": 7996311, + "p95": 7999035, + "min": 7993927, + "max": 7999035, + "n": 3, + "samples": [7993927, 7996311, 7999035] + }, + "max_alloc_block": { + "p50": 34816, + "p95": 36864, + "min": 31744, + "max": 36864, + "n": 3, + "samples": [34816, 31744, 36864] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 15741, + "p95": 22861, + "min": 15496, + "max": 22861, + "n": 6, + "samples": [15720, 18273, 17333, 15741, 15496, 22861] + }, + "free_heap": { + "p50": 33383299, + "p95": 33451479, + "min": 33360551, + "max": 33451479, + "n": 6, + "samples": [33451479, 33447359, 33383299, 33360551, 33379587, 33389399] + }, + "max_alloc_block": { + "p50": 184320, + "p95": 249856, + "min": 151552, + "max": 249856, + "n": 6, + "samples": [249856, 229376, 184320, 151552, 163840, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 3346, + "p95": 3346, + "min": 3346, + "max": 3346, + "n": 1, + "samples": [3346] + }, + "free_heap": { + "p50": 114512, + "p95": 114512, + "min": 114512, + "max": 114512, + "n": 1, + "samples": [114512] + }, + "max_alloc_block": { + "p50": 56232, + "p95": 56232, + "min": 56232, + "max": 56232, + "n": 1, + "samples": [56232] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3137, + "p95": 3255, + "min": 3137, + "max": 3255, + "n": 2, + "samples": [3137, 3255] + }, + "free_heap": { + "p50": 99488, + "p95": 104312, + "min": 99488, + "max": 104312, + "n": 2, + "samples": [104312, 99488] + }, + "max_alloc_block": { + "p50": 70384, + "p95": 72592, + "min": 70384, + "max": 72592, + "n": 2, + "samples": [70384, 72592] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "one-layer-again", + "op": "apply_state", + "document": { + "Effects": { + "Layer2": null + } + } + }, + { + "name": "sender-64x64-width", + "device": "sender", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "sender-64x64-height", + "device": "sender", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64 + }, + { + "name": "receiver-64x64-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "receiver-64x64-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64 + }, + { + "name": "protocols-identity", + "op": "set_control", + "id": "Grid", + "key": "serpentine", + "value": false + }, + { + "name": "protocols-mapped-by-receiver", + "op": "set_control", + "id": "NetworkReceive", + "key": "mapped_by", + "value": 0 + }, + { + "name": "protocol-artnet-set", + "device": "sender", + "op": "set_control", + "id": "Send", + "key": "protocol", + "value": 0 + }, + { + "name": "64x64-artnet", + "description": "4096 lights over artnet, identity layout, mapped by the receiver.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3426, + "p95": 3692, + "min": 3426, + "max": 3692, + "n": 2, + "samples": [3426, 3692] + }, + "free_heap": { + "p50": 8397855, + "p95": 8411803, + "min": 8397855, + "max": 8411803, + "n": 2, + "samples": [8411803, 8397855] + }, + "max_alloc_block": { + "p50": 31744, + "p95": 45056, + "min": 31744, + "max": 45056, + "n": 2, + "samples": [45056, 31744] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 109, + "p95": 116, + "min": 109, + "max": 116, + "n": 2, + "samples": [116, 109] + }, + "free_heap": { + "p50": 33811879, + "p95": 33872987, + "min": 33811879, + "max": 33872987, + "n": 2, + "samples": [33872987, 33811879] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 245760, + "min": 188416, + "max": 245760, + "n": 2, + "samples": [245760, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 3350, + "p95": 3472, + "min": 3256, + "max": 3472, + "n": 3, + "samples": [3256, 3472, 3350] + }, + "free_heap": { + "p50": 8419087, + "p95": 8419459, + "min": 8412399, + "max": 8419459, + "n": 3, + "samples": [8419087, 8412399, 8419459] + }, + "max_alloc_block": { + "p50": 36864, + "p95": 45056, + "min": 34816, + "max": 45056, + "n": 3, + "samples": [45056, 36864, 34816] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 113, + "p95": 831, + "min": 104, + "max": 831, + "n": 6, + "samples": [831, 117, 120, 104, 106, 113] + }, + "free_heap": { + "p50": 33812175, + "p95": 33858791, + "min": 33783283, + "max": 33858791, + "n": 6, + "samples": [33858791, 33858359, 33800783, 33783283, 33812507, 33812175] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 253952, + "min": 159744, + "max": 253952, + "n": 6, + "samples": [253952, 241664, 180224, 159744, 192512, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2139, + "p95": 2139, + "min": 2139, + "max": 2139, + "n": 1, + "samples": [2139] + }, + "free_heap": { + "p50": 270784, + "p95": 270784, + "min": 270784, + "max": 270784, + "n": 1, + "samples": [270784] + }, + "max_alloc_block": { + "p50": 167488, + "p95": 167488, + "min": 167488, + "max": 167488, + "n": 1, + "samples": [167488] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2417, + "p95": 2440, + "min": 2417, + "max": 2440, + "n": 2, + "samples": [2440, 2417] + }, + "free_heap": { + "p50": 263680, + "p95": 264320, + "min": 263680, + "max": 264320, + "n": 2, + "samples": [264320, 263680] + }, + "max_alloc_block": { + "p50": 149640, + "p95": 210536, + "min": 149640, + "max": 210536, + "n": 2, + "samples": [210536, 149640] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "protocol-e131-set", + "device": "sender", + "op": "set_control", + "id": "Send", + "key": "protocol", + "value": 1 + }, + { + "name": "64x64-e131", + "description": "4096 lights over e131, identity layout, mapped by the receiver.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3357, + "p95": 3490, + "min": 3357, + "max": 3490, + "n": 2, + "samples": [3357, 3490] + }, + "free_heap": { + "p50": 8414015, + "p95": 8417851, + "min": 8414015, + "max": 8417851, + "n": 2, + "samples": [8414015, 8417851] + }, + "max_alloc_block": { + "p50": 38912, + "p95": 47104, + "min": 38912, + "max": 47104, + "n": 2, + "samples": [47104, 38912] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 109, + "p95": 110, + "min": 109, + "max": 110, + "n": 2, + "samples": [109, 110] + }, + "free_heap": { + "p50": 33812367, + "p95": 33872439, + "min": 33812367, + "max": 33872439, + "n": 2, + "samples": [33872439, 33812367] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 245760, + "min": 188416, + "max": 245760, + "n": 2, + "samples": [245760, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 3461, + "p95": 3495, + "min": 3334, + "max": 3495, + "n": 3, + "samples": [3334, 3495, 3461] + }, + "free_heap": { + "p50": 8419667, + "p95": 8419679, + "min": 8405079, + "max": 8419679, + "n": 3, + "samples": [8405079, 8419679, 8419667] + }, + "max_alloc_block": { + "p50": 36864, + "p95": 40960, + "min": 34816, + "max": 40960, + "n": 3, + "samples": [40960, 36864, 34816] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 116, + "p95": 819, + "min": 108, + "max": 819, + "n": 6, + "samples": [819, 116, 121, 112, 108, 116] + }, + "free_heap": { + "p50": 33812079, + "p95": 33860755, + "min": 33781963, + "max": 33860755, + "n": 6, + "samples": [33860755, 33857391, 33815699, 33781963, 33812079, 33811995] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 253952, + "min": 159744, + "max": 253952, + "n": 6, + "samples": [253952, 241664, 180224, 159744, 192512, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2490, + "p95": 2490, + "min": 2490, + "max": 2490, + "n": 1, + "samples": [2490] + }, + "free_heap": { + "p50": 263976, + "p95": 263976, + "min": 263976, + "max": 263976, + "n": 1, + "samples": [263976] + }, + "max_alloc_block": { + "p50": 167488, + "p95": 167488, + "min": 167488, + "max": 167488, + "n": 1, + "samples": [167488] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2440, + "p95": 2450, + "min": 2440, + "max": 2450, + "n": 2, + "samples": [2440, 2450] + }, + "free_heap": { + "p50": 253320, + "p95": 263552, + "min": 253320, + "max": 263552, + "n": 2, + "samples": [263552, 253320] + }, + "max_alloc_block": { + "p50": 149640, + "p95": 210536, + "min": 149640, + "max": 210536, + "n": 2, + "samples": [210536, 149640] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "protocol-ddp-set", + "device": "sender", + "op": "set_control", + "id": "Send", + "key": "protocol", + "value": 2 + }, + { + "name": "64x64-ddp", + "description": "4096 lights over ddp, identity layout, mapped by the receiver.", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s", + "Drivers.tick" + ], + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 3261, + "p95": 3393, + "min": 3261, + "max": 3393, + "n": 2, + "samples": [3261, 3393] + }, + "free_heap": { + "p50": 8415903, + "p95": 8419455, + "min": 8415903, + "max": 8419455, + "n": 2, + "samples": [8419455, 8415903] + }, + "max_alloc_block": { + "p50": 38912, + "p95": 49152, + "min": 38912, + "max": 49152, + "n": 2, + "samples": [49152, 38912] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32s3-n16r8": { + "tick_us": { + "p50": 106, + "p95": 106, + "min": 106, + "max": 106, + "n": 2, + "samples": [106, 106] + }, + "free_heap": { + "p50": 33812315, + "p95": 33873411, + "min": 33812315, + "max": 33873411, + "n": 2, + "samples": [33873411, 33812315] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 245760, + "min": 188416, + "max": 245760, + "n": 2, + "samples": [245760, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=esp32s3-n16r8 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 3174, + "p95": 3273, + "min": 3137, + "max": 3273, + "n": 3, + "samples": [3174, 3273, 3137] + }, + "free_heap": { + "p50": 8419511, + "p95": 8419911, + "min": 8412755, + "max": 8419911, + "n": 3, + "samples": [8419511, 8419911, 8412755] + }, + "max_alloc_block": { + "p50": 36864, + "p95": 45056, + "min": 34816, + "max": 45056, + "n": 3, + "samples": [45056, 36864, 34816] + }, + "last_updated": "2026-10-14" + }, + "host=esp32p4rev1-eth sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 112, + "p95": 819, + "min": 103, + "max": 819, + "n": 6, + "samples": [819, 115, 115, 112, 103, 105] + }, + "free_heap": { + "p50": 33812611, + "p95": 33870303, + "min": 33783927, + "max": 33870303, + "n": 6, + "samples": [33859443, 33870303, 33801111, 33783927, 33812611, 33812975] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 253952, + "min": 159744, + "max": 253952, + "n": 6, + "samples": [253952, 249856, 180224, 159744, 192512, 188416] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32p4rev1-eth": { + "tick_us": { + "p50": 2394, + "p95": 2394, + "min": 2394, + "max": 2394, + "n": 1, + "samples": [2394] + }, + "free_heap": { + "p50": 264296, + "p95": 264296, + "min": 264296, + "max": 264296, + "n": 1, + "samples": [264296] + }, + "max_alloc_block": { + "p50": 167488, + "p95": 167488, + "min": 167488, + "max": 167488, + "n": 1, + "samples": [167488] + }, + "last_updated": "2026-10-14" + }, + "host=teensy41 sender=esp32s3-n16r8": { + "tick_us": { + "p50": 2481, + "p95": 2493, + "min": 2481, + "max": 2493, + "n": 2, + "samples": [2481, 2493] + }, + "free_heap": { + "p50": 259528, + "p95": 263312, + "min": 259528, + "max": 263312, + "n": 2, + "samples": [263312, 259528] + }, + "max_alloc_block": { + "p50": 149640, + "p95": 210536, + "min": 149640, + "max": 210536, + "n": 2, + "samples": [210536, 149640] + }, + "last_updated": "2026-10-14" + } + } + } + ] +} diff --git a/test/scenarios/device/scenario_NetworkSend_one_sender_many_receivers.json b/test/scenarios/device/scenario_NetworkSend_one_sender_many_receivers.json new file mode 100644 index 000000000..db873390f --- /dev/null +++ b/test/scenarios/device/scenario_NetworkSend_one_sender_many_receivers.json @@ -0,0 +1,676 @@ +{ + "name": "scenario_NetworkSend_one_sender_many_receivers", + "module": "NetworkSendDriver", + "also": [ + "NetworkReceiveEffect" + ], + "mode": "mutate", + "live_only": true, + "boards": [ + "sender", + "receiver2" + ], + "description": "One sender, two receivers: Network Send splits a grid over this device and receiver2 by unicast DDP, then sends one stream both take, by Art-Net broadcast and E1.31 multicast. Each receiver reports Network Receive's tick and its packets and frames per second against the sender's frame rate.", + "top": [ + "Layouts", + "Effects", + "Drivers" + ], + "steps": [ + { + "name": "sender-clear-layouts", + "op": "apply_state", + "document": { + "Layouts": { + "$patch": "replace" + } + }, + "description": "The sender's canvas: one grid, one layer with a changing effect, and a Network Send aimed at both receivers.", + "device": "sender" + }, + { + "name": "sender-clear-effects", + "op": "apply_state", + "document": { + "Effects": { + "$patch": "replace" + } + }, + "device": "sender" + }, + { + "name": "sender-grid", + "op": "apply_state", + "document": { + "Layouts": { + "Grid": { + "type": "GridLayout", + "width": 64, + "height": 64 + } + } + }, + "device": "sender" + }, + { + "name": "sender-layer", + "op": "apply_state", + "document": { + "Effects": { + "Layer": { + "type": "Layer", + "Rainbow": { + "type": "RainbowEffect" + } + } + } + }, + "device": "sender" + }, + { + "name": "sender-send", + "op": "apply_state", + "document": { + "Drivers": { + "Send": { + "type": "NetworkSendDriver", + "hosts": "{host},{receiver2}", + "protocol": 2, + "fps": 40 + } + } + }, + "device": "sender" + }, + { + "name": "host-clear-layouts", + "op": "apply_state", + "document": { + "Layouts": { + "$patch": "replace" + } + }, + "description": "A receiver's canvas: one grid and one layer holding Network Receive." + }, + { + "name": "host-clear-effects", + "op": "apply_state", + "document": { + "Effects": { + "$patch": "replace" + } + } + }, + { + "name": "host-grid", + "op": "apply_state", + "document": { + "Layouts": { + "Grid": { + "type": "GridLayout", + "width": 64, + "height": 32 + } + } + } + }, + { + "name": "host-layer", + "op": "apply_state", + "document": { + "Effects": { + "Layer": { + "type": "Layer", + "NetworkReceive": { + "type": "NetworkReceiveEffect" + } + } + } + } + }, + { + "name": "receiver2-clear-layouts", + "op": "apply_state", + "document": { + "Layouts": { + "$patch": "replace" + } + }, + "description": "A receiver's canvas: one grid and one layer holding Network Receive.", + "device": "receiver2" + }, + { + "name": "receiver2-clear-effects", + "op": "apply_state", + "document": { + "Effects": { + "$patch": "replace" + } + }, + "device": "receiver2" + }, + { + "name": "receiver2-grid", + "op": "apply_state", + "document": { + "Layouts": { + "Grid": { + "type": "GridLayout", + "width": 64, + "height": 32 + } + } + }, + "device": "receiver2" + }, + { + "name": "receiver2-layer", + "op": "apply_state", + "document": { + "Effects": { + "Layer": { + "type": "Layer", + "NetworkReceive": { + "type": "NetworkReceiveEffect" + } + } + } + }, + "device": "receiver2" + }, + { + "name": "split-64x64-sender-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "device": "sender" + }, + { + "name": "split-64x64-sender-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "device": "sender" + }, + { + "name": "split-64x64-host-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "split-64x64-host-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32 + }, + { + "name": "split-64x64-receiver2-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "device": "receiver2" + }, + { + "name": "split-64x64-receiver2-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "device": "receiver2" + }, + { + "name": "split-64x64-host", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s" + ], + "description": "4096 lights split over two receivers by unicast DDP, 2048 each. Read on host.", + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "tick_us": { + "p50": 2838, + "p95": 2876, + "min": 2838, + "max": 2876, + "n": 2, + "samples": [2838, 2876] + }, + "free_heap": { + "p50": 8441263, + "p95": 8443035, + "min": 8441263, + "max": 8443035, + "n": 2, + "samples": [8443035, 8441263] + }, + "max_alloc_block": { + "p50": 47104, + "p95": 49152, + "min": 47104, + "max": 49152, + "n": 2, + "samples": [49152, 47104] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "split-64x64-receiver2", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s" + ], + "description": "4096 lights split over two receivers by unicast DDP, 2048 each. Read on receiver2.", + "device": "receiver2", + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "tick_us": { + "p50": 81, + "p95": 82, + "min": 81, + "max": 82, + "n": 2, + "samples": [81, 82] + }, + "free_heap": { + "p50": 33835503, + "p95": 33891567, + "min": 33835503, + "max": 33891567, + "n": 2, + "samples": [33891567, 33835503] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 245760, + "min": 192512, + "max": 245760, + "n": 2, + "samples": [245760, 192512] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "split-128x96-sender-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128, + "device": "sender" + }, + { + "name": "split-128x96-sender-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 96, + "device": "sender" + }, + { + "name": "split-128x96-host-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128 + }, + { + "name": "split-128x96-host-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 48 + }, + { + "name": "split-128x96-receiver2-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128, + "device": "receiver2" + }, + { + "name": "split-128x96-receiver2-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 48, + "device": "receiver2" + }, + { + "name": "split-128x96-host", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s" + ], + "description": "12288 lights split over two receivers by unicast DDP, 6144 each. Read on host.", + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "tick_us": { + "p50": 3847, + "p95": 3980, + "min": 3847, + "max": 3980, + "n": 2, + "samples": [3980, 3847] + }, + "free_heap": { + "p50": 8384291, + "p95": 8394931, + "min": 8384291, + "max": 8394931, + "n": 2, + "samples": [8384291, 8394931] + }, + "max_alloc_block": { + "p50": 43008, + "p95": 47104, + "min": 43008, + "max": 47104, + "n": 2, + "samples": [43008, 47104] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "split-128x96-receiver2", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s" + ], + "description": "12288 lights split over two receivers by unicast DDP, 6144 each. Read on receiver2.", + "device": "receiver2", + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "tick_us": { + "p50": 134, + "p95": 135, + "min": 134, + "max": 135, + "n": 2, + "samples": [135, 134] + }, + "free_heap": { + "p50": 33788263, + "p95": 33849055, + "min": 33788263, + "max": 33849055, + "n": 2, + "samples": [33849055, 33788263] + }, + "max_alloc_block": { + "p50": 188416, + "p95": 253952, + "min": 188416, + "max": 253952, + "n": 2, + "samples": [253952, 188416] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "shared-sender-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "device": "sender" + }, + { + "name": "shared-sender-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "device": "sender" + }, + { + "name": "shared-host-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64 + }, + { + "name": "shared-host-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64 + }, + { + "name": "shared-receiver2-width", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "device": "receiver2" + }, + { + "name": "shared-receiver2-height", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "device": "receiver2" + }, + { + "name": "artnet-broadcast-protocol", + "op": "set_control", + "id": "Send", + "key": "protocol", + "value": 0, + "device": "sender" + }, + { + "name": "artnet-broadcast-addressing", + "op": "set_control", + "id": "Send", + "key": "addressing", + "value": 1, + "device": "sender" + }, + { + "name": "artnet-broadcast-host", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s" + ], + "description": "4096 lights to both receivers in one stream, Art-Net broadcast. Read on host.", + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "tick_us": { + "p50": 3555, + "p95": 3630, + "min": 3555, + "max": 3630, + "n": 2, + "samples": [3630, 3555] + }, + "free_heap": { + "p50": 8416775, + "p95": 8420143, + "min": 8416775, + "max": 8420143, + "n": 2, + "samples": [8420143, 8416775] + }, + "max_alloc_block": { + "p50": 47104, + "p95": 49152, + "min": 47104, + "max": 49152, + "n": 2, + "samples": [49152, 47104] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "artnet-broadcast-receiver2", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s" + ], + "description": "4096 lights to both receivers in one stream, Art-Net broadcast. Read on receiver2.", + "device": "receiver2", + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "tick_us": { + "p50": 108, + "p95": 108, + "min": 108, + "max": 108, + "n": 2, + "samples": [108, 108] + }, + "free_heap": { + "p50": 33813155, + "p95": 33874251, + "min": 33813155, + "max": 33874251, + "n": 2, + "samples": [33874251, 33813155] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 253952, + "min": 192512, + "max": 253952, + "n": 2, + "samples": [253952, 192512] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "e131-multicast-protocol", + "op": "set_control", + "id": "Send", + "key": "protocol", + "value": 1, + "device": "sender" + }, + { + "name": "e131-multicast-addressing", + "op": "set_control", + "id": "Send", + "key": "addressing", + "value": 1, + "device": "sender" + }, + { + "name": "e131-multicast-host", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s" + ], + "description": "4096 lights to both receivers in one stream, E1.31 multicast. Read on host.", + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "tick_us": { + "p50": 3206, + "p95": 3249, + "min": 3206, + "max": 3249, + "n": 2, + "samples": [3249, 3206] + }, + "free_heap": { + "p50": 8419959, + "p95": 8420095, + "min": 8419959, + "max": 8420095, + "n": 2, + "samples": [8420095, 8419959] + }, + "max_alloc_block": { + "p50": 47104, + "p95": 49152, + "min": 47104, + "max": 49152, + "n": 2, + "samples": [49152, 47104] + }, + "last_updated": "2026-10-14" + } + } + }, + { + "name": "e131-multicast-receiver2", + "op": "measure", + "read": [ + "sender:fps", + "NetworkReceive.tick", + "NetworkReceive.packets_per_s", + "NetworkReceive.frames_per_s" + ], + "description": "4096 lights to both receivers in one stream, E1.31 multicast. Read on receiver2.", + "device": "receiver2", + "observed": { + "host=esp32s3-n16r8 sender=esp32s3-n16r8 receiver2=esp32p4rev1-eth": { + "tick_us": { + "p50": 100, + "p95": 100, + "min": 100, + "max": 100, + "n": 2, + "samples": [100, 100] + }, + "free_heap": { + "p50": 33813251, + "p95": 33874679, + "min": 33813251, + "max": 33874679, + "n": 2, + "samples": [33874679, 33813251] + }, + "max_alloc_block": { + "p50": 192512, + "p95": 253952, + "min": 192512, + "max": 253952, + "n": 2, + "samples": [253952, 192512] + }, + "last_updated": "2026-10-14" + } + } + } + ] +} diff --git a/test/unit/core/http_loopback.h b/test/unit/core/http_loopback.h index 078dd6fef..c02258680 100644 --- a/test/unit/core/http_loopback.h +++ b/test/unit/core/http_loopback.h @@ -1,6 +1,6 @@ #pragma once -// Loopback helpers the HTTP server tests share: connect a client and let the server accept it, tick until a reply arrives, read a close, make a payload. +/// Loopback helpers the HTTP server tests share: connect a client and let the server accept it, tick until a reply arrives, read a close, make a payload. #include "core/system/HttpServerModule.h" #include "platform/platform.h" diff --git a/test/unit/core/unit_platform_worker.cpp b/test/unit/core/unit_platform_worker.cpp index f256d3443..803a06868 100644 --- a/test/unit/core/unit_platform_worker.cpp +++ b/test/unit/core/unit_platform_worker.cpp @@ -1,6 +1,6 @@ /// @module platform -/// Pins the platform worker-task seam (spawnPinnedTask / notifyTask / waitNotify / stopPinnedTask) that the multicore render↔encode split is built on. On the host the seam is a std::thread + condition_variable, so these tests run the REAL producer/consumer handoff on a second thread, the same invariants the ESP32 core-1 encode task relies on, exercised where CI can catch a race (run the suite under TSan/ASan). No RTOS on the host, so the `core` pin is a no-op; the wake, the single-slot latch, the timeout, and the clean stop-drain are what's under test. +/// Pins the worker-task seam the multicore render/encode split is built on: on the host a std::thread and a condition variable, so these tests run the real handoff where CI can catch a race; the core pin is a no-op. #include "doctest.h" #include "platform/platform.h" @@ -13,7 +13,7 @@ using namespace std::chrono_literals; namespace { -// A worker body that counts wakes until stopped. `wakes` is the consumer-visible proof each notify was delivered exactly once; `running` proves the fn actually started; the loop exits when a woken waitNotify returns while `stop` is set (the production shape: wake, re-check stop, drain, return). +// A worker body counting wakes until stopped: `wakes` proves each notify landed once, `running` that the fn started; it exits when a wake finds `stop` set, the production shape. struct Counter { mm::platform::WorkerTask task; std::atomic<int> wakes{0}; @@ -73,7 +73,7 @@ TEST_CASE("worker seam: waitNotify times out and returns false when not notified mm::platform::stopPinnedTask(p.task); } -TEST_CASE("worker seam: stopPinnedTask joins — the fn has returned before it returns") { +TEST_CASE("worker seam: stopPinnedTask joins: the fn has returned before it returns") { // The stop must be a real join: after stopPinnedTask returns, the worker body is guaranteed done (so its buffers are safe to free, the render-split drain contract). A flag the body sets last proves it ran to completion before stop returned. struct Joiner { mm::platform::WorkerTask task; @@ -92,8 +92,8 @@ TEST_CASE("worker seam: stopPinnedTask joins — the fn has returned before it r } // --- TryLock: the cross-core sender latch --------------------------------------------------------- -// The WS sender has two producers on two cores once the split engages (core 0's drain + state push, core 1's offloaded PreviewDriver). TryLock is what excludes them. Try-only BY DESIGN: a caller on the render/encode thread must never block on a peer (hot-path rule), so the loser SKIPS its slot. -// That constraint is what lets it be a plain atomic_flag test-and-set rather than an OS mutex. +// The WS sender has two producers on two cores under the split, core 0's drain and core 1's PreviewDriver, and TryLock excludes them; try-only by design, so the loser skips its slot. +// That constraint is what lets it be an atomic exchange on a flag rather than an OS mutex. TEST_CASE("TryLock: acquiring excludes a second acquirer, release re-opens it") { mm::TryLock lk; diff --git a/test/unit/light/unit_NetworkReceiveEffect.cpp b/test/unit/light/unit_NetworkReceiveEffect.cpp index b7c3a12c7..edfad257f 100644 --- a/test/unit/light/unit_NetworkReceiveEffect.cpp +++ b/test/unit/light/unit_NetworkReceiveEffect.cpp @@ -9,6 +9,8 @@ #include "platform/platform.h" #include <cstring> +#include <string> +#include <vector> // The Art-Net receive side: parsing, universe placement and clamping, the staging buffer's lifecycle, and one real loopback round trip. @@ -237,3 +239,183 @@ TEST_CASE("NetworkReceiveEffect receives over localhost UDP") { tx.close(); r.fx.release(); } + +// The per-second counts are what a scenario compares with what the sender sent: one DDP frame of two packets, the second pushing, reads as two packets and one frame over one second, and the next second starts from zero. +TEST_CASE("NetworkReceiveEffect counts the packets and frames of the last second") { + mm::platform::setTestNowMs(1000); // the rate window starts when the stream does, on the test clock + Rig r; + r.fx.setup(); + REQUIRE(r.fx.status() == nullptr); // binds succeeded + mm::platform::UdpSocket tx; + REQUIRE(tx.open()); + const uint8_t loopback[4] = {127, 0, 0, 1}; + uint8_t payload[3] = {1, 2, 3}; + uint8_t pkt[mm::DDP_HEADER_SIZE + 3]; + REQUIRE(tx.sendToAddr(loopback, mm::DDP_PORT, pkt, mm::buildDdpPacket(pkt, 0, false, payload, 3))); + REQUIRE(tx.sendToAddr(loopback, mm::DDP_PORT, pkt, mm::buildDdpPacket(pkt, 3, true, payload, 3))); + // UDP on loopback is asynchronous: poll the frame loop until the pushed packet has landed, bounded at about 100 ms. + for (int i = 0; i < 100 && r.layer.buffer().data()[3] != 1; i++) { + r.layer.tick(); + mm::platform::delayMs(1); + } + mm::platform::setTestNowMs(2000); // one second into the stream: the rate is the count + r.fx.tick1s(); + CHECK(r.fx.packetsPerS == 2); + CHECK(r.fx.framesPerS == 1); + mm::platform::setTestNowMs(3000); + r.fx.tick1s(); + CHECK(r.fx.packetsPerS == 0); + CHECK(r.fx.framesPerS == 0); + mm::platform::setTestNowMs(0); + tx.close(); + r.fx.release(); +} + +// --- mapped by: the receiver against the sender ------------------------------ + +namespace { + +// Which value each physical light shows once the layer maps its buffer out: index = driver index, value = the red byte that reached it, 0 for none. +std::vector<uint8_t> redPerDriverLight(const mm::Layer& layer) { + const mm::nrOfLightsType n = layer.physicalLightCount(); + std::vector<uint8_t> out(n, 0); + const uint8_t* buf = layer.buffer().data(); + const mm::MappingLUT& lut = layer.lut(); + for (mm::nrOfLightsType cell = 0; cell < layer.buffer().count(); cell++) { + const uint8_t red = buf[static_cast<size_t>(cell) * 3]; + if (!lut.hasLUT()) { if (cell < n) out[cell] = red; continue; } + lut.forEachDestination(cell, [&](mm::nrOfLightsType dst) { if (dst < n) out[dst] = red; }); + } + return out; +} + +// A frame whose light k carries red = k + 1, so every value names the wiring position it was sent for. +std::vector<uint8_t> numberedFrame(size_t lights) { + std::vector<uint8_t> f(lights * 3, 0); + for (size_t k = 0; k < lights; k++) f[k * 3] = static_cast<uint8_t>(k + 1); + return f; +} + +struct OrderRig { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::NetworkReceiveEffect fx; + + OrderRig(mm::lengthType w, mm::lengthType h, mm::lengthType d, uint8_t mappedBy, bool serpentine = true) { + grid.width = w; + grid.height = h; + grid.depth = d; + grid.serpentine = serpentine; + fx.mappedBy = mappedBy; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&fx); + fx.defineControls(); + layer.applyState(); + } + // A numbered frame of `lights` lights into staging, then one frame of the layer. + void sendNumbered(size_t lights) { + const std::vector<uint8_t> frame = numberedFrame(lights); + fx.applyBytes(0, frame.data(), static_cast<uint16_t>(frame.size())); + layer.tick(); + } + // Each of the first `cells` logical cells shows what was sent for it. + void checkCellsAsSent(size_t cells) { + const uint8_t* buf = layer.buffer().data(); + for (size_t cell = 0; cell < cells; cell++) CHECK(buf[cell * 3] == cell + 1); + } + // Each of the first `lights` physical lights shows what was sent for it. + void checkLightsAsSent(size_t lights) { + const std::vector<uint8_t> seen = redPerDriverLight(layer); + for (size_t k = 0; k < lights; k++) CHECK(seen[k] == k + 1); + } +}; + +} // namespace + +// A sender sending row by row leaves the wiring to this board, so the layer applies the serpentine once and cell (x, y) shows what was sent for it. +TEST_CASE("NetworkReceiveEffect mapped by the receiver lets the layer apply this board's wiring") { + OrderRig r(4, 4, 1, mm::NetworkReceiveEffect::kMappedByReceiver); + REQUIRE(r.layer.lut().hasLUT()); // serpentine: the mapping is not the identity + r.sendNumbered(16); + r.checkCellsAsSent(16); +} + +// A mapping sender (xLights, FSEQ, Network Send) sends in wiring order, so light k shows what was sent for k; copied by index, every other row would reverse. +TEST_CASE("NetworkReceiveEffect mapped by the sender reaches each light as sent, on a serpentine grid") { + OrderRig r(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender); + r.sendNumbered(16); + r.checkLightsAsSent(16); +} + +// The same on a 3D layout, two slices of a serpentine grid, the shape of TouchMyLight's cube in small. +TEST_CASE("NetworkReceiveEffect mapped by the sender reaches each light as sent, on a 3D layout") { + OrderRig r(4, 4, 2, mm::NetworkReceiveEffect::kMappedBySender); + r.sendNumbered(32); + r.checkLightsAsSent(32); +} + +// On a layout already in wiring order, a plain grid with no modifiers, the two coincide and sender mapping is the same single copy. +TEST_CASE("NetworkReceiveEffect mapped by the sender on an identity layout is the same copy as mapped by the receiver") { + OrderRig r(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender, /*serpentine=*/false); + REQUIRE_FALSE(r.layer.lut().hasLUT()); // the identity fast path + r.sendNumbered(16); + r.checkCellsAsSent(16); +} + +// The status tells the user what sender mapping costs: one copy on an identity mapping, mapped back and forward again on any other. +TEST_CASE("NetworkReceiveEffect mapped by the sender says in its status whether a frame is one copy or mapped twice") { + const uint8_t ip[4] = {10, 0, 0, 7}; + OrderRig plain(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender, /*serpentine=*/false); + plain.fx.recordSender("DDP", ip); + plain.layer.tick(); + REQUIRE(plain.fx.status() != nullptr); + CHECK(std::string(plain.fx.status()) == "receiving DDP from 10.0.0.7, one copy"); + plain.fx.release(); + + OrderRig serpentine(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender); + serpentine.fx.recordSender("DDP", ip); + serpentine.layer.tick(); + REQUIRE(serpentine.fx.status() != nullptr); + CHECK(std::string(serpentine.fx.status()) == "receiving DDP from 10.0.0.7, mapped twice"); + serpentine.fx.release(); +} + +// Receiver mapping maps once, as every layer does, so the status names only the sender. +TEST_CASE("NetworkReceiveEffect mapped by the receiver names only the sender in its status") { + const uint8_t ip[4] = {10, 0, 0, 7}; + OrderRig r(4, 4, 1, mm::NetworkReceiveEffect::kMappedByReceiver); + r.fx.recordSender("Art-Net", ip); + r.layer.tick(); + REQUIRE(r.fx.status() != nullptr); + CHECK(std::string(r.fx.status()) == "receiving Art-Net from 10.0.0.7"); + r.fx.release(); +} + +// A rate divides by the time that actually passed, so a late housekeeping tick reports a rate rather than the sum of several seconds. +TEST_CASE("NetworkReceiveEffect reports its counts per second of elapsed time") { + mm::platform::setTestNowMs(1000); // the rate window starts when the stream does, on the test clock + Rig r; + r.fx.setup(); + REQUIRE(r.fx.status() == nullptr); + mm::platform::UdpSocket tx; + REQUIRE(tx.open()); + const uint8_t loopback[4] = {127, 0, 0, 1}; + uint8_t pkt[mm::DDP_HEADER_SIZE + 3]; + for (uint8_t k = 0; k < 4; k++) { // four lights, each its own packet, the last marking the frame + uint8_t payload[3] = {static_cast<uint8_t>(10 + k), 0, 0}; + REQUIRE(tx.sendToAddr(loopback, mm::DDP_PORT, pkt, mm::buildDdpPacket(pkt, k * 3u, true, payload, 3))); + } + // A packet is counted before it is written, so the last light landing means all four were counted; bounded at about 100 ms. + for (int i = 0; i < 100 && r.fx.stagingData()[9] != 13; i++) mm::platform::delayMs(1); + REQUIRE(r.fx.stagingData()[9] == 13); + mm::platform::setTestNowMs(3000); // two seconds into the stream + r.fx.tick1s(); + CHECK(r.fx.packetsPerS == 2); // four packets over two seconds + CHECK(r.fx.framesPerS == 2); + mm::platform::setTestNowMs(0); + tx.close(); + r.fx.release(); +} diff --git a/test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp b/test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp index f60e50394..63bc7b60d 100644 --- a/test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp +++ b/test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp @@ -3,7 +3,7 @@ /// Pins the no-allocation-in-loop contract for NetworkSendDriver. The framework fires prepare (topology) and onCorrectionChanged (preset toggle) off the hot path; tick() must read the pre-sized buffer and never allocate. /// -/// We can't observe a malloc from inside the test without an interceptor, so the contract is pinned indirectly: capture the corrected_ buffer's data pointer and size after a build-state, then assert they're already correct (the resize fired before any tick()) and that the buffer matches the source. +/// A malloc cannot be seen without an interceptor, so the contract is pinned indirectly: after a build-state the corrected_ buffer's pointer and shape are already right, before any tick(), and stay so. #include "doctest.h" #include "light/drivers/NetworkSendDriver.h" @@ -30,7 +30,7 @@ TEST_CASE("NetworkSendDriver sizes corrected_ in prepare, not in tick") { CHECK(driver.correctedBuffer().count() == 64); CHECK(driver.correctedBuffer().channelsPerLight() == 3); - // tick() must not reallocate, same backing pointer, same shape, on every protocol path (ArtNet, E1.31, DDP all share the pre-sized buffer and a stack packet). Virtual time advances past the fps limiter between protocols so each send path actually executes. + // tick() keeps the backing pointer and shape on every protocol path, all sharing the pre-sized buffer and the member packet; virtual time passes the fps limiter between protocols so each path sends. const uint8_t* dataBefore = driver.correctedBuffer().data(); for (uint8_t p = 0; p < mm::NetworkSendDriver::kProtocolCount; p++) { mm::platform::setTestNowMs(1000u + 100u * p); diff --git a/test/unit/light/unit_NetworkSendDriver_packet.cpp b/test/unit/light/unit_NetworkSendDriver_packet.cpp index 63052edf2..e9286219b 100644 --- a/test/unit/light/unit_NetworkSendDriver_packet.cpp +++ b/test/unit/light/unit_NetworkSendDriver_packet.cpp @@ -2,10 +2,12 @@ #include "doctest.h" #include "light/drivers/NetworkSendDriver.h" +#include "correction_presets.h" #include <cstring> #include <cstdio> #include <string> +#include <vector> // The built packet contains the exact header layout the Art-Net spec mandates: ID, OpCode, version, sequence, physical, universe, length, data. TEST_CASE("ArtNet packet header format") { @@ -355,3 +357,88 @@ TEST_CASE("NetworkSendDriver: E1.31 multicast on a network that drops it says to mm::NetworkPath::multicastDropped = false; CHECK(status.find("use unicast") != std::string::npos); } + +namespace { +// A sender of `lights` RGB lights aimed at this host over DDP, corrected so the frame is the task's to send. +struct PaceRig { + mm::Buffer source; + mm::NetworkSendDriver driver; + mm::platform::UdpSocket rx; + PaceRig(mm::nrOfLightsType lights, uint16_t gapUs, uint8_t fps) { + REQUIRE(source.allocate(lights, 3)); + REQUIRE(rx.open()); + REQUIRE(rx.bind(mm::DDP_PORT)); // a running desktop app holds this port: don't run it and the tests at once + mm::Correction correction; + mm::test::rebuildFromPreset(correction, 255, mm::test::PresetOrder::RGB); + driver.correctionForTest() = correction; + driver.protocol = mm::NetworkSendDriver::kProtoDdp; + std::snprintf(driver.hosts, sizeof(driver.hosts), "%s", "127.0.0.1"); + driver.packetGap = gapUs; + driver.fps = fps; + driver.setSourceBuffer(&source); + driver.applyState(); + mm::platform::setTestNowMs(1000); // past the fps limiter, which a test starting at process launch is not + } + ~PaceRig() { driver.release(); rx.close(); mm::platform::setTestNowMs(0); } + // The arrival time of each of `count` packets, polling up to about half a second. + std::vector<uint32_t> arrivals(size_t count) { + std::vector<uint32_t> at; + uint8_t buf[1500]; + uint8_t ip[4]; + for (int i = 0; i < 5000 && at.size() < count; i++) { + if (rx.recvFrom(buf, sizeof(buf), ip) > 0) at.push_back(mm::platform::micros()); + else mm::platform::delayUs(100); + } + return at; + } +}; +} // namespace + +// A burst is what a receiver loses, so a packet gap spaces a frame's packets, and a task sends them so the render loop never waits. +TEST_CASE("NetworkSendDriver: a packet gap spaces a frame's packets, sent from a task") { + PaceRig r(1000, 3000, 20); // 3000 bytes: three DDP packets, 3 ms apart + REQUIRE(r.driver.sendsInTask()); + r.driver.tick(); + const std::vector<uint32_t> at = r.arrivals(3); + REQUIRE(at.size() == 3); + for (size_t i = 1; i < at.size(); i++) CHECK(at[i] - at[i - 1] >= 2000); // the gap, less scheduling slack +} + +// A gap of 0 keeps the burst from the tick, with no task: the choice for a board that cannot spare one. +TEST_CASE("NetworkSendDriver: a packet gap of 0 sends the frame as one burst from the tick") { + PaceRig r(1000, 0, 20); + CHECK_FALSE(r.driver.sendsInTask()); + r.driver.tick(); + CHECK(r.arrivals(3).size() == 3); +} + +// A gap too long for the frame rate is shortened so a frame's packets fit in 80% of its period, leaving the rest for the next. +TEST_CASE("NetworkSendDriver: a long packet gap is shortened so the frame fits its fps period") { + PaceRig r(1000, 5000, 100); // 10 ms per frame at 100 fps; three packets plus one destination + CHECK(r.driver.effectiveGap(3000, 3) == 8000 / 3); + r.driver.fps = 10; // 100 ms per frame: the configured gap fits as it is + CHECK(r.driver.effectiveGap(3000, 3) == 5000); +} + +// The send task reads the corrected buffer while it paces a frame, so a correction that grows the buffer waits the task out and restarts it; the next frame goes out whole, four channels a light. +TEST_CASE("NetworkSendDriver: a correction that grows the buffer under a paced send restarts the task safely") { + PaceRig r(400, 1000, 20); // 1200 bytes of RGB: one DDP packet + REQUIRE(r.driver.sendsInTask()); + r.driver.tick(); + REQUIRE(r.arrivals(1).size() == 1); + mm::test::rebuildFromPreset(r.driver.correctionForTest(), 255, mm::test::PresetOrder::RGBW); + r.driver.onCorrectionChanged(); // 400 lights at four channels: the buffer reallocates + CHECK(r.driver.sendsInTask()); + CHECK(r.driver.correctedBuffer().channelsPerLight() == 4); + mm::platform::setTestNowMs(2000); // past the fps limiter again + r.driver.tick(); + uint8_t buf[1500]; + uint8_t ip[4]; + size_t bytes = 0; + for (int i = 0; i < 5000 && bytes < 1600; i++) { + const int n = r.rx.recvFrom(buf, sizeof(buf), ip); + if (n > 0) bytes += static_cast<size_t>(n) - mm::DDP_HEADER_SIZE; + else mm::platform::delayUs(100); + } + CHECK(bytes == 1600); // 400 lights x 4 channels, all of it +} From f487420a95738b7bb51d34273a139ab1b8531d45 Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Wed, 14 Oct 2026 19:54:25 +0200 Subject: [PATCH 3/4] Streams groundwork: one-copy sender frames, Picture, FSEQ player stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stream mapped by its sender (xLights, FSEQ, Network Send) now reaches the LEDs in one copy on any layout, halving the S3's tick at 64x64, and a modifier on its layer transforms it. A new Picture service and Picture effect show a test card on any layout, fitted, filled or stretched, ready for USB capture and a browser source to feed them. The installer resends settings the board refused, lets a mistyped WiFi password be corrected in place, and applies a device's defaults without reflashing. KPI: 256lights | Desktop:2326KB | tick:6/9/7/2/12/8/274/7/14/14us(FPS:166666/111111/142857/500000/83333/125000/3649/142857/71428/71428) | src:331(84711) | test:243(55778) | functions-over:353 Commit: src +820/-143 lines | test +659/-106 lines | duplicated lines 3654 -> 3654 (+0) **Core** - PictureFrame and PictureService (G3): a picture source in Audio's pattern, the PM5544 test card drawn at any size, a Reader that holds the picture for one gather, and two pictures swapped by pointer for a source writing from its own task, degrading to one locked picture when the second does not fit (the groundwork #139's USB capture lands on). - WiFi join window shared by NetworkModule and the Improv answer (JoinWindow.h): 10 s to join, plus 15 s for the lease once joined, so a slow router no longer drops a joined board to its access point, and a wrong password is reported in about 12 s instead of an installer timeout. - Platform: wifiStaAssociated() on ESP32, desktop and the no-WiFi stub; the USB-Serial-JTAG console moved onto the driver, so S3 and P4 boards log on their native USB port in the standard build. - ES7210 microphone codec beside the ES8311, behind a new MM_AUDIO_CODEC Kconfig gate (on for S31, P4 and the Waveshare ESP32-S3-RGB-Matrix image), so boards with an INMP441-style mic carry no codec library. **Light domain** - G5: Layer::wiringSource decides when a wiring-order frame goes straight to the output, and Drivers copies it there; on the S3 at 64x64 serpentine the tick fell from 5.83 to 2.96 ms. Under a static modifier the frame is placed by the layout's positions (Layer::placeByLayout), so a mirror or transpose transforms it. - G6: ChannelDataSink holds, places and copies channel data for Network Receive and the new FseqPlayerEffect, a registered stub with file, loop and speed that reads no file yet (TouchMyLight's contribution). - G4: PictureEffect samples the picture per light, fit, fill or stretch, point or area in linear light, from separable range tables refilled without allocating. - Drivers composites through one composite() helper; BeatRipples walks the whole palette, its index wrapping with height and shifting per stone. **UI** - Installer: stop-and-wait APPLY_OP that reads the board's replies and resends a refused or unanswered document; a WiFi form that comes back with the reason after a failed join; "Apply device defaults now, without flashing"; an erase progress bar estimated at 3 s per MB. - Tooltips for the picture and recorded-show emoji. - Waveshare ESP32-S3-RGB-Matrix device model: ES7210 audio and BeatRipples as its default effect. **Scripts/MoonDeck** - fseq_play: writes and plays uncompressed FSEQ v2 over DDP, the reference sender for FSEQ. - monitor_esp32 raises only the board on its port to Info, in the background, restores it on exit or SIGTERM, and opens the port without asserting DTR/RTS; collect_kpi raises only its own board and says when none is registered. - screenshot_modules lets the device name the modules it adds, which the 15-character name limit refused for long type names. **Tests** - G5: pass-through on serpentine and 3D layouts, a mirror and a transpose on sender-mapped frames, both failing with the placement switched off. - PictureService: the test card's landmarks, the generation, the empty frame, and a writer task racing a reader with two pictures and with one, both reading every picture whole (the one-picture case fails with its lock removed). - PictureEffect: point against area by hand, fit, fill and stretch, a cube, no resampling without a new picture; FseqPlayerEffect: its output path and status; BeatRipples: the palette spread; NetworkModule: the join window and a rejoin after a dropout; installer reply parsing. **Docs/CI** - Streams plan: G3 to G7 built, G8 (the browser as a picture source) added with recording and baking as follow-ups; the how-to for adding a picture or channel-data source (G7); cards and images for Picture, FseqPlayer and the new Network Receive card. - CLAUDE.md: every pull request is based on main, the one exception a step of a larger topic. **Reviews** - 👾 the background log-level raise could hold the port for about 75 s on an unreachable board: fixed, it stops with the monitor. - 👾 collect_kpi skipped a board with no recorded port silently: fixed, it says so. - 👾 wiringSource computed per frame: kept, since a state document that changes mapped_by re-prepares only the effect, so a cached answer would go stale. - 👾 any Improv error aborted the defaults push: fixed, only "unknown RPC" means unsupported, the rest is resent. - 👾 the two-picture writer path has no writer yet: accepted, it is #139's groundwork, built on request before this commit; the double generation bump in prepare fixed. - 👾 FseqPlayerEffect's default dimensions override and PictureEffect's unexplained include: fixed. - 🐇 CLAUDE.md's generated-file wording, the plan's pacing paragraph, a long test comment, an unsynchronized staging read in a test: fixed; the repo-health coverage count: not a defect, marker and summary share the n >= 4 threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/assets/core/PictureService.png | Bin 0 -> 12502 bytes .../assets/light/effects/FseqPlayerEffect.gif | Bin 0 -> 331361 bytes .../assets/light/effects/FseqPlayerEffect.png | Bin 0 -> 16270 bytes .../light/effects/NetworkReceiveEffect.png | Bin 17985 -> 32366 bytes docs/assets/light/effects/PictureEffect.gif | Bin 0 -> 32710 bytes docs/assets/light/effects/PictureEffect.png | Bin 0 -> 12571 bytes docs/how-to/adding-a-stream-source.md | 95 ++++++++ docs/moonmodules/core/services.md | 19 +- docs/moonmodules/light/effects.md | 37 ++- docs/reference/metrics/code.md | 40 +-- docs/reference/metrics/docgen.md | 38 +-- docs/reference/metrics/prose.md | 10 +- docs/reference/metrics/repo-health.json | 88 +++---- docs/reference/metrics/repo-health.md | 90 +++---- docs/tutorials/how-moonlight-works.md | 2 + ...013 - Streams, every frame from outside.md | 107 ++++++-- esp32/main/Kconfig.projbuild | 11 + esp32/main/idf_component.yml | 11 +- esp32/sdkconfig.defaults.esp32s3-n32r16 | 3 + mkdocs.yml | 1 + moondeck/MoonDeck.md | 13 + moondeck/_moondeck_config.py | 62 +++-- moondeck/check/collect_kpi.py | 14 +- moondeck/docs/screenshot_modules.py | 30 ++- moondeck/moondeck_config.json | 10 + moondeck/run/fseq_play.py | 144 +++++++++++ moondeck/run/monitor_esp32.py | 11 +- mooninstaller/deviceModels.json | 21 +- mooninstaller/improv-frame.js | 37 +++ mooninstaller/index.html | 7 +- mooninstaller/install-orchestrator.js | 206 +++++++++++----- mooninstaller/install.css | 6 +- mooninstaller/install.js | 91 +++++-- src/core/services/AudioService.h | 13 +- src/core/services/PictureService.h | 229 ++++++++++++++++++ src/core/system/NetworkModule.h | 20 +- src/core/util/JoinWindow.h | 19 ++ src/core/util/PictureFrame.h | 17 ++ src/light/drivers/Drivers.h | 43 ++-- src/light/effects/BeatRipplesEffect.h | 24 +- src/light/effects/ChannelDataSink.h | 133 ++++++++++ src/light/effects/EffectBase.h | 5 + src/light/effects/FseqPlayerEffect.h | 54 +++++ src/light/effects/NetworkReceiveEffect.h | 84 ++----- src/light/effects/PictureEffect.h | 164 +++++++++++++ src/light/layers/Layer.h | 54 ++++- src/light/layers/MappingLUT.h | 14 +- src/module_types.cpp | 6 + src/platform/desktop/platform_desktop.cpp | 3 + src/platform/esp32/platform_esp32.cpp | 5 + src/platform/esp32/platform_esp32_es8311.cpp | 50 ++-- src/platform/esp32/platform_esp32_improv.cpp | 18 +- src/platform/platform.h | 6 +- src/ui/app.js | 2 + test/CMakeLists.txt | 3 + test/js/improv-frame.test.mjs | 47 ++++ test/python/test_fseq_play.py | 67 +++++ ...lette_preset_changes_only_the_palette.json | 8 +- ...ment_adds_an_effect_with_its_controls.json | 8 +- ...cenario_Drivers_output_and_brightness.json | 4 +- ...o_Effects_pipeline_builds_and_renders.json | 22 +- .../scenario_Effects_swap_while_running.json | 8 +- ...cts_teardown_under_a_running_pipeline.json | 30 +-- .../scenario_Layers_stack_and_blend_live.json | 42 ++-- ...nario_Layouts_resize_reallocates_live.json | 12 +- ...cenario_Modifiers_reshape_the_mapping.json | 28 +-- ..._Palettes_a_script_drives_the_effects.json | 14 +- test/unit/core/unit_AudioService_codec.cpp | 9 +- test/unit/core/unit_NetworkModule.cpp | 51 +++- test/unit/core/unit_PictureService.cpp | 126 ++++++++++ test/unit/core/unit_platform_worker.cpp | 2 +- test/unit/light/unit_BeatRipples.cpp | 14 +- test/unit/light/unit_FseqPlayerEffect.cpp | 46 ++++ test/unit/light/unit_NetworkReceiveEffect.cpp | 90 ++++++- test/unit/light/unit_PictureEffect.cpp | 134 ++++++++++ 76 files changed, 2401 insertions(+), 533 deletions(-) create mode 100644 docs/assets/core/PictureService.png create mode 100644 docs/assets/light/effects/FseqPlayerEffect.gif create mode 100644 docs/assets/light/effects/FseqPlayerEffect.png create mode 100644 docs/assets/light/effects/PictureEffect.gif create mode 100644 docs/assets/light/effects/PictureEffect.png create mode 100644 docs/how-to/adding-a-stream-source.md create mode 100644 moondeck/run/fseq_play.py create mode 100644 src/core/services/PictureService.h create mode 100644 src/core/util/JoinWindow.h create mode 100644 src/core/util/PictureFrame.h create mode 100644 src/light/effects/ChannelDataSink.h create mode 100644 src/light/effects/FseqPlayerEffect.h create mode 100644 src/light/effects/PictureEffect.h create mode 100644 test/python/test_fseq_play.py create mode 100644 test/unit/core/unit_PictureService.cpp create mode 100644 test/unit/light/unit_FseqPlayerEffect.cpp create mode 100644 test/unit/light/unit_PictureEffect.cpp diff --git a/CLAUDE.md b/CLAUDE.md index e32d72a12..dae233686 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ 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 and generated files (metrics, images, scenario observations) not counted. 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. A larger topic lands in steps, each its own pull request based on main. A stacked pull request from a fork shows its parents, so it is reviewed once the one below it merges. +**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 diff --git a/docs/assets/core/PictureService.png b/docs/assets/core/PictureService.png new file mode 100644 index 0000000000000000000000000000000000000000..b4cf26477bdc11b40590d96de9fb26c90ef5a6ff GIT binary patch literal 12502 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 literal 0 HcmV?d00001 diff --git a/docs/assets/light/effects/FseqPlayerEffect.gif b/docs/assets/light/effects/FseqPlayerEffect.gif new file mode 100644 index 0000000000000000000000000000000000000000..35ccfaf22320939d2d039e28fe2d3c1811fa9dce GIT binary patch literal 331361 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¬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 literal 0 HcmV?d00001 diff --git a/docs/assets/light/effects/FseqPlayerEffect.png b/docs/assets/light/effects/FseqPlayerEffect.png new file mode 100644 index 0000000000000000000000000000000000000000..9a4e9878f86e542cddd6a4dbd81a377316f57d67 GIT binary patch literal 16270 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 IDQN`xKc*Ui4FCWD literal 0 HcmV?d00001 diff --git a/docs/assets/light/effects/NetworkReceiveEffect.png b/docs/assets/light/effects/NetworkReceiveEffect.png index 5fb8c56fafd5cd9ff91521409779ce6a007aba2a..8d0251321151e04d2bb8691fdb8334598dea93cd 100644 GIT binary patch literal 32366 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¥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 D$NrT~ literal 17985 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 Z46gQC<x{45Rs%&CJYD@<);T3K0RRW|9#jAT diff --git a/docs/assets/light/effects/PictureEffect.gif b/docs/assets/light/effects/PictureEffect.gif new file mode 100644 index 0000000000000000000000000000000000000000..b66eeab549f0c17fb72b7d8436121441be9c54a9 GIT binary patch literal 32710 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 W02~TfLZklg#{Uh;|F<85^Zx++z<Xf; literal 0 HcmV?d00001 diff --git a/docs/assets/light/effects/PictureEffect.png b/docs/assets/light/effects/PictureEffect.png new file mode 100644 index 0000000000000000000000000000000000000000..d55d94a10269829ddf4116a9884005af71d30bc9 GIT binary patch literal 12571 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 G0{<6?;W!om literal 0 HcmV?d00001 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..749c43280 --- /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 it is mapped back to the grid, and under a modifier it is placed by the layout's positions, so the 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<false>`. + +**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) +<a id="picture"></a> + +### 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. + +<img src="../../assets/core/PictureService.png" width="300" alt="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) + <a id="osc"></a> ### OSC diff --git a/docs/moonmodules/light/effects.md b/docs/moonmodules/light/effects.md index 29b894c5e..c35a8a974 100644 --- a/docs/moonmodules/light/effects.md +++ b/docs/moonmodules/light/effects.md @@ -1331,13 +1331,31 @@ Detail: [technical](moxygen/DemoReelEffect.md) [Tests](../../reference/tests/unit-tests.md#demoreeleffect) +<a id="fseqplayer"></a> + +### FseqPlayer 🎞️ + +<img src="../../assets/light/effects/FseqPlayerEffect.gif" width="300" alt="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) + <a id="networkreceive"></a> ### NetworkReceive 📡🌙 <img src="../../assets/light/effects/NetworkReceiveEffect.gif" width="300" alt="NetworkReceive effect preview"> -Receives lights over UDP into the layer, from Resolume, Madrix, xLights and LedFx. Sender mapping is one copy on a layout listed as wired, else two mappings, as the status shows. Behind a correcting sender, keep the profile in byte order, brightness 255 and the linear curve. +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. @@ -1353,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). +<a id="picture"></a> + +### Picture 🖼️ · 2D + +<img src="../../assets/light/effects/PictureEffect.gif" width="300" alt="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) + <a id="sine"></a> ### Sine 💫 · 3D diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index b6dd4c1f1..d9ec2a39a 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -4,7 +4,7 @@ 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. -**1629 finding(s)** across 324 file(s). 2.65% of all lines are duplicated, 3654 lines. +**1629 finding(s)** across 324 file(s). 2.62% of all lines are duplicated, 3654 lines. ## By rule @@ -45,8 +45,8 @@ Each rule is a code smell with a named move from the refactoring catalog, the ta | Area | Findings | |---|---:| | `test` | 560 | -| `light` | 414 | -| `core` | 368 | +| `light` | 415 | +| `core` | 367 | | `platform` | 174 | | `ui` | 80 | | `moondeck` | 24 | @@ -63,7 +63,7 @@ The next function worth simplifying is at the top of each file's list. | `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 9197 | +| `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 | @@ -123,7 +123,7 @@ 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 | @@ -203,8 +203,8 @@ The next function worth simplifying is at the top of each file's list. | `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<lengthType>`, 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 | @@ -235,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<uint8_t*>(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 | @@ -276,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:20`, duplicated block 22 | +| `test/unit/light/unit_NetworkReceiveEffect_protocols.cpp` | 2 | `lines 23-44, also in test/unit/light/unit_NetworkReceiveEffect.cpp:22`, 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 | @@ -287,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 1237 | | `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 1789 | -| `mooninstaller/install-orchestrator.js` | 1 | `install-orchestrator.js`, large file 1386 | -| `mooninstaller/install.js` | 1 | `install.js`, large file 1553 | +| `mooninstaller/install-orchestrator.js` | 1 | `install-orchestrator.js`, large file 1468 | +| `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 | @@ -302,7 +302,7 @@ 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 | @@ -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 | @@ -323,7 +324,6 @@ The next function worth simplifying is at the top of each file's list. | `src/light/effects/GEQEffect.h` | 1 | `GEQEffect::for`, complex function 19 | | `src/light/effects/LavaLampEffect.h` | 1 | `lines 68-78, also in src/light/effects/SpiralEffect.h:79`, duplicated block 11 | | `src/light/effects/MovingHeadEffect.h` | 1 | `MovingHeadEffect::shape`, complex function 11 | -| `src/light/effects/NetworkReceiveEffect.h` | 1 | `NetworkReceiveEffect::drainSockets<false> in tick`, blocking call on the render path 1 | | `src/light/effects/RadialSpectrumEffect.h` | 1 | `RadialSpectrumEffect::for`, deeply nested 4 | | `src/light/effects/SineEffect.h` | 1 | `SineEffect::for`, deeply nested 4 | | `src/light/effects/SphereMoveEffect.h` | 1 | `SphereMoveEffect::for`, deeply nested 4 | @@ -333,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 | @@ -350,7 +350,7 @@ The next function worth simplifying is at the top of each file's list. | `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:769`, duplicated block 9 | -| `src/platform/platform.h` | 1 | `platform.h`, large file 1449 | +| `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 20-41, also in test/unit/light/unit_NetworkReceiveEffect_protocols.cpp:23`, duplicated block 22 | +| `test/unit/light/unit_NetworkReceiveEffect.cpp` | 1 | `lines 22-43, 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 f497c7413..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 **3723 warning(s)** across 434 page(s). +**0 error(s)** and **3711 warning(s)** across 429 page(s). -**5505 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 | 1567 | -| split paragraphs | 0 | 1274 | -| appendix sections nothing refers to | 0 | 349 | +| 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 | 74 | +| over-long sentences | 0 | 79 | +| over-wide doc lines | 0 | 73 | | member deep dives | 0 | 67 | ## By documentation area @@ -30,10 +30,10 @@ 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 | 2029 | +| `(tests, no card)` | 0 | 2025 | | `platform/index.md` | 0 | 658 | -| `light/effects.md` | 0 | 248 | -| `core/system.md` | 0 | 136 | +| `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 | 98 | @@ -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 **2029 warning(s)** across 165 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 | *155 more warning files, 1280 findings* | | +| 1-44 each | *153 more warning files, 1276 findings* | | -By rule, warnings: 1351 over-wide comment lines, 253 split paragraphs, 196 multi-line comment blocks, 80 over-long sentences, 74 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 **248 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, 185 findings* | | +| 1-5 each | *57 more warning files, 179 findings* | | -By rule, warnings: 169 split paragraphs, 79 appendix sections nothing refers to. +By rule, warnings: 164 split paragraphs, 78 appendix sections nothing refers to. ### core/system.md -**0 error(s)** and **136 warning(s)** across 29 file(s). +**0 error(s)** and **134 warning(s)** across 28 file(s). | Findings | File | | |---:|---|---| @@ -124,9 +124,9 @@ By rule, warnings: 169 split paragraphs, 79 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 | *19 more warning files, 47 findings* | | +| 1-6 each | *18 more warning files, 45 findings* | | -By rule, warnings: 99 split paragraphs, 34 appendix sections nothing refers to, 2 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 diff --git a/docs/reference/metrics/prose.md b/docs/reference/metrics/prose.md index 09040a468..d04a80c77 100644 --- a/docs/reference/metrics/prose.md +++ b/docs/reference/metrics/prose.md @@ -9,12 +9,12 @@ 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. -**2858 finding(s)** across 364 file(s). +**2850 finding(s)** across 361 file(s). | rule | findings | |---|---| -| `MoonLight.EmDash` | 1461 | -| `MoonLight.Weasel` | 675 | +| `MoonLight.EmDash` | 1456 | +| `MoonLight.Weasel` | 672 | | `MoonLight.SentenceLength` | 426 | | `MoonLight.Temporal` | 211 | | `MoonLight.Spelling` | 42 | @@ -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 67cd21fb9..ca27e504a 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "c2cf6a4b", + "commit": "4d3ed5b6", "flash": { - "esp32s3-n16r8": 2443600, - "esp32": 2249040, - "esp32p4rev1-eth": 2380544, + "esp32s3-n16r8": 2453120, + "esp32": 2258768, + "esp32p4rev1-eth": 2396736, "esp32p4rev1-eth-wifi": 2623840, "esp32s3-n8r8": 2437792, "esp32s31": 2744032, @@ -14,11 +14,11 @@ "esp32p4rev3-eth": 2231792, "esp32s3-zero": 2224816, "esp32-pico": 2107168, - "desktop-macos": 2358536, + "desktop-macos": 2382184, "desktop-windows": 1925120, "esp32c3": 2335072, "desktop-linux": 2548000, - "esp32s3-n32r16": 2438176, + "esp32s3-n32r16": 2474800, "teensy41": 1271788 }, "measured": { @@ -37,7 +37,7 @@ "desktop-windows": "2026-09-30", "esp32c3": "2026-10-10", "desktop-linux": "2026-10-12", - "esp32s3-n32r16": "2026-10-13", + "esp32s3-n32r16": "2026-10-14", "teensy41": "2026-10-14" }, "perf": { @@ -50,9 +50,9 @@ "Control_a_palette_preset_changes_only_the_palette": { "desktop-macos": { "p50": 8, - "p95": 324, + "p95": 13, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "esp32s3-n16r8": { "p50": 2073, @@ -128,9 +128,9 @@ "State_one_document_adds_an_effect_with_its_controls": { "desktop-macos": { "p50": 7, - "p95": 387, + "p95": 9, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "esp32s3-n16r8": { "p50": 2505, @@ -594,7 +594,7 @@ "p50": 7, "p95": 15, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "desktop-windows": { "p50": 4, @@ -624,9 +624,9 @@ "Effects_pipeline_builds_and_renders": { "desktop-macos": { "p50": 7, - "p95": 10, + "p95": 9, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "desktop-windows": { "p50": 4, @@ -640,7 +640,7 @@ "p50": 1, "p95": 2, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "desktop-windows": { "p50": 4, @@ -669,10 +669,10 @@ }, "Effects_teardown_under_a_running_pipeline": { "desktop-macos": { - "p50": 7, + "p50": 8, "p95": 18, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "esp32s3-n16r8": { "p50": 9080, @@ -704,7 +704,7 @@ "p50": 267, "p95": 355, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" } }, "Layouts_resize_reallocates_live": { @@ -712,7 +712,7 @@ "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "desktop-windows": { "p50": 93, @@ -744,7 +744,7 @@ "p50": 7, "p95": 11, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "esp32s3-n16r8": { "p50": 10350, @@ -774,27 +774,27 @@ "Palettes_a_script_drives_the_effects": { "desktop-macos": { "p50": 7, - "p95": 74, + "p95": 12, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" } } }, "desktop-macos": { - "tick_us": 7, - "fps": 142857, + "tick_us": 6, + "fps": 166666, "scenario_p50": { "Effects_pipeline_builds_and_renders": { "p50": 7, - "p95": 10, + "p95": 9, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" }, "Layouts_resize_reallocates_live": { "p50": 10, "p95": 19, "n": 32, - "last": "2026-10-13" + "last": "2026-10-14" } } }, @@ -822,24 +822,24 @@ } }, "loc": { - "core": 30139, - "light": 31542, - "platform": 21589, - "ui": 12471, - "test": 67801, - "moondeck": 31404 + "core": 30403, + "light": 31915, + "platform": 21621, + "ui": 12473, + "test": 68310, + "moondeck": 31595 }, "comments": { "core": { - "lines": 6438, - "ratio": 0.237 + "lines": 6486, + "ratio": 0.236 }, "light": { - "lines": 7542, - "ratio": 0.268 + "lines": 7599, + "ratio": 0.267 }, "platform": { - "lines": 4167, + "lines": 4171, "ratio": 0.216 }, "ui": { @@ -847,28 +847,28 @@ "ratio": 0.295 }, "test": { - "lines": 7150, + "lines": 7192, "ratio": 0.122 }, "moondeck": { "lines": 4941, - "ratio": 0.179 + "ratio": 0.178 } }, "tests": { - "cases": 2535, + "cases": 2554, "scenarios": 32 }, "docs": { - "md_files": 160, - "md_lines": 33983, + "md_files": 161, + "md_lines": 34201, "plans_files": 50, "backlog_lines": 3393, "lessons_lines": 532, "claude_md_lines": 287 }, "complexity": { - "functions": 5009, + "functions": 5090, "over_threshold": 353, "worst_ccn": 128 } diff --git a/docs/reference/metrics/repo-health.md b/docs/reference/metrics/repo-health.md index 5b8a9c44c..3c0ba7424 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `c2cf6a4b`. Generated by [`moondeck/check/repo_health.py`](../../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `4d3ed5b6`. 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,28 +9,28 @@ 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,303 KB | +340.2 KB (+17.3%) | -| flash esp32 | 2,061 KB | 2,196 KB | +135.1 KB (+6.6%) | +| flash desktop-macos | 1,963 KB | 2,326 KB | +363.3 KB (+18.5%) | +| flash esp32 | 2,061 KB | 2,206 KB | +144.6 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,325 KB | +303.0 KB (+15.0%) | +| flash esp32p4rev1-eth | 2,022 KB | 2,341 KB | +318.8 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,386 KB | +274.8 KB (+13.0%) | -| flash esp32s3-n32r16 | - | 2,381 KB | new | +| flash esp32s3-n16r8 | 2,111 KB | 2,396 KB | +284.1 KB (+13.5%) | +| flash esp32s3-n32r16 | - | 2,417 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,139 | +7,840 (+35.2%) | -| lines light | 31,054 | 31,542 | +488 (+1.6%) | -| lines platform | 16,798 | 21,589 | +4,791 (+28.5%) | -| lines ui | 11,394 | 12,471 | +1,077 (+9.5%) | -| lines test | 57,759 | 67,801 | +10,042 (+17.4%) | -| lines moondeck | 29,325 | 31,404 | +2,079 (+7.1%) | -| unit tests | 2,120 | 2,535 | +415 (+19.6%) | +| lines core | 22,299 | 30,403 | +8,104 (+36.3%) | +| lines light | 31,054 | 31,915 | +861 (+2.8%) | +| lines platform | 16,798 | 21,621 | +4,823 (+28.7%) | +| lines ui | 11,394 | 12,473 | +1,079 (+9.5%) | +| lines test | 57,759 | 68,310 | +10,551 (+18.3%) | +| lines moondeck | 29,325 | 31,595 | +2,270 (+7.7%) | +| unit tests | 2,120 | 2,554 | +434 (+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. @@ -40,24 +40,24 @@ 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 2d | -| desktop-macos | 2,303 KB (+2 KB) ⚠ | - | - | yes | +| desktop-macos | 2,326 KB (+23 KB) ⚠ | - | - | yes | | desktop-windows | 1,880 KB | - | - | **STALE 14d** | -| esp32 | 2,196 KB (+8 KB) ⚠ | 2,496 KB | 88% | yes | +| esp32 | 2,206 KB (+10 KB) ⚠ | 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 4d | -| esp32p4rev1-eth | 2,325 KB (+7 KB) ⚠ | 4,096 KB | 57% | yes | -| esp32p4rev1-eth-wifi | 2,562 KB (+98 KB) ⚠ | 4,096 KB | 63% | yes | +| esp32p4rev1-eth | 2,341 KB (+16 KB) ⚠ | 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,386 KB (+6 KB) ⚠ | 4,096 KB | 58% | yes | -| esp32s3-n32r16 | 2,381 KB | 4,096 KB | 58% | carried 1d | +| esp32s3-n16r8 | 2,396 KB (+9 KB) ⚠ | 4,096 KB | 58% | yes | +| esp32s3-n32r16 | 2,417 KB (+36 KB) ⚠ | 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,242 KB (+4 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 | |---|---:|---:|---:|---:|---:|---:|---| -| esp32 | +8.0 KB | +6.4 KB | -0.1 KB | +3.2 KB | -1.5 KB | -0.1 KB | `c2cf6a4b` | -| esp32p4rev1-eth | +7.0 KB | +5.7 KB | +0.0 KB | +2.4 KB | -1.1 KB | -0.1 KB | `c2cf6a4b` | -| esp32p4rev1-eth-wifi | +97.7 KB | +100.2 KB | -14.4 KB | +8.8 KB | -6.5 KB | +9.6 KB | `c2cf6a4b` | -| esp32s3-n16r8 | +5.6 KB | +5.0 KB | -0.1 KB | +2.7 KB | -1.8 KB | -0.0 KB | `c2cf6a4b` | +| esp32 | +9.5 KB | +8.5 KB | -1.5 KB | +2.4 KB | -0.6 KB | +0.7 KB | `4d3ed5b6` | +| esp32p4rev1-eth | +15.8 KB | +14.3 KB | -1.5 KB | +2.9 KB | -0.4 KB | +0.5 KB | `4d3ed5b6` | +| esp32p4rev1-eth-wifi | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `4d3ed5b6` | +| esp32s3-n16r8 | +9.3 KB | +8.3 KB | -1.2 KB | +2.2 KB | -0.5 KB | +0.5 KB | `4d3ed5b6` | +| esp32s3-n32r16 | +35.8 KB | +31.5 KB | -2.1 KB | +6.1 KB | -2.1 KB | +2.3 KB | `4d3ed5b6` | Largest moves: -- **esp32**: `void mm::NetworkReceiveEffect::drainSockets<true>()` +1,739 B, `void mm::NetworkReceiveEffect::drainSockets<false>()` +1,735 B, `void mm::NetworkSendDriver::sendFrame<true>(unsigned char co` +1,289 B, `mm::NetworkReceiveEffect::tick()` -571 B, `mm::NetworkSendDriver::resizeCorrected()` +471 B -- **esp32p4rev1-eth**: `void mm::NetworkReceiveEffect::drainSockets<true>()` +1,416 B, `void mm::NetworkSendDriver::sendFrame<true>(unsigned char co` +1,374 B, `void mm::NetworkReceiveEffect::drainSockets<false>()` +1,290 B, `mm::NetworkReceiveEffect::tick()` -632 B, `mm::NetworkSendDriver::resizeCorrected()` +386 B -- **esp32p4rev1-eth-wifi**: `spicommon_bus_initialize_io` +3,128 B, `mm::HttpServerModule::serveScriptCatalog(mm::platform::TcpCo` -2,466 B, `f_mkfs` +2,388 B, `mm::platform::mountCard(mm::platform::SdPinConfig const&, bo` +2,186 B, `spi_bus_add_device` +2,038 B -- **esp32s3-n16r8**: `void mm::NetworkSendDriver::sendFrame<true>(unsigned char co` +1,293 B, `void mm::NetworkReceiveEffect::drainSockets<true>()` +1,179 B, `void mm::NetworkReceiveEffect::drainSockets<false>()` +1,151 B, `mm::NetworkReceiveEffect::tick()` -752 B, `mm::NetworkSendDriver::tick()` -491 B +- **esp32**: `mm::PictureService::drawTestPattern(unsigned char*, unsigned` +1,089 B, `mm::PictureEffect::tick()` +916 B, `mm::PictureEffect::gather(mm::PictureFrame const&)` +672 B, `mm::ChannelDataSink::copyIntoLayer(mm::Layer const*, bool)` +471 B, `mm::SystemModule::SystemModule()` -434 B +- **esp32p4rev1-eth**: `mm::PictureService::drawTestPattern(unsigned char*, unsigned` +1,070 B, `mm::PictureEffect::tick()` +946 B, `es7210_open` +882 B, `es7210_mic_select.part.0` +766 B, `mm::PictureEffect::gather(mm::PictureFrame const&)` +690 B +- **esp32p4rev1-eth-wifi**: none +- **esp32s3-n16r8**: `mm::PictureService::drawTestPattern(unsigned char*, unsigned` +1,089 B, `mm::PictureEffect::tick()` +751 B, `mm::PictureEffect::gather(mm::PictureFrame const&)` +672 B, `mm::SystemModule::SystemModule()` -434 B, `mm::PanelCardDriver::PanelCardDriver()` +408 B +- **esp32s3-n32r16**: `coeff_div` +2,144 B, `void mm::NetworkSendDriver::sendFrame<true>(unsigned char co` +1,293 B, `void mm::NetworkReceiveEffect::drainSockets<true>()` +1,188 B, `void mm::NetworkReceiveEffect::drainSockets<false>()` +1,175 B, `mm::PictureService::cardCircle(float, float)` +965 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,7 +86,7 @@ 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 | 6 µs (−1 µs) ✓ | 166,666 (+23,809) ✓ | | desktop-windows | 4 µs | 250,000 | | esp32 | 1,716 µs | 582 | @@ -97,7 +99,7 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | 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 ? | - | - | - | - | - | - | - | - | +| Effects_teardown_under_a_running_pipeline | 8 (+1) ⚠ | 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 ? | - | - | - | - | - | - | - | @@ -122,7 +124,7 @@ Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first | Scenario | p50 | p95 | n | |---|---:|---:|---:| -| Effects_pipeline_builds_and_renders | 7 µs | 10 µs | 32 | +| Effects_pipeline_builds_and_renders | 7 µs | 9 µs | 32 | | Layouts_resize_reallocates_live | 10 µs | 19 µs | 32 | These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. @@ -140,25 +142,25 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 30,139 (−8) ✓ | 6,438 | 23.7 % | -| light | 31,542 (+277) ⚠ | 7,542 | 26.8 % (−0.2 %) ✓ | -| platform | 21,589 (+80) ⚠ | 4,167 | 21.6 % (−0.1 %) ✓ | -| ui | 12,471 | 3,471 | 29.5 % | -| test | 67,801 (+249) ⚠ | 7,150 | 12.2 % | -| moondeck | 31,404 (+112) ⚠ | 4,941 | 17.9 % (−0.1 %) ✓ | +| core | 30,403 (+264) ⚠ | 6,486 | 23.6 % (−0.1 %) ✓ | +| light | 31,915 (+373) ⚠ | 7,599 | 26.7 % (−0.1 %) ✓ | +| platform | 21,621 (+32) ⚠ | 4,171 | 21.6 % | +| ui | 12,473 (+2) ⚠ | 3,471 | 29.5 % | +| test | 68,310 (+509) ⚠ | 7,192 | 12.2 % | +| moondeck | 31,595 (+191) ⚠ | 4,941 | 17.8 % (−0.1 %) ✓ | ## Tests | Kind | Count | |---|---:| -| unit cases | 2,535 (+12) ✓ | -| scenarios | 32 (+2) ✓ | +| unit cases | 2,554 (+19) ✓ | +| scenarios | 32 | ## Complexity | Metric | Value | |---|---:| -| functions | 5,009 (+13) ✓ | +| functions | 5,090 (+81) ✓ | | over threshold | 353 | | worst CCN | 128 | @@ -166,10 +168,10 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| -| markdown files | 160 | -| markdown lines | 33,983 (+37) ⚠ | +| markdown files | 161 (+1) ⚠ | +| markdown lines | 34,201 (+218) ⚠ | | plan files | 50 | -| backlog lines | 3,393 (+6) ⚠ | +| backlog lines | 3,393 | | lessons lines | 532 | -| CLAUDE.md lines | 287 (+2) ⚠ | +| CLAUDE.md lines | 287 | 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/present/Plan-20261013 - Streams, every frame from outside.md b/docs/work/present/Plan-20261013 - Streams, every frame from outside.md index d07a9f1ef..bea38b467 100644 --- a/docs/work/present/Plan-20261013 - Streams, every frame from outside.md +++ b/docs/work/present/Plan-20261013 - Streams, every frame from outside.md @@ -1,6 +1,6 @@ # 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 Ambilight 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. +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 @@ -40,6 +40,8 @@ The three axes describe any stream, and they do not make every stream one module **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:** @@ -111,13 +113,13 @@ The textbook name is inverse mapping: compose every transform into one coordinat - **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 Ambilight samples at. +**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.** Ambilight 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. +**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 @@ -133,7 +135,7 @@ flowchart TB ps["<b>picture source</b><br/>test pattern<br/><i>#138</i>"] end place["<b>light-list placement</b><br/>through the layout"] - samp["<b>picture effect</b><br/>sampled per light<br/>Ambilight <i>#138</i>"] + samp["<b>picture effect</b><br/>sampled per light<br/>backlight <i>#138</i>"] mix["<b>Layers</b>: effects, opacity, blend modes, modifiers"] map["<b>Layouts</b>: 2D, 3D, backlight.mll <i>#140</i>"] subgraph OUT["<b>Out</b>"] @@ -198,7 +200,7 @@ 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 ambilight processing on a computer and drives WLED over DDP. +- **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. @@ -224,7 +226,7 @@ One rule decides the split, and MoonLight already follows it where the device ca | 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 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 @@ -245,7 +247,7 @@ The P4's hardware video codec encodes H.264 but does not decode it; its JPEG cod | Contribution | In the plan | |---|---| | `VideoService` (#138) | the picture source, renamed for what it is | -| `AmbilightEffect` (#138) | the picture effect's area sampler, edge depth among its settings | +| #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 | @@ -260,7 +262,7 @@ The work follows the idea, not the pull requests: a contribution lands where it | What | From | Why not | |---|---|---| -| Ambilight 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 | +| 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 | @@ -274,7 +276,7 @@ The work follows the idea, not the pull requests: a contribution lands where it |---|---|---| | 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 | an Ambilight setting | a picture effect setting, since temporal smoothing applies to any picture, or dropped | +| 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 | @@ -289,10 +291,12 @@ What MoonLight builds first, so that every contribution after it is one source i |---|---|---|---| | G1 | **Light-list placement** ✅ built | `MappingLUT::gatherFromPhysical` | the mapping's own inverse, needing no table of its own: each logical cell takes the value its first physical light was sent, read from the CSR runs the layer already builds; one copy on the identity path ; it inverts the whole mapping today, modifiers included, so a mirror or a rotation on a sender-mapped stream cancels instead of applying: G5 inverts the layout's mapping alone, accepted on a rotated layer | | 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** | 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** | 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** | `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 | **A contributor page** | docs | how to add a channel-data source and how to add a picture source, with the test pattern as the template | +| 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 `<video>` element first, since it plays every file the browser does, and GIF frame by frame after it through `ImageDecoder`; about 7 KB a frame at 64x36, 200 KB/s at 30 fps; it needs no hardware, so every board tests the picture path with it. Follow-ups: live recording of the drivers' output to FSEQ on SD (the Recording row below), and the browser baking a whole video into FSEQ for this layout ahead of time, so nothing runs in real time and the board plays it standalone; an hour is about 1.3 GB at 64x64 and 30 fps and 5.3 GB at 12,288 lights and 40 fps, so a baked movie travels on the card rather than over WiFi | G1 and G2 come first: they add the second convention every mapping sender needs, and wait on no naming. G3 and G4 take #138's core part, credited to Gohnnyman. Before code: the double mapping confirmed on the bench (a serpentine grid fed by xLights, expected to show every other row reversed) and the open questions below decided. @@ -304,18 +308,83 @@ Each later piece is a small pull request, one topic, based on current main, with |---|---|---| | Backlight wiring, today's #140 resubmitted on main | Gohnnyman | nothing; it can come now | | Color trim, split from #139 | Gohnnyman | nothing; a separate idea | -| FSEQ playback from SD: uncompressed v2, sparse ranges, the frame index from the clock, verified on a 3D layout. Its first step extracts Network Receive's channel-data core (staging, hold-last-frame, `mapped_by`, the placement, the counters) into one sink that Network Receive and the FSEQ player both use, each a thin effect with its own settings (decided 2026-10-14) | TouchMyLight | G1, G2; G5 makes it cheap on a 3D layout, where a sender-mapped frame is otherwise mapped back and forward again (4 ms against 1.6 ms on the S3 at 12,288 lights) | +| FSEQ playback from SD: uncompressed v2, sparse ranges, the frame index from the clock, verified on a 3D layout. It builds on the channel-data sink (G6) that Network Receive and the FSEQ player both use, each a thin effect with its own settings (decided 2026-10-14) | TouchMyLight | G1, G2, G6; G5 sends a player alone on its layer to the output in one copy on any layout, a 3D one included | | Border sampling: edge depth, smoothing | Gohnnyman | G4 | | GIF decoder, PPM as a test format | open | G3 | | USB capture with its MJPEG decoder, P4, with Teensy stubs | Gohnnyman | G3 | -| Browser pages: converter, baking to FSEQ, a live tab source | open | G1 to G4 | +| Browser pages: converter, baking to FSEQ | open | G1 to G4, G8 | | Recording, HDMI out on the ESP32-P4-PC, clock sync | open | FSEQ playback | #138 and #139 close with thanks once G3 and G4 land, their good parts credited in the groundwork; a stacked pull request waits for the one below it, as [CLAUDE.md](../../../CLAUDE.md) says. +## Skeletons for the contributions + +What each pull request builds, settled before code so it arrives small and on design. The names follow the open naming question below. Each passes `check_specs` with a card, and its tests pass on the desktop before a board sees it. + +**FSEQ player (TouchMyLight), on G6.** A thin effect on the channel-data sink, the file being its only source; FSEQ is mapped by the sender, so it sets no `mapped_by` of its own. `FseqPlayerEffect` is registered with these settings on the sink and reads no file yet, its status saying so: the contribution is the file reading in `prepare()` and `tick()`. + +```cpp +class FseqPlayerEffect : public EffectBase { // light/effects/FseqPlayerEffect.h + char file[64]; // a path on /sd or flash, picked from the file list + bool loop = true; + uint8_t speed = 100; // percent of the file's step time + // prepare(): open the file, read the v2 header, size the sink to its channel count, refuse compressed files and sparse ranges with a status + // tick(): the frame index from the clock (start time, speed), read that frame into the sink, which places and holds it + ChannelDataSink sink_; +}; +``` + +Tests: a v2 header read and refused when compressed; the frame index from the clock at 50 and 200 percent; a written serpentine file placed on a serpentine and a 3D layout reaching each light as sent (`moondeck/run/fseq_play.py` writes the reference files); a missing file degrades to a status, never a crash. + +**The picture source (G3) and the picture effect (G4), from #138.** A core service publishing frames in `AudioService`'s pattern, and an effect sampling them per light. + +```cpp +struct PictureFrame { const uint8_t* rgb; uint16_t width, height; uint32_t generation; }; // core/util/PictureFrame.h +class PictureService : public MoonModule { // core/services/PictureService.h + uint8_t source = 0; // test pattern first; each later source adds an option + static const PictureFrame* latestFrame(); // always valid: a blank frame when nothing publishes +}; +class PictureEffect : public EffectBase { // light/effects/PictureEffect.h + uint8_t fit = 0; // fit or stretch + uint8_t sampling = 0; // point or area, area in linear light + // prepare(): a table from each light's position to its picture rectangle; tick(): one gather per new generation +}; +``` + +Tests: the test pattern's frame; point and area sampling against hand-computed values; fit against stretch on a non-square layout; a 3D layout sampling its front face; no new frame, no gather. + +**Backlight wiring (#140).** The layout exists as `moonlive/layouts/backlight.mll`, one strand clockwise from the top-left corner; #140's own commit extends that script, the name staying Backlight since its lights sit on the edge pixels of a wall's canvas. + +``` +class BacklightLayout { // moonlive/layouts/backlight.mll + byte wallCols, wallRows; // kept: the wall's size in pixels + byte across, down; // kept: lights along the top and along a side + byte startCorner = 0; // where the strand enters, a select with the corner names + byte clockwise = 1; // which way it runs + int offset = 0; // lights past the corner where it begins, counted along the strand + void placeLights(); // one walk around the edge, rotated and reversed; only the indices move +} +``` + +Tests: each start corner and direction pinned as the expected sequence on a small wall; an offset along a counter-clockwise strand; the defaults reproducing today's order; the script's line in the IR golden table. Later, optionally: Hyperion's separate count per edge, which allows a gap for a TV stand. + +**Color trim (#139's second commit).** Output calibration, so it lives in the fixture profile and the correction, judged against a 3×3 correction matrix before code. + +```cpp +// light/drivers/FixtureProfilesModule.h: per profile, beside the channel roles +uint8_t trimRed = 255, trimGreen = 255, trimBlue = 255; // gain per channel +// light/drivers/Correction.h: rebuild() folds the trim into each channel's table, so the hot path is unchanged +``` + +Tests: a trim scaling one channel and leaving the others; a trim of 255 being the identity; the trim surviving a profile save and load. + ## Where it stands (2026-10-14) -Built and uncommitted: G1 and G2 (`mapped_by`), and around them what measuring Network Receive on real boards turned up. +Built and uncommitted: G1, G2 (`mapped_by`) and G5, and around them what measuring Network Receive on real boards turned up. + +<img src="../../assets/light/effects/NetworkReceiveEffect.png" width="404" alt="Network Receive receiving a sender-mapped stream in one copy"> + +What the card added: `mapped_by` picks who maps a frame onto the wiring, the status names the sender and the path its frame takes ("one copy" or "mapped twice"), and `packets_per_s` and `frames_per_s` count the last second, so a scenario compares them with what the sender sent. Here the S3 testbench receives `fseq_play`'s 64x64 serpentine sweep: alone on its layer, the frame goes to the output in one copy, and the tick fell from 5.83 ms to 2.96 ms against G1's two mappings. - **Network Receive** drains its sockets in a receive task on core 1, shows its path in the status ("one copy" or "mapped twice"), and counts `packets_per_s` and `frames_per_s`. - **TryLock** releases with an exchange: in PSRAM a plain store raced the emulated read-modify-write, left the latch set with no owner, and took the WiFi stack down with the stuck receive task. @@ -332,13 +401,11 @@ Received at 12,288 lights, 40 frames/s sent (1,040 packets/s), mapped by the rec | S3 WiFi to P4 Ethernet | 984 | 32 | 0.3 ms | | P4 Ethernet to P4 Ethernet | 790 to 846 | 8 to 19 | 0.8 ms | -P4 to P4 started the day at about 200 packets/s and 0 to 1 frames. What remains is the sender's burst: the access point drops it on WiFi, and on Ethernet the push packet at its end is the one most often lost. Network Send pacing its packets is the next step (a 0.5 ms gap recovered 96% on WiFi), then G5, which removes the double mapping that sender mapping costs on a layout that is not the identity (4 ms against 1.6 ms on the S3). The rest is in the backlog below. +P4 to P4 started the day at about 200 packets/s and 0 to 1 frames. What remains is the sender's burst: the access point drops it on WiFi, and on Ethernet the push packet at its end is the one most often lost. Network Send paces its packets, set by `packet_gap` and off at 0 (a 0.5 ms gap recovered 96% on WiFi), which took P4 to P4 to 26 to 36 frames/s. G5 removes the double mapping that sender mapping cost on a layout that is not the identity (4 ms against 1.6 ms on the S3): a stream alone on its layer goes to the output in one copy, and a static modifier on its layer transforms it. G6 is next. The rest is in the backlog below. ## Backlog, found while measuring - **A blocking HTTP write freezes the render task under a stream.** `serveState` writes the full `/api/state` (about 35 KB) with a blocking `lwip_write` on the render task; with the S3 receiving 12,288 lights over WiFi the client's acknowledgements slow, the send buffer fills, and the write holds the render task for seconds until the task watchdog reboots the board (backtrace: `handleConnection` → `serveState` → `JsonSink::flushStage` → `lwip_write`, 2026-10-14). The UI no longer reads the full state since state by subscription, so the trigger was the scenario runner's reads at every measure step; only the diagnostic bundle, assign mode and a backup restore still read it, each on a user's explicit action. The textbook fix: write without blocking and resume a partial write on the next tick, as uploads already stream. Check whether the WebSocket state pushes, smaller TCP writes on the same task, can block the same way. -- **The WiFi connect grace counts from STA init, not from association.** `NetworkModule::kStaGraceMs` (10 s) starts when the station starts; a router taking 7.3 s to lease an address plus one missed scan (reason 201, about 2.4 s) runs it out, and the board falls back to its access point for about 80 s while already joined. The lease wait belongs after association, which `kDhcpWaitMs` already names. -- **No log on the S3's native USB port.** The S3 builds keep IDF's default console, UART0 primary with USB-Serial-JTAG as the secondary mirror, yet nothing arrives on the native port, so a board connected by that port alone (the testbench, an S3-Zero) shows no log and loses any crash reason. Suspected, not verified: Improv installs its own USB-Serial-JTAG driver for provisioning, which takes the peripheral from the mirror, where [platform_esp32_improv.cpp](../../../src/platform/esp32/platform_esp32_improv.cpp) expects the console to win. With USB-Serial-JTAG as the primary console a pyserial read with DTR and RTS off receives the log, while MoonDeck's `monitor_esp32` still receives nothing. The fix is either the mirror and Improv sharing the port, or USB-Serial-JTAG as the primary console on native-USB S3 boards; check too whether the installer's post-flash address read depends on it. - **The UDP mailbox of 32 and the paced send task on boards without PSRAM.** `CONFIG_LWIP_UDP_RECVMBOX_SIZE=32` is in the base defaults for every board. It costs about 128 bytes per socket, and on WiFi the queued packets are WiFi's own receive buffers, capped at 32. On Ethernet without PSRAM they come from the internal heap at about 1.6 KB each, so a full queue holds about 50 KB on a classic board with 100 to 170 KB free, which could starve the HTTP server during a burst. Measure it with `scenario_NetworkReceive_two_boards` on a classic Ethernet board (the Olimex at 192.168.1.210) receiving from a P4, reading free heap and the HTTP answers; if it hurts, the 4 MB classic fragments override it with a smaller value. Measure Network Send's paced task there too, with the classic as the sender: about 5.5 KB of internal RAM per driver (a 4 KB stack, the 1.45 KB packet buffer, a timer), and on a single-core chip such as the C3 the send task outranks the frame loop, so each packet briefly interrupts it. - **The two-board scenarios on every platform.** `scenario_NetworkReceive_two_boards` and `scenario_NetworkSend_one_sender_many_receivers` run today on S3 boards over WiFi and P4 boards over Ethernet, in all four pairings. Add the Teensy 4.1 (Ethernet), desktop builds over WiFi, and the NanoPi over Ethernet, as sender and as receiver, so every platform's receive cost and loss sits in the same table. A desktop or NanoPi board joins with `--board name=<ip>:8080`. The Teensy build runs bare-metal with no scheduler, so Network Send and Network Receive work inside their tick (`spawnPinnedTask` returns false); QNEthernet fills its receive buffers in an interrupt, so a late drain loses less there. Its runs show whether threads (TeensyThreads or a FreeRTOS port, with stacks in its tight RAM and shared structures made thread-safe) are worth it. - **E1.31 multicast never arrives.** Network Receive binds 5568 but never joins the multicast groups, so both receivers in `scenario_NetworkSend_one_sender_many_receivers` read 0 packets while Network Send multicast; `UdpSocket::membership` exists in the platform layer and goes unused. Join the groups of the universes the layer covers, from `universe_start` on. @@ -350,11 +417,11 @@ P4 to P4 started the day at about 200 packets/s and 0 to 1 frames. What remains - **The receive task's priority on Ethernet.** Ours runs at 3 on core 1, below the driver task (`mmEncode`, 5); troyhacks' Art-Net receive runs at 20 on core 0, blocking in `recvfrom()`, above lwIP's own thread (`tcpip`, 18), which a busy drain could starve. On the S3 over WiFi 6 against 3 changed nothing, the loss being at the access point. Measure 6 (above the driver task) and 19 (above lwIP) P4 to P4. - **Network Send paces its packets.** Back-to-back bursts are what the access point drops (a 0.5 ms gap recovered 96% on WiFi) and what loses the push packet at the end of an Ethernet burst. Spread a frame's packets over part of the frame period, and back off on a full transmit queue (`ENOMEM`) rather than adding buffers, as Espressif advises ([esp-idf#13602](https://github.com/espressif/esp-idf/issues/13602)) and troyhacks' raw Ethernet driver does. - **The live runner restores no board when a board fails mid-run.** A two-board run whose receiver rebooted left the sender's scenario canvas and its Network Send on P4 .139, still streaming to the S3. The restore of every board belongs in a `finally`, so an exception or a dead board still puts the others back. +- **HTTP to an S3 fails under a stream from the same computer.** With a 40 fps DDP stream from the Mac into either S3 over WiFi, 4 to 6 of 20 HTTP probes from that Mac answered, while the render loop stayed healthy (log: 5.5 ms ticks, the HTTP server idle) and the Ethernet P4 took the same stream with every probe answered. A phone opening the S3's page during such a stream tells whether the S3's WiFi suffers or only the sending computer's link carrying both. ## Open questions - The names: `PictureService` and `PictureEffect`, or names closer to the industry's (media source, pixel map)? -- Does a recording capture the layer's input (before mapping) or the drivers' output (after correction)? xLights expects the output. - How far video goes on a board without a hardware decoder: GIF and image sequences, or a software decoder measured in flash first. - How HDMI out is wired on the ESP32-P4-PC: its sources name HDMI and MIPI-DSI but no bridge chip; Olimex's separate P4 HDMI adapter uses Lontium's LT8912B (MIPI-DSI to HDMI, up to 1080p60), so check the schematic. - Whether a layout reads xLights model exports, so a 3D model's node order and positions come from the file that designed the show. diff --git a/esp32/main/Kconfig.projbuild b/esp32/main/Kconfig.projbuild index 1208cc71a..c10b14cc1 100644 --- a/esp32/main/Kconfig.projbuild +++ b/esp32/main/Kconfig.projbuild @@ -46,6 +46,17 @@ menu "MoonLight" MIDI service with no computer. Set by the S3 images with flash to spare (n8r8, n16r8); the 4 MB S3-Zero leaves it off. + config MM_AUDIO_CODEC + bool "Audio codecs in front of a microphone (ES8311, ES7210)" + default y if IDF_TARGET_ESP32P4 || IDF_TARGET_ESP32S31 + default n + help + Pulls the esp_codec_dev managed component and compiles the codec + bring-up, for the boards whose microphone sits behind an ES8311 or + ES7210. On for the chips whose boards carry one (the S31 CoreBoard, + P4 boards) and set by the Waveshare ESP32-S3-RGB-Matrix's image; a + board with an I2S microphone such as the INMP441 needs no codec. + config MM_SD_CARD bool "microSD card support (SDMMC or SPI)" default y diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml index 1ba79df79..4bfc94d16 100644 --- a/esp32/main/idf_component.yml +++ b/esp32/main/idf_component.yml @@ -51,15 +51,14 @@ dependencies: version: "^1.0.0" rules: - if: "$CONFIG{MM_USB_MIDI_HOST} == True" - # esp_codec_dev: Espressif's audio-codec library, whose ES8311 part configures an analog mic - # behind an ES8311 I2S codec. Which board has one, and on which pins, is AudioService's runtime - # configuration from the device model, so the component is built for every chip a codec board - # uses (the S31 CoreBoard, and P4 boards such as the Waveshare ESP32-P4-ETH) and nowhere else. - # Referenced only from platform_esp32_es8311.cpp, behind the codec gate. + # esp_codec_dev: Espressif's audio-codec library, whose ES8311 and ES7210 parts configure a + # microphone behind a codec. Which board has one, and on which pins, is AudioService's runtime + # configuration from the device model; the Kconfig gate keeps the component out of every image + # that does not set MM_AUDIO_CODEC. Referenced only from platform_esp32_es8311.cpp. espressif/esp_codec_dev: version: "^1.3.0" rules: - - if: "target in [esp32s31, esp32p4]" + - if: "$CONFIG{MM_AUDIO_CODEC} == True" # esp_wifi_remote + esp_hosted: WiFi for the ESP32-P4 via the on-board ESP32-C6 # co-processor over SDIO. The P4 has no native radio; these present the C6's # radio through the standard esp_wifi_* API (API-compatible, so the WiFi seam in diff --git a/esp32/sdkconfig.defaults.esp32s3-n32r16 b/esp32/sdkconfig.defaults.esp32s3-n32r16 index 141c67a61..2b7875635 100644 --- a/esp32/sdkconfig.defaults.esp32s3-n32r16 +++ b/esp32/sdkconfig.defaults.esp32s3-n32r16 @@ -18,3 +18,6 @@ CONFIG_HEAP_HAS_EXEC_HEAP=y # A MIDI desk on the board's own USB port: the MIDI service's USB source. CONFIG_MM_USB_MIDI_HOST=y + +# The Waveshare ESP32-S3-RGB-Matrix's microphones sit behind an ES7210. +CONFIG_MM_AUDIO_CODEC=y diff --git a/mkdocs.yml b/mkdocs.yml index e2626f0b4..db5472226 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -169,6 +169,7 @@ nav: - Installing on a desktop: how-to/installing-to-desktop.md - Running on a Linux machine: how-to/installing-on-linux.md - Building, running, flashing: how-to/building.md + - Adding a picture or channel-data source: how-to/adding-a-stream-source.md - Driving LED panels with a receiving card: how-to/panel-cards.md - LED signal integrity: how-to/led-signal-integrity.md - Connecting a control surface: how-to/control-surface.md diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 6a1a78e27..73a69c298 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -952,6 +952,19 @@ uv run moondeck/scenario/run_network_roundtrip.py --host 192.168.1.156 --repeats Captures and restores each device's grid and removes the temporary NetworkReceive on exit (also on failure). Exit codes match the matrix test: `0` = at least one device measured, `1` = none returned a frame, `2` = environment problem (no checked/reachable devices). +### fseq_play + +Play an FSEQ show to the selected device over DDP, the reference sender for FSEQ on MoonLight. + +```bash +uv run moondeck/run/fseq_play.py --host 192.168.1.158 # the test sweep, written on first use +uv run moondeck/run/fseq_play.py --host 192.168.1.158 --file show.fseq --loops 3 +uv run moondeck/run/fseq_play.py --write sweep.fseq --width 64 --height 64 --serpentine +uv run moondeck/run/fseq_play.py --info show.fseq +``` + +FSEQ is xLights' and Falcon Player's file of channel data, frame by frame in wiring order. The tool reads and writes uncompressed v2 as FPP documents it. It streams each frame as DDP packets with the push flag on the last, paced as Network Send paces, at the file's step time. Set the device's Network Receive `mapped_by` to sender. The test sweep is a diagonal with a white light at the logical top-left, so a wrong mapping shows as a stepped edge and a moved corner. Compressed files and sparse ranges are refused: export from xLights with compression off. + ### preview_health Browser-faithful **3D-preview stream health probe** — measures the device's `/ws` preview the way a real browser tab experiences it, so the numbers match what a person watching the [PreviewDriver](../docs/moonmodules/light/drivers.md#preview) preview sees. A plain one-shot WebSocket reader gives up the moment the device closes the socket, so it reports stalls a browser never shows (the browser reconnects) and misses the brief blips a browser does show; this probe replicates the real client in [app.js](../src/ui/app.js)'s `connectWs` — reads the binary frames, sends a `"ping"` text frame every 25 s, and **auto-reconnects on close with 500 ms→5 s backoff** — so a momentary device-side close registers as a short blip, not a frozen preview. Pure WebSocket client: **no device-side changes**, it observes the unmodified stream the device already broadcasts. Reports, per device: color frames + sustained fps, reconnects (each a visible blip), `maxgap` (the longest stretch with no color frame — the real "did it freeze?" number), and a `SMOOTH` / `CHOPPY` / `DEAD` verdict. Diagnostic, not a gate — it always exits `0`; read the verdict. Runs against **every device checked in the Live tab** (or an explicit `--host`); with no host it sweeps every online device on the active network. diff --git a/moondeck/_moondeck_config.py b/moondeck/_moondeck_config.py index 2a20ee61d..452d005e5 100644 --- a/moondeck/_moondeck_config.py +++ b/moondeck/_moondeck_config.py @@ -6,6 +6,7 @@ """ import json +import threading import urllib.request from contextlib import contextmanager from pathlib import Path @@ -18,21 +19,33 @@ LOG_NONE, LOG_ERROR, LOG_WARN, LOG_INFO, LOG_DEBUG, LOG_VERBOSE = range(6) -def active_device_ips(): - """ESP32 device IPs in the active network (from moondeck.json). Skips the desktop - entry (its ip carries a :port) and anything without an ip. [] if unresolvable.""" - if not _STATE.exists(): +def device_ips_for_port(port): + """The IP of the device moondeck.json last saw on `port`, as a one-element list, so a tool watching one board changes only that board; [] when none matches. A tty and a cu name of one port match.""" + if not port or not _STATE.exists(): return [] + key = Path(port).name.replace("tty.", "cu.", 1) try: state = json.loads(_STATE.read_text(encoding="utf-8")) active = next((n for n in (state.get("networks") or []) if n.get("name") == state.get("active_network")), None) return [d["ip"] for d in (active or {}).get("devices", []) - if d.get("ip") and ":" not in d["ip"]] + if d.get("ip") and ":" not in d["ip"] + and Path(d.get("last_port") or "").name.replace("tty.", "cu.", 1) == key][:1] except Exception: return [] +def open_serial(port, baud=115200, timeout=1): + """Open a serial port with DTR and RTS cleared first: on a native USB-Serial-JTAG port they drive the chip's reset and boot pins, and pyserial's default of asserting them holds the chip in reset.""" + import serial # here, so this module stays importable without pyserial + ser = serial.Serial() + ser.port, ser.baudrate, ser.timeout = port, baud, timeout + ser.dtr = False + ser.rts = False + ser.open() + return ser + + def _get_log_level(ip): """Read one device's current System.logLevel index from /api/state, or None if unreachable or unparseable. Used to snapshot a device before a temporary change so it can be restored.""" @@ -81,16 +94,37 @@ def set_log_level(ips, index): @contextmanager -def raised_log_level(ips, index=LOG_INFO): - """Temporarily raise each device to `index` (default Info, so the serial tick line prints) and - restore each device's ORIGINAL level on exit — not a hardcoded Warn, so a device the user had set - to Debug/Error keeps its choice. A device whose level can't be read is still raised, and restored - to Warn (the resting default) as the honest fallback. Best-effort throughout; never raises.""" - saved = {ip: _get_log_level(ip) for ip in ips} - set_log_level(ips, index) +def raised_log_level(ips, index=LOG_INFO, background=False): + """Temporarily raise each device to `index` (default Info, so the serial tick line prints) and restore each device's ORIGINAL level on exit, so a device set to Debug or Error keeps its choice. + + A device whose level can't be read is still raised, and restored to Warn, the resting default. `background` raises from a thread, so a caller reading a port starts at once rather than after the HTTP calls, 3 s each for a device that is off. Best-effort throughout; never raises. + """ + saved = {} + stop = threading.Event() + + def raise_all(): + # In the background the board may be rebooting, since opening its native USB port resets it, so the level is retried until it answers or the caller is done. + for ip in ips: + prev = _get_log_level(ip) + for _ in range(15 if background else 0): + if prev is not None or stop.wait(2): + break + prev = _get_log_level(ip) + if stop.is_set(): + return + saved[ip] = prev + set_log_level([ip], index) + + raiser = threading.Thread(target=raise_all, daemon=True) + if background: + raiser.start() + else: + raise_all() try: yield finally: - for ip in ips: - prev = saved.get(ip) + if background: + stop.set() # a board that never answered must not hold the port for the retries left + raiser.join() + for ip, prev in saved.items(): # only what was raised goes back set_log_level([ip], prev if prev is not None else LOG_WARN) diff --git a/moondeck/check/collect_kpi.py b/moondeck/check/collect_kpi.py index ab34d9231..746974147 100644 --- a/moondeck/check/collect_kpi.py +++ b/moondeck/check/collect_kpi.py @@ -23,7 +23,7 @@ # Shared moondeck.json + logLevel-toggle helpers (one level up, reachable from check/ and run/). sys.path.insert(0, str(ROOT / "moondeck")) -from _moondeck_config import active_device_ips, raised_log_level, LOG_INFO # noqa: E402 +from _moondeck_config import device_ips_for_port, open_serial, raised_log_level, LOG_INFO # noqa: E402 from _host import desktop_target # noqa: E402 # Same directory; imported by path so this needs no PYTHONPATH tweak (the pattern the @@ -384,14 +384,14 @@ def _live_capture(log, seconds=15): import serial except ImportError: return False - # Raise the device(s) to Info so the KPI tick line prints during the capture (they rest at Warn, - # which silences it), and restore each device's ORIGINAL level on exit: covering the serial-open - # failure and the normal-cleanup paths alike. A freshly booted device already logs at Info for its - # first 60 s, so this is a no-op there but harmless. + # The board on this port raised to Info so its tick line prints (it rests at Warn), and restored to its own level on exit, the serial-open failure included. print(f" ESP32 KPI: capturing {seconds}s from {port}...") - with raised_log_level(active_device_ips(), LOG_INFO): + ips = device_ips_for_port(port) + if not ips: + print(f" no device in the registry was last flashed on {port}: its log level is not raised, so the tick lines show only at Info", flush=True) + with raised_log_level(ips, LOG_INFO): try: - ser = serial.Serial(port, 115200, timeout=1) + ser = open_serial(port) except Exception as e: print(f" ESP32 KPI: cannot open {port}: {e}") return False diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py index e495315af..3c3ba276e 100644 --- a/moondeck/docs/screenshot_modules.py +++ b/moondeck/docs/screenshot_modules.py @@ -95,6 +95,9 @@ def asset_dir_for(type_name: str) -> Path: # "Layouts" = child of Layouts # capture_gif: True = also record a preview GIF # --------------------------------------------------------------------------- +# The longest module name the device accepts: MoonModule's kNameLen less its terminator. +MAX_NAME = 15 + MODULES = [ # Layouts ("GridLayout", "Layouts", {}, True), @@ -178,6 +181,7 @@ def asset_dir_for(type_name: str) -> Path: # (not in state); capture these against a board when needed. "ImprovProvisioningModule", "AudioService", + "PictureService", "AnalogService", "I2cBusModule", "InfraredService", @@ -210,6 +214,7 @@ def asset_dir_for(type_name: str) -> Path: # the desktop tree — so they're captured against an ESP32, where these entries route the # shot to the right nav root. "AudioService": "Services", + "PictureService": "Services", "AnalogService": "Services", "InfraredService": "Services", "ButtonService": "Services", @@ -324,9 +329,9 @@ def _collect_names(modules: list) -> set[str]: return names -def add_module(host: str, id_: str, type_: str, parent_id: str | None, +def add_module(host: str, id_: str | None, type_: str, parent_id: str | None, props: dict) -> str | None: - """Add a module and return the actual name the server assigned. + """Add a module and return the actual name the server assigned; with no `id_` the device names it, as the UI's add does. Snapshots state before and after the POST; the new module is whichever name appears after that wasn't there before and has the right type. @@ -335,7 +340,9 @@ def add_module(host: str, id_: str, type_: str, parent_id: str | None, before = _get(f"http://{host}/api/state", timeout=5) names_before = _collect_names(before.json().get("modules", [])) if before.ok else set() - body: dict = {"id": id_, "type": type_} + body: dict = {"type": type_} + if id_: + body["id"] = id_ if parent_id: body["parent_id"] = parent_id # `props` is deliberately NOT sent: the create endpoint ignores it (no handler reads it), so a @@ -343,11 +350,14 @@ def add_module(host: str, id_: str, type_: str, parent_id: str | None, r = _post(f"http://{host}/api/modules", json=body, timeout=5) if not r.ok: return None + named = r.json().get("name") if r.headers.get("content-type", "").startswith("application/json") else None + if named: + return named time.sleep(0.15) sr = _get(f"http://{host}/api/state", timeout=5) if not sr.ok: - return id_[:16] + return id_[:MAX_NAME] if id_ else None def find_new(modules: list) -> str | None: for m in modules: @@ -359,7 +369,7 @@ def find_new(modules: list) -> str | None: return found return None - return find_new(sr.json().get("modules", [])) or id_[:16] + return find_new(sr.json().get("modules", [])) or (id_[:MAX_NAME] if id_ else None) def delete_module(host: str, id_: str) -> bool: @@ -486,7 +496,7 @@ def find(ms): def restore_default_layout(host: str, snap: dict) -> None: """Put back what displace_default_layout took, controls and all.""" try: - name = add_module(host, (snap.get("name") or snap["type"])[:16], snap["type"], + name = add_module(host, (snap.get("name") or snap["type"])[:MAX_NAME], snap["type"], "Layouts", {}) if not name: print(f" restore-layout: re-adding {snap['type']} failed ", end="", flush=True) @@ -895,8 +905,8 @@ def main() -> int: if args.no_modifier or args.grid: prepare_pipeline(args.host, args.no_modifier, args.grid or None) - # Known name prefixes for orphan sweep (first 16 chars of each type name). - known_prefixes = {t[:16] for t, _, _, _ in MODULES} + # Known name prefixes for orphan sweep (each type name as the device would cut it). + known_prefixes = {t[:MAX_NAME] for t, _, _, _ in MODULES} # Sweep orphans from a previous aborted run. Per-delete try/except # so one failed cleanup doesn't abort the whole recursion; outer @@ -1068,7 +1078,7 @@ def _sweep_orphans(modules: list) -> None: parent_id = parents.get(parent_type) nav_root = nav_roots.get(parent_type, "") - req_id = type_name[:16] + req_id = None # the device names it, as the UI's add does print(f" {type_name} …", end=" ", flush=True) # The bed effect, removed again below, so the pipeline holds exactly one effect @@ -1077,7 +1087,7 @@ def _sweep_orphans(modules: list) -> None: # on its own: both photograph as a dark panel without a lit effect under them. bed_name = None if need_gif and (type_name.endswith("Modifier") or type_name.endswith("Layout")): - bed_name = add_module(args.host, BED_EFFECT[:16], BED_EFFECT, + bed_name = add_module(args.host, BED_EFFECT[:MAX_NAME], BED_EFFECT, parents.get("Layer"), {}) if bed_name: added_ids.append(bed_name) diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json index 0518147b0..0984ee29d 100644 --- a/moondeck/moondeck_config.json +++ b/moondeck/moondeck_config.json @@ -326,6 +326,16 @@ "help": "run_network_roundtrip", "script": "scenario/run_network_roundtrip.py" }, + { + "id": "fseq_play", + "tab": "live", + "group": "scenario", + "label": "FSEQ Play", + "speed": "medium", + "help": "fseq_play", + "script": "run/fseq_play.py", + "needs_device": true + }, { "id": "preview_health", "tab": "live", diff --git a/moondeck/run/fseq_play.py b/moondeck/run/fseq_play.py new file mode 100644 index 000000000..33e9e7b4a --- /dev/null +++ b/moondeck/run/fseq_play.py @@ -0,0 +1,144 @@ +"""Play an FSEQ show to a device over DDP, or write a test show: the reference sender for FSEQ on MoonLight. + +FSEQ is xLights' and Falcon Player's file of channel data, frame by frame in wiring order with a step time. This reads and writes uncompressed v2 as FPP's FSEQ_Sequence_File_Format.txt lays it out, little-endian, and plays a file the way a mapping sender streams: each frame as DDP packets, the push flag on the last, paced, at the file's step time. The device's Network Receive takes it with `mapped_by` set to sender. + + uv run moondeck/run/fseq_play.py --host 192.168.1.158 # play the test sweep, written on first use + uv run moondeck/run/fseq_play.py --host 192.168.1.158 --file show.fseq --loops 3 + uv run moondeck/run/fseq_play.py --write sweep.fseq --width 64 --height 64 --serpentine + uv run moondeck/run/fseq_play.py --info show.fseq +""" +from __future__ import annotations + +import argparse +import socket +import struct +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +DDP_PORT = 4048 +DDP_HEADER = 10 +DDP_PAYLOAD = 1440 # whole RGB and RGBW lights, as Network Send chunks +HEADER = struct.Struct("<4sHBBHIIBBBBBBQ") # the 32-byte fixed header + + +def wiring_index(x: int, y: int, width: int, serpentine: bool) -> int: + """A light's index in wiring order: rows from the top, every other row reversed when serpentine.""" + return y * width + ((width - 1 - x) if serpentine and y % 2 else x) + + +def sweep_frame(width: int, height: int, frame: int, serpentine: bool) -> bytes: + """A diagonal sweep in wiring order with a white light at logical (0, 0), so a wrong mapping shows.""" + out = bytearray(width * height * 3) + span = width + height + for y in range(height): + for x in range(width): + v = (x + y + frame) % span * 255 // span + i = wiring_index(x, y, width, serpentine) * 3 + out[i:i + 3] = bytes((v, 0, 255 - v)) + corner = wiring_index(0, 0, width, serpentine) * 3 + out[corner:corner + 3] = b"\xff\xff\xff" + return bytes(out) + + +def write_fseq(path: Path, frames: list[bytes], step_ms: int) -> None: + """An uncompressed v2 file with one `sp` variable header naming its producer.""" + producer = b"MoonLight fseq_play\0" + variable = struct.pack("<H2s", 4 + len(producer), b"sp") + producer + header_len = HEADER.size + len(variable) + data_offset = (header_len + 3) & ~3 + channels = len(frames[0]) if frames else 0 + head = HEADER.pack(b"PSEQ", data_offset, 0, 2, header_len, channels, len(frames), step_ms, + 0, 0, 0, 0, 0, int(time.time() * 1e6)) + path.write_bytes(head + variable + bytes(data_offset - header_len) + b"".join(frames)) + + +def read_header(path: Path) -> dict: + """The fixed header's fields, or SystemExit for a file that is not FSEQ.""" + with open(path, "rb") as f: + raw = f.read(HEADER.size) + if len(raw) < HEADER.size or raw[:4] != b"PSEQ": + raise SystemExit(f"{path}: not an FSEQ file") + (_, data_offset, minor, major, header_len, channels, frames, step, + _, compression, blocks, sparse, _, uid) = HEADER.unpack(raw) + return {"version": f"{major}.{minor}", "data_offset": data_offset, "header_len": header_len, + "channels": channels, "frames": frames, "step_ms": step, + "compression": compression & 0x0F, "blocks": blocks | (compression >> 4) << 8, + "sparse_ranges": sparse, "uid": uid} + + +def ddp_packets(frame: bytes) -> list[bytes]: + """One frame as DDP data packets, byte-addressed, the push flag on the last.""" + packets = [] + for seq, offset in enumerate(range(0, len(frame), DDP_PAYLOAD)): + chunk = frame[offset:offset + DDP_PAYLOAD] + last = offset + DDP_PAYLOAD >= len(frame) + header = bytes((0x40 | (0x01 if last else 0), seq & 0x0F, 0x0B, 0x01)) \ + + offset.to_bytes(4, "big") + len(chunk).to_bytes(2, "big") + packets.append(header + chunk) + return packets + + +def play(path: Path, host: str, loops: int, gap_us: int) -> None: + """Stream the file at its step time, pacing a frame's packets `gap_us` apart.""" + info = read_header(path) + if info["compression"]: + raise SystemExit("compressed FSEQ: export with compression off") + if info["sparse_ranges"]: + raise SystemExit("sparse ranges are not played by this tool") + size, step = info["channels"], info["step_ms"] / 1000 + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + print(f"playing {path.name} to {host}: {info['frames']} frames, {size} channels, {info['step_ms']} ms", flush=True) + with open(path, "rb") as f: + for loop in range(loops): + f.seek(info["data_offset"]) + start = time.time() + for n in range(info["frames"]): + packets = ddp_packets(f.read(size)) + for i, packet in enumerate(packets): + sock.sendto(packet, (host, DDP_PORT)) + if gap_us and i + 1 < len(packets): + time.sleep(gap_us / 1e6) + time.sleep(max(0.0, start + (n + 1) * step - time.time())) + print(f"loop {loop + 1}: {info['frames']} frames in {time.time() - start:.1f} s", flush=True) + + +def main() -> None: + p = argparse.ArgumentParser(description="Play an FSEQ show to a device over DDP, or write a test show.") + p.add_argument("--host", help="the device to play to") + p.add_argument("--file", type=Path, help="the show; the test sweep when omitted") + p.add_argument("--loops", type=int, default=1) + p.add_argument("--gap-us", type=int, default=500, help="microseconds between a frame's packets, as Network Send paces") + p.add_argument("--write", type=Path, help="write the test sweep to this file and stop") + p.add_argument("--info", type=Path, help="print a file's header and stop") + p.add_argument("--width", type=int, default=64) + p.add_argument("--height", type=int, default=64) + p.add_argument("--serpentine", action="store_true", help="every other row reversed, as most panels are wired") + p.add_argument("--frames", type=int, default=400) + p.add_argument("--step", type=int, default=25, help="milliseconds per frame") + a = p.parse_args() + + if a.info: + for key, value in read_header(a.info).items(): + print(f"{key}: {value}") + return + def sweep(path: Path) -> None: + write_fseq(path, [sweep_frame(a.width, a.height, f, a.serpentine) for f in range(a.frames)], a.step) + print(f"wrote {path}: {a.width}x{a.height} {'serpentine' if a.serpentine else 'rows'}, {a.frames} frames") + if a.write: + sweep(a.write) + return + if not a.host: + p.error("--host names the device to play to") + path = a.file + if path is None: + path = ROOT / "build" / "fseq" / f"sweep-{a.width}x{a.height}{'-serpentine' if a.serpentine else ''}.fseq" + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + sweep(path) + play(path, a.host.split(":")[0], a.loops, a.gap_us) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/run/monitor_esp32.py b/moondeck/run/monitor_esp32.py index 18e5c8415..5009bf3f2 100644 --- a/moondeck/run/monitor_esp32.py +++ b/moondeck/run/monitor_esp32.py @@ -16,6 +16,7 @@ import os import re import serial +import signal import subprocess import sys import time @@ -60,7 +61,7 @@ def decode(tool: str, elf: str, addrs: list[str]) -> list[str]: # Shared moondeck.json + logLevel-toggle helpers (one level up, reachable from check/ and run/). sys.path.insert(0, str(ROOT / "moondeck")) -from _moondeck_config import active_device_ips, raised_log_level, LOG_INFO # noqa: E402 +from _moondeck_config import device_ips_for_port, open_serial, raised_log_level, LOG_INFO # noqa: E402 sys.path.insert(0, str(ROOT / "moondeck" / "build")) from build_esp32 import APP_ELF # noqa: E402 @@ -107,12 +108,14 @@ def main(): # blocking before a single byte is read, and the boot output you were monitoring FOR is already # gone. The log level is a nicety; the serial stream is the point. try: - ser = serial.Serial(args.port, args.baud, timeout=1) + ser = open_serial(args.port, args.baud) except serial.SerialException as e: print(f"Cannot open {args.port}: {e}") sys.exit(1) - with raised_log_level(active_device_ips(), LOG_INFO): + # Only the board on this port, and restored however the monitor ends: MoonDeck's Stop sends SIGTERM, whose default skips the restore. + signal.signal(signal.SIGTERM, lambda *_: sys.exit(143)) + with raised_log_level(device_ips_for_port(args.port), LOG_INFO, background=True): with open(LOG_FILE, "w") as log: try: while True: @@ -134,7 +137,7 @@ def main(): while ser is None: time.sleep(0.3) with suppress(Exception): - ser = serial.Serial(args.port, args.baud, timeout=1) + ser = open_serial(args.port, args.baud) note = " -- reconnected --" print(note) log.write(note + "\n") diff --git a/mooninstaller/deviceModels.json b/mooninstaller/deviceModels.json index e2c9129a3..fa2e3e93b 100644 --- a/mooninstaller/deviceModels.json +++ b/mooninstaller/deviceModels.json @@ -792,10 +792,10 @@ "WiFi", "I2C", "Buttons", - "SD card" + "SD card", + "Audio" ], "planned": [ - "Microphone", "Speaker", "Accelerometer", "Temperature", @@ -821,7 +821,12 @@ "Effects": { "$patch": "replace", "Layer": { - "type": "Layer" + "type": "Layer", + "BeatRipples": { + "type": "BeatRipplesEffect", + "rain": 73, + "shine": 255 + } } }, "Drivers": { @@ -834,6 +839,16 @@ }, "Services": { "$patch": "replace", + "Audio": { + "type": "AudioService", + "mode": 2, + "sckPin": 43, + "wsPin": 38, + "sdPin": 39, + "mclkPin": 12, + "codec": "ES7210", + "codecAddr": "0x40" + }, "Buttons": { "type": "ButtonService", "buttons": [ diff --git a/mooninstaller/improv-frame.js b/mooninstaller/improv-frame.js index 6ee168c32..d936a2fbe 100644 --- a/mooninstaller/improv-frame.js +++ b/mooninstaller/improv-frame.js @@ -88,3 +88,40 @@ export function encodeApplyOpFrames(doc) { } return frames; } + +// The device's replies: an RPC result per acknowledged chunk, an error state when it refuses one. +export const IMPROV_FRAME_TYPE_ERROR = 0x02; +export const IMPROV_FRAME_TYPE_RPC_RESULT = 0x04; +// The device is still applying the previous document, or a chunk arrived out of order: send the document again from its first chunk. +export const IMPROV_ERROR_OP_REFUSED = 0x82; + +// The Improv frames in bytes that also carry log text: each whole frame whose checksum holds, and the bytes to keep for the next read (an incomplete frame, or the start of a magic). +export function parseImprovFrames(bytes) { + const frames = []; + let i = 0; + for (;;) { + let p = i; + while (p + IMPROV_MAGIC.length <= bytes.length && !IMPROV_MAGIC.every((m, k) => bytes[p + k] === m)) p++; + if (p + IMPROV_MAGIC.length > bytes.length) return { frames, rest: bytes.slice(Math.max(i, bytes.length - IMPROV_MAGIC.length + 1)) }; + if (p + 9 > bytes.length || p + 10 + bytes[p + 8] > bytes.length) return { frames, rest: bytes.slice(p) }; + const len = bytes[p + 8]; + let sum = 0; + for (let k = 0; k < 9 + len; k++) sum = (sum + bytes[p + k]) & 0xff; + if (sum === bytes[p + 9 + len]) { + frames.push({ type: bytes[p + 7], payload: bytes.slice(p + 9, p + 9 + len) }); + i = p + 10 + len; + } else { + i = p + 1; // a magic inside log text, or a damaged frame + } + } +} + +// Improv's "unknown RPC command": firmware without the operation, the one error a resend cannot fix. +export const IMPROV_ERROR_UNKNOWN_RPC = 0x02; + +// What a reply frame says about an APPLY_OP chunk: "ack", "unsupported" (firmware without the operation), "refused" for any other error (send again: the device is busy, or an error state left from provisioning arrived first), or null for any other frame. +export function applyOpReply(frame) { + if (frame.type === IMPROV_FRAME_TYPE_RPC_RESULT && frame.payload[0] === IMPROV_CMD_APPLY_OP) return "ack"; + if (frame.type !== IMPROV_FRAME_TYPE_ERROR || !frame.payload[0]) return null; + return frame.payload[0] === IMPROV_ERROR_UNKNOWN_RPC ? "unsupported" : "refused"; +} diff --git a/mooninstaller/index.html b/mooninstaller/index.html index 847714277..49d39fe66 100644 --- a/mooninstaller/index.html +++ b/mooninstaller/index.html @@ -92,18 +92,19 @@ <h1>MoonLight Installer <span class="version-chip" id="version-chip" hidden></sp WiFi boots into PROVISIONED and the installer can't speak Improv to it; erase first avoids that) - clean-slate testing - Costs ~12 s of chip-erase time before the flash starts. --> + Costs about 3 s of chip-erase time per MB of flash before the flash starts. --> <div class="erase-row" id="erase-row"> <label> <input type="checkbox" id="erase-before-flash"> Erase chip first - <span class="note erase-note">wipes saved WiFi + device, adds ~12 s. Tick when switching firmware variant, starting clean, or <strong>when a normal update fails</strong> or the device comes back without its settings: the partition layout can change between releases, and only an erase install applies the new one cleanly. Back up first (see below), restore after.</span> + <span class="note erase-note">wipes saved WiFi + device, adds about 3 s per MB of flash. Tick when switching firmware variant, starting clean, or <strong>when a normal update fails</strong> or the device comes back without its settings: the partition layout can change between releases, and only an erase install applies the new one cleanly. Back up first (see below), restore after.</span> </label> <label> <input type="checkbox" id="apply-device-defaults"> Apply device defaults <span class="note erase-note">sets this device model's recommended modules/settings. Auto-ticks with erase; untick to keep your current config when re-flashing a configured device.</span> </label> + <button type="button" class="secondary" id="apply-defaults-now">Apply device defaults now, without flashing</button> </div> <!-- Device picker: a control-row consistent with USB Port / Release / Firmware @@ -357,7 +358,7 @@ <h2 id="install-title">Installing</h2> <div class="install-section" id="section-flashing"> <div class="install-status" id="flashing-status">Preparing to flash…</div> <div class="install-progress"><div class="install-progress-bar" id="flash-bar"></div></div> - <p class="note install-warn">Don't close this window or unplug the device until flash completes — interrupting mid-write leaves the chip in a half-flashed state that needs a full re-flash to recover.</p> + <p class="note install-warn">Don't close this window or unplug the device until flash completes: interrupting mid-write leaves the chip in a half-flashed state that needs a full re-flash to recover.</p> </div> <!-- WiFi-creds form: rendered LAZILY by buildWifiForm() the first time diff --git a/mooninstaller/install-orchestrator.js b/mooninstaller/install-orchestrator.js index 6a343ad68..4a7f73f04 100644 --- a/mooninstaller/install-orchestrator.js +++ b/mooninstaller/install-orchestrator.js @@ -67,8 +67,10 @@ import { ImprovSerial } from "https://unpkg.com/improv-wifi-serial-sdk@2.5.0/dis import { IMPROV_CMD_SET_TX_POWER, IMPROV_FRAME_TYPE_RPC, + applyOpReply, buildImprovFrame, encodeApplyOpFrames, + parseImprovFrames, stateFrames, } from "./improv-frame.js"; import { @@ -128,7 +130,7 @@ async function fetchManifest(manifestUrl) { // Sends the SET_TX_POWER frame ([0xFD][1][dBm]) on a port we own — called // BEFORE ImprovSerial takes the port's locks, so no close/reopen dance is -// needed. Fire-and-forget like APPLY_OP: the device acks with RpcResponse we don't +// needed. Fire-and-forget: the device acks with RpcResponse we don't // read. This must precede provisioning because the cap has to land before the radio // associates; the APPLY_OP config push later also carries WiFi.txPowerSetting, but // that arrives too late for the first association on a brown-out-prone device. @@ -143,31 +145,62 @@ async function sendSetTxPowerFrame(port, dBm) { } } -// Send ONE state document (a JSON string like {"Drivers":{…}}) to the device over -// serial as APPLY_OP frames: "Improv = REST over serial". The JSON is chunked -// into [0xFC][seq][last][chunk] frames (≤ APPLY_OP_CHUNK_MAX bytes each). We own the port here (ImprovSerial closed after provision), so we -// write directly. Best-effort fire: the device acks each frame with an RpcResponse -// we don't read back (Web Serial duplex read while we hold the writer is awkward), -// and the single-buffer busy-guard on the device + a small inter-document delay keep the -// device from being overrun. Returns nothing; throws only on a write error. -async function sendApplyOpFrame(port, doc) { - const frames = encodeApplyOpFrames(doc); // [0xFC][seq][last][chunk…] per frame +// The device's replies to APPLY_OP chunks, read from a port that also carries its log. One reader for a whole push, a read still pending when a wait times out being kept for the next. +function applyOpReplies(port) { + const reader = port.readable.getReader(); + let bytes = new Uint8Array(0), pending = null; + const queue = []; + return { + // The next reply, or "timeout" when none came in `ms`. + async next(ms) { + const deadline = Date.now() + ms; + while (!queue.length) { + const left = deadline - Date.now(); + if (left <= 0) return "timeout"; + pending = pending || reader.read(); + const res = await Promise.race([pending, new Promise(r => setTimeout(() => r(null), left))]); + if (!res) return "timeout"; + pending = null; + if (res.done) return "timeout"; + const all = new Uint8Array(bytes.length + res.value.length); + all.set(bytes); all.set(res.value, bytes.length); + const { frames, rest } = parseImprovFrames(all); + bytes = rest; + for (const f of frames) { const r = applyOpReply(f); if (r) queue.push(r); } + } + return queue.shift(); + }, + async close() { + try { await reader.cancel(); } catch (_) { /* ignore */ } + try { reader.releaseLock(); } catch (_) { /* ignore */ } + }, + }; +} + +// Send ONE state document (a JSON string like {"Drivers":{…}}) as APPLY_OP chunks, stop-and-wait: each chunk waits for its acknowledgement, and a refusal (the device still applies the previous document) or a silence (a board still booting after the port opened) sends the document again from its first chunk. A document is a merge, so a resend is harmless. Returns false when the budget ran out, or the device lacks the operation. +async function sendApplyOpDocument(port, replies, doc, onLog) { + const frames = encodeApplyOpFrames(doc); + const giveUp = Date.now() + 10000; // a large layout's rebuild can hold the device for seconds const writer = port.writable.getWriter(); try { - for (const frame of frames) await writer.write(frame); + for (;;) { + let again = false; + for (const frame of frames) { + await writer.write(frame); + const reply = await replies.next(1000); + if (reply === "unsupported") return false; + if (reply !== "ack") { again = true; break; } + } + if (!again) return true; + if (Date.now() > giveUp) { + if (onLog) onLog(`[orchestrator] no acknowledgement from the device within 10 s`); + return false; + } + await new Promise(r => setTimeout(r, 100)); + } } finally { writer.releaseLock(); } - // Pace so the device's main loop consumes the document (clears its single buffer) before - // the next document's frame arrives, since the device refuses a new document while busy. This is - // open-loop (we don't read the ack back: a Web Serial duplex read while we hold the - // writer is awkward), so the delay must clear the worst-case consume window. A - // loaded tick (large grid, many modules) can run a few hundred µs, but the document is - // applied at the START of the next tick() poll, not after a full render — so ~120 ms - // comfortably covers it with headroom. (A read-back ack + retry-on-busy is the - // closed-loop upgrade; backlogged until a real install drops a document, since each document is - // a merge, so a lost one would re-apply cleanly on a re-flash.) - await new Promise(r => setTimeout(r, 120)); } // Apply a device-model's catalog defaults over serial, as APPLY_OP documents. The caller must @@ -232,11 +265,19 @@ async function catalogFlashBaud(device, onLog) { async function sendConfigOverSerial(port, device, onLog) { const entry = await fetchCatalogEntry(device, onLog); if (!entry) return false; - for (const doc of stateFrames(entry)) { - await sendApplyOpFrame(port, doc); + const replies = applyOpReplies(port); + let all = true; + try { + for (const doc of stateFrames(entry)) { + if (await sendApplyOpDocument(port, replies, doc, onLog)) continue; + all = false; + if (onLog) onLog(`[orchestrator] the device did not take ${doc.slice(0, 40)}…`); + } + } finally { + await replies.close(); } - if (onLog) onLog(`[orchestrator] applied ${device} defaults over serial`); - return true; + if (onLog) onLog(`[orchestrator] applied ${device} defaults over serial${all ? "" : ", with documents missing"}`); + return all; } // Read the device's boot serial log for the two facts the browser can't get any other @@ -551,7 +592,7 @@ export const installer = { * null when the device has no cap. * @param {boolean} [opts.eraseBefore=false] - when true, eraseFlash() * before writeFlash. Wipes the entire chip including LittleFS (saved - * WiFi credentials, device name). Adds ~12 s. Default false because + * WiFi credentials, device name). Adds about 3 s per MB. Default false because * a normal re-flash overwrites in place and users usually want * persistent state to survive a firmware bump. * @param {boolean} [opts.ethOnly=false] - the picked firmware has WiFi compiled @@ -840,11 +881,12 @@ export const installer = { // preserves user state (WiFi credentials, device name on // LittleFS), which is what most users want. Tick the box when // switching firmware variants whose partition tables differ, or - // when wiping the device for a clean install. ~12 s on a 4 MB - // chip; status text covers it so the user doesn't think it - // hung. + // when wiping the device for a clean install. It takes about 3 s per MB, so the + // status names the chip's size and the wait, and the user doesn't think it hung. if (eraseBefore) { - trackProgress("erase"); + let mb = 0; + try { mb = parseInt(await esploader.detectFlashSize(), 10) || 0; } catch (_) { /* unknown size: a generic message */ } + trackProgress("erase", { mb }); await esploader.eraseFlash(); } @@ -1140,40 +1182,51 @@ export const installer = { } if (!needsIp && !viaHttp) { - trackProgress("wifi-creds-form"); - const { ssid, password } = await uiWaitForCreds(); + // A network the board could not join brings the form back with the reason, so a mistyped password is corrected in place rather than by a reinstall. + let failedSsid = ""; + for (;;) { + trackProgress("wifi-creds-form", { failedSsid }); + const { ssid, password } = await uiWaitForCreds(); - // Skip path: user clicked Skip in the creds form (or - // submitted an empty SSID). Don't call provision() with - // empty creds — the device-side handler rejects empty - // SSIDs as ErrorState, which would surface as a misleading - // "provisioning failed" error. Instead exit cleanly with - // no deviceUrl; the host's onSuccess handler treats an - // empty url as "user opted into AP fallback, walk them - // through joining MM-XXXX manually" (see Step 2 in - // mooninstaller/index.html and the closeModal path in - // handleSuccess). - // - // Note on the two "skip" shapes: `uiWaitForIp()` returns - // a tagged union ({action: "skip"|"ip"|"retry"}) because - // the needs-ip dialog has three distinct outcomes the - // orchestrator dispatches on. `onSuccess({url:""})` is a - // sentinel because the outbound result has only two - // outcomes ("we got a URL" vs "we didn't"). Different - // shapes for different cardinalities, not drift. - if (!ssid) { - // Empty SSID (Skip in the creds form). Keep device + applyDefaults - // so the success screen guides the user to apply defaults later. - trackProgress("done"); - onSuccess({ url: "", device, applyDefaults, alreadyOnline: false }); - return; - } + // Skip path: user clicked Skip in the creds form (or + // submitted an empty SSID). Don't call provision() with + // empty creds: the device-side handler rejects empty + // SSIDs as ErrorState, which would surface as a misleading + // "provisioning failed" error. Instead exit cleanly with + // no deviceUrl; the host's onSuccess handler treats an + // empty url as "user opted into AP fallback, walk them + // through joining MM-XXXX manually" (see Step 2 in + // mooninstaller/index.html and the closeModal path in + // handleSuccess). + // + // Note on the two "skip" shapes: `uiWaitForIp()` returns + // a tagged union ({action: "skip"|"ip"|"retry"}) because + // the needs-ip dialog has three distinct outcomes the + // orchestrator dispatches on. `onSuccess({url:""})` is a + // sentinel because the outbound result has only two + // outcomes ("we got a URL" vs "we didn't"). Different + // shapes for different cardinalities, not drift. + if (!ssid) { + // Empty SSID (Skip in the creds form). Keep device + applyDefaults + // so the success screen guides the user to apply defaults later. + trackProgress("done"); + onSuccess({ url: "", device, applyDefaults, alreadyOnline: false }); + return; + } - trackProgress("provisioning"); - // provision() throws on failure (timeout, bad creds). 30s - // timeout matches the device-side wait at - // platform_esp32_improv.cpp::improvHandleProvision. - await improvClient.provision(ssid, password, 30000); + trackProgress("provisioning"); + // provision() throws on failure (timeout, bad creds). 30s + // timeout matches the device-side wait at + // platform_esp32_improv.cpp::improvHandleProvision. + try { + await improvClient.provision(ssid, password, 30000); + break; + } catch (e) { + if (!/UNABLE_TO_CONNECT/.test(String(e && e.message || e))) throw e; + if (onLog) onLog(`[orchestrator] the board could not join ${ssid}; asking again`); + failedSsid = ssid; + } + } deviceUrl = improvClient.nextUrl; if (!deviceUrl) { throw new Error("provision succeeded but no device URL returned"); @@ -1286,6 +1339,35 @@ export const installer = { await releaseDetected(); }, + /** + * Defaults-only flow: send a device model's modules and settings to a board that already runs MoonLight, over its USB port, with no flash. For a board whose install skipped the defaults (unticked, or WiFi that failed first). + */ + async applyDefaultsOnly({ port, device, onProgress, onSuccess, onError, onLog }) { + if (!navigator.serial) { + onError("setup", new Error("Web Serial API not available: use Chrome, Edge, or Opera")); + return; + } + let lastStage = "request-port"; + const trackProgress = (stage, detail) => { lastStage = stage; onProgress(stage, detail); }; + let opened = false; + try { + trackProgress("request-port"); + if (!port) port = await navigator.serial.requestPort({}); + await port.open({ baudRate: 115200 }); + opened = true; + // Opening the port can restart the board; the stop-and-wait resends until it listens again. + if (!await pushDefaultsOverSerial(port, device, true, trackProgress, onLog)) { + throw new Error(`the board did not take the ${device} defaults: see the log`); + } + trackProgress("done"); + onSuccess({ url: "", device, applyDefaults: true, defaultsApplied: true, defaultsOnly: true }); + } catch (e) { + onError(lastStage, e); + } finally { + if (opened) { try { await port.close(); } catch (_) { /* ignore */ } } + } + }, + /** * Erase-only flow: request a port and wipe the chip. No flash, no * provision. Used by the "Erase" button on a "Your devices" entry. diff --git a/mooninstaller/install.css b/mooninstaller/install.css index 96c4b13c8..404ed0063 100644 --- a/mooninstaller/install.css +++ b/mooninstaller/install.css @@ -133,6 +133,7 @@ .erase-row { margin-top: 12px; font-size: 13px; } .erase-row label { cursor: pointer; } .erase-row input { vertical-align: middle; margin-right: 6px; } + .erase-row button { margin-top: 8px; } .erase-note { display: inline; margin-top: 0; } a { color: var(--accent); } code { @@ -457,7 +458,7 @@ justify-content: flex-end; margin-top: 16px; } - .install-actions button { + .install-actions button, .erase-row button { padding: 8px 16px; font: inherit; font-weight: 600; @@ -469,11 +470,12 @@ background: var(--accent); color: #1a1a2e; } - .install-actions button.secondary { + .install-actions button.secondary, .erase-row button.secondary { background: transparent; color: var(--fg); border: 1px solid var(--border); } + .install-status.error { color: #f8a5a5; } .install-error { color: #f8a5a5; font-size: 13px; diff --git a/mooninstaller/install.js b/mooninstaller/install.js index 44413c0d8..984d36741 100644 --- a/mooninstaller/install.js +++ b/mooninstaller/install.js @@ -213,7 +213,14 @@ document.addEventListener('DOMContentLoaded', () => { // Mapping from orchestrator's progress stage → which modal section to // show + any per-stage detail rendering. Centralizes the UI ↔ flow // contract so the orchestrator stays UI-agnostic. + // The erase reports no progress, so its bar runs on the estimate of about 3 s per MB, held short of full until the write takes over. + let eraseTimer = null; + function stopEraseEstimate() { + if (eraseTimer) { clearInterval(eraseTimer); eraseTimer = null; } + } + function handleProgress(stage, detail) { + stopEraseEstimate(); switch (stage) { case "request-port": showSection("connecting"); @@ -243,19 +250,29 @@ document.addEventListener('DOMContentLoaded', () => { break; case "erase": { showSection("flashing"); - document.getElementById("flashing-status").textContent = - "Erasing flash (this takes ~12 s)…"; - // esptool-js's eraseFlash doesn't report progress — animate the - // bar so the user sees the page isn't hung. flash stage clears - // the class below and switches to determinate progress. - // - // Clear the inline width set by an earlier stage (fetch-firmware - // forces `style.width = "0%"`); inline style beats the CSS class's - // `width: 100%` rule, so the animation would run invisibly on a - // zero-width bar without this reset. + const status = document.getElementById("flashing-status"); const bar = document.getElementById("flash-bar"); - bar.style.width = ""; - bar.classList.add("indeterminate"); + if (detail && detail.mb) { + // An estimate, so the bar stops at 95% and the text says so once the time is up. + const estimateS = detail.mb * 3; + const start = Date.now(); + const show = () => { + const elapsedS = (Date.now() - start) / 1000; + const left = Math.ceil(estimateS - elapsedS); + bar.style.width = `${Math.min(95, (elapsedS / estimateS) * 100)}%`; + status.textContent = left > 0 + ? `Erasing ${detail.mb} MB of flash… about ${left} s left` + : `Erasing ${detail.mb} MB of flash… almost done`; + }; + bar.classList.remove("indeterminate"); + show(); + eraseTimer = setInterval(show, 500); + } else { + // No size to estimate from: the marquee shows the page is alive. The inline width an earlier stage set would hide it, the inline style beating the class. + status.textContent = "Erasing flash (about 3 s per MB)…"; + bar.style.width = ""; + bar.classList.add("indeterminate"); + } break; } case "flash": { @@ -315,10 +332,21 @@ document.addEventListener('DOMContentLoaded', () => { // inside the field. Don't leave that sitting in the DOM longer // than the active install. passEl.value = ""; - try { - const saved = localStorage.getItem("MoonLight.installer.lastSsid"); - if (saved) ssidEl.value = saved; - } catch (_) { /* hostile storage */ } + // A network the board could not join comes back with the reason, its name kept for the password to be typed again. + const failedSsid = detail && detail.failedSsid; + const formStatus = document.getElementById("wifi-form-status"); + formStatus.textContent = failedSsid + ? `The board could not join "${failedSsid}". Check the password, and that the network is 2.4 GHz, then connect again.` + : "Flash done. Enter your WiFi credentials to provision the device."; + formStatus.classList.toggle("error", !!failedSsid); + if (failedSsid) { + ssidEl.value = failedSsid; + } else { + try { + const saved = localStorage.getItem("MoonLight.installer.lastSsid"); + if (saved) ssidEl.value = saved; + } catch (_) { /* hostile storage */ } + } requestAnimationFrame(() => (ssidEl.value ? passEl : ssidEl).focus()); break; } @@ -378,7 +406,7 @@ document.addEventListener('DOMContentLoaded', () => { _wifiFormBuilt = true; const section = document.getElementById("section-wifi-form"); section.innerHTML = ` - <div class="install-status">Flash done. Enter your WiFi credentials to provision the device.</div> + <div class="install-status" id="wifi-form-status"></div> <form id="wifi-form" class="install-form" onsubmit="return false"> <label for="wifi-ssid">Network name (SSID)</label> <input type="text" id="wifi-ssid" autocomplete="off" @@ -1175,6 +1203,35 @@ document.addEventListener('DOMContentLoaded', () => { } })(); + // The device model's defaults for a board that already runs MoonLight, over USB, for an install that skipped them. + document.getElementById("apply-defaults-now").addEventListener("click", async () => { + const device = installPicker.getSelectedDevice(); + if (!device) { alert("Pick the device model first, under Device."); return; } + if (_monitor.port) await closeMonitor(); + openModal(`Applying ${device} defaults`); + showSection("connecting"); + document.getElementById("connecting-detail").textContent = ""; + await installer.applyDefaultsOnly({ + port: pickedPort, + device, + onProgress: handleProgress, + onSuccess: () => { + disarmUnloadGuard(); + showSection("done"); + document.getElementById("done-status").textContent = "Defaults applied"; + const a = document.getElementById("done-url"); + a.removeAttribute("href"); + a.textContent = ""; + document.getElementById("done-url-mdns").hidden = true; + const note = document.getElementById("done-defaults"); + note.textContent = `Applied ${device} defaults. They take effect at once; no restart needed.`; + note.hidden = false; + }, + onError: handleError, + onLog: appendLog, + }); + }); + myDevices.init({ container: document.getElementById("devices-mount"), onErase: async (device) => { diff --git a/src/core/services/AudioService.h b/src/core/services/AudioService.h index ff636069c..3915a637b 100644 --- a/src/core/services/AudioService.h +++ b/src/core/services/AudioService.h @@ -84,18 +84,19 @@ class AudioService : public MoonModule { /// The master clock a converter may need, a self-clocked part leaving it unset. int8_t mclkPin = -1; /// The codecs `codec` picks from, in its option order. - enum Codec : uint8_t { kCodecNone, kCodecEs8311, kCodecCount }; + enum Codec : uint8_t { kCodecNone, kCodecEs8311, kCodecEs7210, kCodecCount }; /// Which codec sits in front of the microphone, none for a part that speaks I2S itself; the device model names it. uint8_t codec = kCodecNone; - /// The codec's 7-bit address on the board's I2C bus (I2cBusModule), the ES8311's own unless the board straps it elsewhere. + /// The codec's 7-bit address on the board's I2C bus (I2cBusModule): 0x18 for an ES8311, 0x40 to 0x43 for an ES7210 by its strapping. uint8_t codecAddr = 0x18; /// The codec the settings ask for. platform::CodecType codecType() const MM_NONBLOCKING { - return codec == kCodecEs8311 ? platform::CodecType::Es8311 : platform::CodecType::None; + return codec == kCodecEs8311 ? platform::CodecType::Es8311 + : codec == kCodecEs7210 ? platform::CodecType::Es7210 : platform::CodecType::None; } /// The codecs the selector offers, each by the name the bus scan shows it under. - static constexpr const char* kCodecOptions[] = {"none", "ES8311"}; + static constexpr const char* kCodecOptions[] = {"none", "ES8311", "ES7210"}; /// The codec, while the wired microphone uses one, so the bus scan names its address. uint8_t i2cDevices(I2cDevice* out, uint8_t max) const override { @@ -298,10 +299,10 @@ class AudioService : public MoonModule { /// How many times the receive socket joined the group, for the tests. uint32_t syncJoinCountForTest() const { return syncSock_.joinCountForTest(); } + /// What a consumer reads with no source: a class constant, since a function-local static needs a guard on first use. + static constexpr AudioFrame kSilence{}; /// The live frame every consumer reads, or silence where there is no source. static const AudioFrame* latestFrame() MM_NONBLOCKING { - // Not a function-local static, which would need a guard and a lock on first use. - static constexpr AudioFrame kSilence{}; AudioService* a = ActiveInstance<AudioService>::active(); return a ? &a->frame_ : &kSilence; } diff --git a/src/core/services/PictureService.h b/src/core/services/PictureService.h new file mode 100644 index 000000000..e22cd53f9 --- /dev/null +++ b/src/core/services/PictureService.h @@ -0,0 +1,229 @@ +#pragma once + +#include "core/module/MoonModule.h" +#include "core/util/ActiveInstance.h" // the one-active-source election +#include "core/util/color.h" // RGB +#include "core/util/PictureFrame.h" +#include "core/util/ScratchBuffer.h" +#include "core/util/TryLock.h" // the swap a reader holds off for its gather +#include "platform/platform.h" // maxAllocBlock, freeHeap: whether the second picture fits + +#include <cstdint> +#include <cstring> + +namespace mm { + +/// Produces a picture for the effects that sample it: the source half of the picture pipeline, named for what it publishes, as Audio is. +/// +/// Each source is an option of `source`. One service is active at a time, and a `Reader` hands its picture to any number of effects. +/// +/// A source writing from its own task fills a back picture while effects read the front one, and publishing swaps the two pointers. +/// +/// Without room for the second picture, the one picture is locked while written, so frames may be skipped but never torn. +class PictureService : public MoonModule { +public: + ModuleRole role() const MM_NONBLOCKING override { return ModuleRole::Service; } + + /// The sources `source` picks from, in option order. + enum Source : uint8_t { kTestPattern, kSourceCount }; + /// Where the picture comes from. + uint8_t source = kTestPattern; + /// The picture's width in pixels. + uint16_t width = 64; + /// The picture's height in pixels, 4:3 by default as the test card is. + uint16_t height = 48; + + /// Publish the source and the picture's size. + void defineControls() override { + static constexpr const char* kSources[] = {"test pattern"}; + controls_.addSelect("source", source, kSources, kSourceCount); + controls_.addControl("width", width, 1, kMaxSide); + controls_.addControl("height", height, 1, kMaxSide); + } + + /// Every control reshapes or redraws the picture. + bool affectsPrepare(const char*) const override { return true; } + + /// Size the pixels for the source and draw a still one into them; a failed allocation publishes no picture. + void prepare() override { + seat_.claim(); + if (!prepareBuffers(kRunsInTask[source])) return; + drawTestPattern(front_, width, height); // the frame prepareBuffers published, filled before any reader ticks + } + + /// Size one picture, or two for a source writing from its own task, degrading to one when the second does not fit; false when even one does not. + bool prepareBuffers(bool writerTask) { + // On the render thread with the source's task stopped, so no reader or writer holds the pictures. + const size_t bytes = static_cast<size_t>(width) * height * 3; + if (!a_.resize(bytes)) { + front_ = back_ = nullptr; + frame_ = PictureFrame{nullptr, 0, 0, frame_.generation + 1}; + setStatus("not enough memory for the picture", Severity::Error); + return false; + } + front_ = a_.data(); + back_ = writerTask && secondFits(bytes) && b_.resize(bytes) ? b_.data() : nullptr; + if (!back_) b_.resize(0); + if (writerTask && !back_) setStatus("one picture buffer: not enough memory, a capture may skip frames", Severity::Warning); + else clearStatus(); + frame_ = PictureFrame{front_, width, height, frame_.generation + 1}; + return true; + } + + /// Whether a second picture is held, so a writer fills one while readers read the other. + bool twoBuffers() const { return back_ != nullptr; } + + /// The picture a source fills: the back one, or the only one, locked until endWrite. + uint8_t* beginWrite() { + if (back_) return back_; + while (!lock_.tryAcquire()) platform::yield(); // readers hold it for one gather at most + return front_; + } + /// Publish what beginWrite handed out: swap the two pictures, or unlock the one, and bump the generation. + void endWrite() { + if (back_) { + while (!lock_.tryAcquire()) platform::yield(); + uint8_t* filled = back_; + back_ = front_; + front_ = filled; + } + frame_ = PictureFrame{front_, width, height, frame_.generation + 1}; + lock_.release(); + } + + /// Re-claim an empty seat, so removing the active service lets a survivor take over. + void tick() MM_NONBLOCKING override { seat_.claim(); } + + /// Vacate the seat for any survivor. + void release() override { + seat_.vacate(); + MoonModule::release(); + } + + /// What a consumer reads with no source: a class constant, since a function-local static needs a guard on first use. + static constexpr PictureFrame kNone{}; + + /// The active service's picture held still for one read, or an empty frame when no service publishes one; false while a writer swaps or fills the only picture. + class Reader { + public: + /// Take the active service's lock for one read, which fails while a writer swaps. + Reader() : svc_(ActiveInstance<PictureService>::active()), held_(!svc_ || svc_->lock_.tryAcquire()) {} + /// Let a writer swap again. + ~Reader() { if (svc_ && held_) svc_->lock_.release(); } + /// Not copyable: one reader holds the lock once. + Reader(const Reader&) = delete; + /// Not assignable, for the same reason. + Reader& operator=(const Reader&) = delete; + /// Whether the frame may be read now. + explicit operator bool() const { return held_; } + /// The frame, valid while this reader lives. + const PictureFrame& frame() const { return svc_ ? svc_->frame_ : kNone; } + private: + PictureService* svc_; + bool held_; + }; + + /// The test card in the layout of Philips' PM5544, drawn from coordinates at any size; its round circle shows a stretch, its gratings where sampling aliases. + static void drawTestPattern(uint8_t* rgb, uint16_t w, uint16_t h) { + const float cx = w / 2.0f, cy = h / 2.0f; + const float r = 0.44f * (w < h * 4 / 3 ? w * 0.75f : h); // the circle's radius: 88% of a 4:3 picture's height + const float sq = 2.0f * r / 12.0f; // a grid square: twelve across the circle + const float line = sq / 12.0f > 1.0f ? sq / 12.0f : 1.0f; + for (uint32_t y = 0; y < h; y++) + for (uint32_t x = 0; x < w; x++) { + const float px = x + 0.5f, py = y + 0.5f; + const float dx = px - cx, dy = py - cy; + uint8_t* out = rgb + (y * w + x) * 3; + const RGB c = dx * dx + dy * dy <= r * r ? cardCircle((dx + r) / (2 * r), (dy + r) / (2 * r)) + : cardBackground(dx / sq, dy / sq, line / sq); + out[0] = c.r; out[1] = c.g; out[2] = c.b; + } + } + +private: + /// Outside the circle, in grid squares from the center: the gray grid with its white lines, and the four colored blocks at each side. + static RGB cardBackground(float gx, float gy, float lineFrac) { + RGB block; + if (sideBlock(gx, gy, block)) return block; + return onGridLine(gx, lineFrac) || onGridLine(gy, lineFrac) ? RGB{230, 230, 230} : RGB{128, 128, 128}; + } + + /// The colored block at (gx, gy), true when there is one: two a side, a tall one outside and a short one inside, each split at the middle row. + static bool sideBlock(float gx, float gy, RGB& out) { + static constexpr RGB kOuter[2][2] = {{{80, 160, 140}, {160, 160, 90}}, {{160, 80, 110}, {100, 80, 160}}}; // [bottom][right] + static constexpr RGB kInner[2] = {{70, 130, 160}, {160, 110, 80}}; // [bottom] + const float ax = gx < 0 ? -gx : gx; + if (ax < 5.0f || ax >= 7.0f || gy <= -5.0f || gy >= 5.0f) return false; + const int bottom = gy >= 0, right = gx >= 0; + if (ax >= 6.0f) { out = kOuter[bottom][right]; return true; } + if (gy > -3.0f && gy < 3.0f) return false; + out = kInner[bottom]; + return true; + } + + /// Whether a coordinate in grid squares falls on a grid line `lineFrac` of a square wide. + static bool onGridLine(float g, float lineFrac) { + return g - static_cast<float>(static_cast<int>(g < 0 ? g - 1 : g)) < lineFrac; + } + + /// Inside the circle at (s, t), each 0 to 1 across its diameter: the center marker over the middle, else the band `t` falls in, from top to bottom. + static RGB cardCircle(float s, float t) { + if (s > 0.47f && s < 0.53f && t > 0.38f && t < 0.72f) return RGB{0, 0, 0}; + struct Band { float until; RGB (*draw)(float s, float t); }; + static constexpr Band kBands[] = {{0.125f, topBox}, {0.25f, shoulders}, {0.33f, steps}, {0.50f, colorBars}, + {0.60f, crossLines}, {0.75f, gratings}, {0.82f, grayRamp}, {0.91f, lowerBox}}; + for (const Band& b : kBands) if (t < b.until) return b.draw(s, t); + return (s > 0.45f && s < 0.55f) ? RGB{180, 40, 30} : RGB{190, 190, 50}; // the yellow base with its red mark + } + + // The bands, each one call: a box, the black shoulders, the gray and black steps, the bars, the lines, the gratings, the gray ramp, the lower box. + static RGB bw(bool black) { return black ? RGB{0, 0, 0} : RGB{255, 255, 255}; } + static RGB topBox(float s, float t) { return bw(s > 0.30f && s < 0.70f && t > 0.04f); } + static RGB shoulders(float s, float) { return bw(s < 0.30f || s > 0.75f); } + static RGB steps(float s, float) { return static_cast<int>(s * 10.0f) % 2 ? RGB{0, 0, 0} : RGB{190, 190, 190}; } + static RGB colorBars(float s, float) { + // Yellow, cyan, green, magenta, red, blue: the bars at three quarters of full saturation. + static constexpr RGB kBars[6] = {{190, 190, 50}, {80, 190, 190}, {80, 190, 60}, {180, 60, 190}, {180, 40, 30}, {0, 30, 190}}; + return kBars[sixth(s)]; + } + static RGB crossLines(float s, float t) { + const float fs = s * 12.0f; + return bw(!(fs - static_cast<float>(static_cast<int>(fs)) < 0.06f || (t > 0.545f && t < 0.555f))); + } + static RGB gratings(float s, float) { + // Five gratings of rising frequency, the resolution wedges. + if (s < 0.1f || s >= 0.9f) return RGB{0, 0, 0}; + const int g = static_cast<int>((s - 0.1f) / 0.16f); + const float phase = (s - 0.1f - g * 0.16f) / 0.16f * static_cast<float>(3 + 2 * g); + return bw(phase - static_cast<float>(static_cast<int>(phase)) >= 0.5f); + } + static RGB grayRamp(float s, float) { + static constexpr uint8_t kSteps[6] = {0, 50, 100, 150, 200, 255}; + const uint8_t v = kSteps[sixth(s)]; + return RGB{v, v, v}; + } + static RGB lowerBox(float s, float) { return bw(s > 0.25f && s < 0.75f); } + /// Which sixth of the width `s` falls in, 0 to 5. + static int sixth(float s) { const int i = static_cast<int>(s * 6.0f); return i < 0 ? 0 : (i > 5 ? 5 : i); } + + static constexpr uint16_t kMaxSide = 512; ///< a side above this exceeds what a board holds for one picture + /// Which sources write from a task of their own, in `source` order; the test pattern is drawn at prepare. + static constexpr bool kRunsInTask[kSourceCount] = {false}; + + /// Whether an optional picture of `bytes` fits one free block with the heap's reserve intact, the rule the mapping uses; 0 from the platform means no limit. + static bool secondFits(size_t bytes) { + const size_t block = platform::maxAllocBlock(); + if (block != 0 && block < bytes) return false; + const size_t free = platform::freeHeap(); + return free == 0 || (free > platform::HEAP_RESERVE && free - platform::HEAP_RESERVE >= bytes); + } + + ScratchBuffer<uint8_t> a_{*this}, b_{*this}; ///< the pictures, three bytes a pixel; b_ only for a writer task + uint8_t* front_ = nullptr; ///< what readers see + uint8_t* back_ = nullptr; ///< what a writer fills, or null with one picture + PictureFrame frame_; ///< the front picture, as a Reader hands it out + TryLock lock_; ///< held by a reader for its gather, by the writer to swap or to fill the only picture + ActiveInstance<PictureService> seat_{*this}; +}; + +} // namespace mm diff --git a/src/core/system/NetworkModule.h b/src/core/system/NetworkModule.h index 494edd7fe..a6c57adb0 100644 --- a/src/core/system/NetworkModule.h +++ b/src/core/system/NetworkModule.h @@ -10,6 +10,7 @@ #include "core/system/WiFiModule.h" #include "core/system/AccessPointModule.h" #include "core/util/CaptivePortal.h" +#include "core/util/JoinWindow.h" // kStaGraceMs, kDhcpWaitMs: the windows the provisioning answer shares #include "platform/platform.h" #include <cstdio> @@ -28,8 +29,7 @@ inline bool addressCounts(const uint8_t ip[4], bool buildHasWiFi, const uint8_t* /// All device connectivity, cascading from Ethernet through WiFi to an access point: @xref{the-cascade}. /// -/// Network holds the cascade, its mode and mDNS; each interface's settings are on its own card below it: Ethernet, WiFi and the access point. -/// A desktop builds the same cards over stubs, so they can be shown and tested, and reports no network of its own. +/// Network holds the cascade, its mode and mDNS; each interface's settings are on its own card: Ethernet, WiFi and the access point. A desktop builds the same cards over stubs. /// @card NetworkModule.png /// /// @moreinfo @@ -40,9 +40,9 @@ inline bool addressCounts(const uint8_t ip[4], bool buildHasWiFi, const uint8_t* /// /// ## A link-local address is a last resort /// -/// With no DHCP server the client gives itself a 169.254.x.y address (RFC 3927), so a laptop on the same cable still reaches the device by name. It counts as connected only on a build without WiFi, where it is the one way in. -/// Where WiFi exists it never displaces the cascade: a cable on a network without DHCP would otherwise switch off a working WiFi path or the access point. -/// A 169.254 address the user set as static is a choice, not a fallback, so it counts everywhere, while a self-assigned one beside a static setting still does not. +/// With no DHCP server the client takes a 169.254.x.y address (RFC 3927), the one way in on a build without WiFi; where WiFi exists it never displaces the cascade. +/// +/// A 169.254 address the user set as static is a choice rather than a fallback, so it counts everywhere. /// /// The device name belongs to the system module, and is the one identity behind every name. It registers before the light pipeline, which then sees the real remaining heap. class NetworkModule : public MoonModule { @@ -279,7 +279,7 @@ class NetworkModule : public MoonModule { // Pinned during bring-up, since a network without a server fires no event. if (!staUp()) applyStaStatic(); if (staUp()) { onStaJoined(now); return; } - if (elapsed <= kStaGraceMs) return; + if (elapsed <= staWindowMs()) return; // A join asked for from the card says why there, and the known networks take over from the top; otherwise the next one is tried, then the fallback. const bool asked = wifi_ && wifi_->joinRequest(); if (asked) { @@ -369,7 +369,7 @@ class NetworkModule : public MoonModule { setStatus(statusBuf_, Severity::Warning); return; } - const uint32_t grace = releasing_ ? kDhcpWaitMs : kStaGraceMs; + const uint32_t grace = releasing_ ? kDhcpWaitMs : staWindowMs(); if (now - lostTime_ <= grace) return; std::printf("NetworkModule: WiFi STA gone for %us, starting AP\n", static_cast<unsigned>(grace / 1000)); restartMdns(); @@ -479,10 +479,8 @@ class NetworkModule : public MoonModule { uint32_t stateChangeTime_ = 0; /// When the carrying interface lost its address: a WiFi dropout the radio recovers from itself, or a re-lease. uint32_t lostTime_ = 0; - /// How long WiFi gets to connect or recover, the question being the same in both cases. - static constexpr uint32_t kStaGraceMs = 10000; - /// How long a lease may take: a live Ethernet link without an address, or an interface re-leasing after a switch to DHCP. - static constexpr uint32_t kDhcpWaitMs = 15000; + /// The station's window as it stands now, joined or not. + static uint32_t staWindowMs() MM_NONBLOCKING { return mm::staWindowMs(platform::wifiStaAssociated()); } /// The interface carrying the device is taking a lease the cascade itself asked for, so the gap is that wait rather than a dropout. bool releasing_ = false; diff --git a/src/core/util/JoinWindow.h b/src/core/util/JoinWindow.h new file mode 100644 index 000000000..9b67d7568 --- /dev/null +++ b/src/core/util/JoinWindow.h @@ -0,0 +1,19 @@ +#pragma once + +#include <cstdint> + +namespace mm { + +/// @defgroup JoinWindow How long a station gets to join a network +/// @{ +/// The network cascade and the provisioning answer read the same windows, so a board tells the installer it failed exactly when it gives up itself. + +/// How long WiFi gets to join a network, first or again after a dropout. +inline constexpr uint32_t kStaGraceMs = 10000; +/// How long a lease may take: a live Ethernet link or a joined station without an address, or an interface re-leasing after a switch to DHCP. +inline constexpr uint32_t kDhcpWaitMs = 15000; +/// How long a station gets before the next network or the fallback: the join's grace, plus the lease's wait once it has joined, as Ethernet waits on a live link. +inline constexpr uint32_t staWindowMs(bool associated) { return associated ? kStaGraceMs + kDhcpWaitMs : kStaGraceMs; } + +/// @} +} // namespace mm diff --git a/src/core/util/PictureFrame.h b/src/core/util/PictureFrame.h new file mode 100644 index 000000000..e77df7e21 --- /dev/null +++ b/src/core/util/PictureFrame.h @@ -0,0 +1,17 @@ +#pragma once + +#include <cstdint> + +namespace mm { + +/// A picture as a source publishes it: RGB rows, top row first, three bytes a pixel. +/// +/// `PictureService` produces it and `PictureEffect` samples it, so an effect never touches a capture device or a decoder. +struct PictureFrame { + const uint8_t* rgb = nullptr; ///< the pixels, null when there is no picture + uint16_t width = 0; ///< pixels across + uint16_t height = 0; ///< pixels down + uint32_t generation = 0; ///< changes with the pixels or the size, so a consumer skips a picture it already sampled +}; + +} // namespace mm diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index afd634236..5883072a9 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -552,6 +552,7 @@ class Drivers : public MoonModule, public LightOutput { outputBuffer_.free(); } setDynamicBytes(outputBuffer_.bytes()); + wiredLast_ = false; // a reallocated output holds no frame yet // Decided from the alloc OUTCOME, so a board that cannot claim the buffer runs inline. const bool shouldSplit = splitWanted && outputBuffer_.data(); @@ -588,30 +589,41 @@ class Drivers : public MoonModule, public LightOutput { } // Resolved ONCE: both single-layer branches need it, and an if-init shadowed the outer one. Layer* srcLayer = effects_ ? effects_->firstEnabledLayer() : layer_; + // A frame already in wiring order, which goes to the output as it is: one copy, whatever the layout. + EffectBase* const wired = outputBuffer_.data() && srcLayer ? srcLayer->wiringSource() : nullptr; + composite(srcLayer, wired); + wiredLast_ = wired != nullptr; + // A socket write still lands in lwIP on core 0, so core 1 builds and core 0 sends. + if (renderSplitActive_) { + encodeDone_.store(false, std::memory_order_release); + platform::notifyTask(encodeTask_); + tickNonDriverChildren(); + } else { + MoonModule::tick(); // parent already composited; base ticks all children + } + } - if (outputBuffer_.data() && effects_ && effects_->enabledLayerCount() > 1) { - // The bottom layer overwrites; each one above blends per its own mode and opacity: @xref{the-shared-output-buffer}. + /// Fill the shared output from the layers: blended when several, a wiring-order frame as it is, else the one layer mapped. @xref{the-shared-output-buffer} + void composite(Layer* srcLayer, EffectBase* wired) MM_NONBLOCKING { + if (!outputBuffer_.data()) return; // the drivers read the layer's buffer directly + if (effects_ && effects_->enabledLayerCount() > 1) { + // The bottom layer overwrites; each one above blends per its own mode and opacity. effects_->forEachEnabledLayer([&](Layer* L, bool first) { BlendOp op = first ? BlendOp::Overwrite : L->blendOp(); uint8_t op_opacity = first ? 255 : L->opacity; blendMap(L->buffer(), outputBuffer_, L->lut(), L->channelsPerLight(), op, op_opacity, /*clearFirst=*/first); }); - } else if (outputBuffer_.data() && srcLayer && srcLayer->lut().hasLUT()) { - // One layer with a LUT: map its logical buffer into physical space. - blendMap(srcLayer->buffer(), outputBuffer_, srcLayer->lut(), srcLayer->channelsPerLight()); - } else if (renderSplitActive_ && outputBuffer_.data() && srcLayer) { - // Copied even in the identity case: core 1 must not read a buffer core 0 is mutating. - blendMap(srcLayer->buffer(), outputBuffer_, srcLayer->lut(), srcLayer->channelsPerLight()); + return; } - // A socket write still lands in lwIP on core 0, so core 1 builds and core 0 sends. - if (renderSplitActive_) { - encodeDone_.store(false, std::memory_order_release); - platform::notifyTask(encodeTask_); - tickNonDriverChildren(); - } else { - MoonModule::tick(); // parent already composited; base ticks all children + if (wired) { + // Whole the first time, since the output held the mapped frame or a fresh allocation. + wired->copyWiringFrame(outputBuffer_.data(), outputBuffer_.bytes(), !wiredLast_); + return; } + // A mapped layer goes through its LUT; an identity one is copied only for the split, as core 1 must not read a buffer core 0 is mutating. + if (srcLayer && (srcLayer->lut().hasLUT() || renderSplitActive_)) + blendMap(srcLayer->buffer(), outputBuffer_, srcLayer->lut(), srcLayer->channelsPerLight()); } // This container picks the SIDE; core's one tickChildren loop still applies the rule. @@ -638,6 +650,7 @@ class Drivers : public MoonModule, public LightOutput { std::atomic<bool> encodeStop_{false}; // stop flag the worker fn observes via a woken waitNotify // atomic, not volatile: volatile is no thread primitive bool renderSplitActive_ = false; // the split is engaged (task spawned, boundary in effect) + bool wiredLast_ = false; ///< the last frame went out as a wiring-order copy, so the output already holds it bool encodeStalled_ = false; // a stall warning is showing, so recovery can clear it uint32_t renderWaitUs_ = 0; // last frame's core-0 wait at the boundary (the tick-line KPI) uint32_t renderWaitPeakUs_ = 0; // worst wait in the current 1 s window (what the control shows) diff --git a/src/light/effects/BeatRipplesEffect.h b/src/light/effects/BeatRipplesEffect.h index ab4bd4fc0..a2f75f586 100644 --- a/src/light/effects/BeatRipplesEffect.h +++ b/src/light/effects/BeatRipplesEffect.h @@ -7,9 +7,7 @@ namespace mm { /// Effect: a wave surface where every detected beat drops a stone, rippling and interfering. /// @card BeatRipplesEffect.gif /// -/// The surface is a real wave simulation, the classic two-buffer scheme. -/// Ripples pass through each other, reflect off the walls, and interfere into standing patterns. -/// A detected onset drops a stone, and between beats the surface keeps ringing on its own. +/// The surface is a real wave simulation, the classic two-buffer scheme: ripples pass through each other, reflect off the walls and interfere, and a detected onset drops a stone. /// /// Prior art: Gomez 2000, the water effect the demoscene settled on. /// @@ -17,13 +15,9 @@ namespace mm { /// /// ## The wave equation, and rendering by slope /// -/// Each cell's next height is its four neighbors averaged and doubled, minus its previous height, damped. -/// That discrete wave equation is what a hand-drawn expanding circle cannot give. -/// The field renders by slope rather than height: a surface is visible because it bends light. -/// So the difference between neighboring cells lights a pixel, and crests read as bright lines. +/// Each cell's next height is its four neighbors averaged and doubled, minus its previous height, damped; the field renders by slope, so crests read as bright lines. /// -/// The loudest band picks where a stone lands, bass near the center and treble out at the rim. -/// `Layer::extrude` fills a cube with the plane, as Particles and Wave do, since the equation is 2D. +/// The loudest band picks where a stone lands, bass near the center and treble at the rim; a cube takes the plane extruded, as the equation is 2D. class BeatRipplesEffect : public EffectBase { public: /// Catalog tags for the visual catalog. @@ -114,7 +108,7 @@ class BeatRipplesEffect : public EffectBase { renderSurface(cv, w, h); } - /// One step of the wave equation, which is what makes ripples pass through each other. + /// One step of the wave equation, which is what makes ripples pass through each other. @xref{the-wave-equation-and-rendering-by-slope} void waveStep() { const lengthType w = width(), h = height(); int16_t* c = cur_.data(); @@ -143,13 +137,15 @@ class BeatRipplesEffect : public EffectBase { int32_t mag = (gx < 0 ? -gx : gx) + (gy < 0 ? -gy : gy); mag = (mag * shine) / 512; const uint8_t bri = static_cast<uint8_t>(mag > 255 ? 255 : mag); - // The index follows height, so a crest and a trough differ in color. - const int32_t hgt = static_cast<int32_t>(s[o]); - const uint8_t index = static_cast<uint8_t>(128 + (hgt > 4000 ? 127 : (hgt < -4000 ? -128 : (hgt * 127) / 4000))); - draw::pixel(cv, {x, y, 0}, colorFromPalette(*Palettes::active(), index, bri)); + draw::pixel(cv, {x, y, 0}, colorFromPalette(*Palettes::active(), paletteIndex(s[o], seq_), bri)); } } + /// The palette index at a height: it wraps with height, so ripples a few hundred high walk the whole palette, and each stone moves the start along it. + static uint8_t paletteIndex(int16_t height, uint32_t stones) { + return static_cast<uint8_t>(128 + static_cast<int32_t>(stones * 8u) + height / 8); + } + private: /// Scales the 0..255 knobs onto the int16 field, pressing a third of the way to the clamp at 255. static constexpr int32_t kSplashScale = 6000; diff --git a/src/light/effects/ChannelDataSink.h b/src/light/effects/ChannelDataSink.h new file mode 100644 index 000000000..b12ef834a --- /dev/null +++ b/src/light/effects/ChannelDataSink.h @@ -0,0 +1,133 @@ +#pragma once + +#include "core/util/ScratchBuffer.h" +#include "core/util/TryLock.h" // staging, shared by a writer task and the render copy +#include "light/effects/EffectBase.h" +#include "light/layers/Layer.h" // the mapping a sender-mapped frame is placed through +#include "platform/platform.h" // yield, while a writer waits out the render copy + +#include <atomic> +#include <cstring> + +namespace mm { + +/// Where channel data lands, whatever carried it: Art-Net, E1.31 and DDP over a network, or an FSEQ file from flash or SD. +/// +/// A member of the effect that owns the transport, so Network Receive and the FSEQ player are thin effects on one copy of this logic. +/// +/// It holds the last frame, so the lights keep it between frames, and copies a frame only when it changed. +/// +/// A receiver-mapped frame is copied into the layer, which applies this board's wiring. A sender-mapped one alone on its layer goes to the output as it is. +/// +/// Beside another writer a sender-mapped frame is mapped back to the grid, and under a static modifier it is placed by the layout's positions. +class ChannelDataSink { +public: + /// What a frame costs on its way to the drivers, for a status to name. + enum Path : uint8_t { + kPathNone, ///< receiver mapping, which maps once as every layer does + kPathOneCopy, ///< sender mapping straight to the output, or through an identity mapping + kPathTwice, ///< sender mapping mapped back to the grid and forward again + }; + + /// Declared as a member of the effect it serves, which owns the memory. + explicit ChannelDataSink(EffectBase& owner) : owner_(owner) {} + + /// Size staging to the layer, by logical cell or, for a sender-mapped frame, by physical light; static modifiers add the layout placement. + void prepare(bool senderMapped) { + const Layer* layer = owner_.layer(); + senderMapped_ = senderMapped && layer; + const size_t lights = senderMapped_ ? layer->physicalLightCount() : owner_.nrOfLights(); + staging_.resize(lights * owner_.channelsPerLight()); + placement_.resize(senderMapped_ && layer->foldsStatically() ? owner_.nrOfLights() : 0); + if (placement_) layer->placeByLayout(placement_.data(), static_cast<nrOfLightsType>(placement_.count())); + dirty_.store(true, std::memory_order_release); // a resize, or a re-enable, paints the layer from staging once + } + + /// Whether a frame is in wiring order, which a layer showing only it hands to the output unmapped. + bool wiringOrder() const MM_NONBLOCKING { return senderMapped_; } + + /// The render tick: copy a changed frame into the layer, unless the layer hands it to the output as it is; returns the path it took. + Path tick() { + const Layer* layer = owner_.layer(); + if (!senderMapped_ || !layer) { + copyIntoLayer(layer, false); + return kPathNone; + } + if (layer->wiringSource() == &owner_) return kPathOneCopy; // Drivers copies it to the output + copyIntoLayer(layer, true); + return layer->lut().hasLUT() ? kPathTwice : kPathOneCopy; + } + + /// Copy staging to the output when a write changed it, or whole when asked; a write in progress waits for the next frame. + void copyWiringFrame(uint8_t* out, size_t bytes, bool force) { + if (!force && !dirty_.load(std::memory_order_acquire)) return; + LockGuard guard(lock_); + if (!guard) return; + dirty_.store(false, std::memory_order_release); + std::memcpy(out, staging_.data(), staging_.bytes() < bytes ? staging_.bytes() : bytes); + } + + /// The one clamped write into staging, bound-checked before any addition; `kWait` for a writer task, which waits out one render copy at most, else a busy lock drops the write. + template <bool kWait = true> + void write(size_t offset, const uint8_t* data, size_t len) { + if (!staging_ || offset >= staging_.bytes()) return; + const size_t n = offset + len > staging_.bytes() ? staging_.bytes() - offset : len; + if constexpr (kWait) { + while (!lock_.tryAcquire()) platform::yield(); + } else if (!lock_.tryAcquire()) { + return; + } + std::memcpy(staging_.data() + offset, data, n); + lock_.release(); + dirty_.store(true, std::memory_order_release); + } + + /// The held frame's bytes, for a test pinning the lifecycle. + const uint8_t* data() const { return staging_.data(); } + /// How many bytes staging holds. + size_t bytes() const { return staging_.bytes(); } + +private: + /// Copy staging into the layer when it changed or the layer was overwritten, through whichever placement the frame needs. + void copyIntoLayer(const Layer* layer, bool sender) { + uint8_t* buf = owner_.buffer(); + if (!buf || !staging_) return; + const uint32_t gen = layer ? layer->bufferGen() : 0; + if (!dirty_.load(std::memory_order_acquire) && layer && gen == lastGen_) return; + LockGuard guard(lock_); + if (!guard) return; // a write holds staging: copy on the next tick + dirty_.store(false, std::memory_order_release); + place(buf, layer, sender); + lastGen_ = gen + 1; // the Layer bumps the generation after this tick, for our own copy + } + + /// Staging into the layer buffer: by the layout's positions under static modifiers, through the mapping's inverse when sender-mapped, else as it is. + void place(uint8_t* buf, const Layer* layer, bool sender) { + const size_t bufBytes = static_cast<size_t>(owner_.nrOfLights()) * owner_.channelsPerLight(); + if (placement_) gatherPlaced(buf, bufBytes); + else if (sender) layer->lut().gatherFromPhysical(buf, bufBytes, staging_.data(), staging_.bytes(), owner_.channelsPerLight()); + else std::memcpy(buf, staging_.data(), staging_.bytes() < bufBytes ? staging_.bytes() : bufBytes); + } + + /// Each cell takes the light the layout places at its position; a cell with none keeps its value. + void gatherPlaced(uint8_t* buf, size_t bufBytes) { + const size_t cpl = owner_.channelsPerLight(); + const size_t physLights = staging_.bytes() / cpl; + const size_t cells = bufBytes / cpl < placement_.count() ? bufBytes / cpl : placement_.count(); + for (size_t cell = 0; cell < cells; cell++) { + const size_t src = placement_[cell]; + if (src >= physLights) continue; // kNoDriver, or past a shorter frame + MappingLUT::copyLight(buf + cell * cpl, staging_.data() + src * cpl, cpl); + } + } + + EffectBase& owner_; + ScratchBuffer<uint8_t> staging_{owner_}; ///< the held frame + ScratchBuffer<nrOfLightsType> placement_{owner_}; ///< each cell's physical light by layout position, only under static modifiers + TryLock lock_; ///< staging, between a writer's writes and the render copy + std::atomic<bool> dirty_{true}; ///< staging changed since the last copy + uint32_t lastGen_ = 0; ///< the layer's write generation after our last copy + bool senderMapped_ = false; ///< the frame is in wiring order, as prepare was told +}; + +} // namespace mm diff --git a/src/light/effects/EffectBase.h b/src/light/effects/EffectBase.h index d3c59cf3c..6f62851b6 100644 --- a/src/light/effects/EffectBase.h +++ b/src/light/effects/EffectBase.h @@ -50,6 +50,11 @@ class EffectBase : public MoonModule { /// Which axes the effect iterates, read at frame time: a hardcoded bound is a buffer overrun. virtual Dim dimensions() const { return Dim::D3; } + /// Whether the frame arrives in the order the lights are wired, so a layer showing it alone hands it to the output unmapped. + virtual bool deliversWiringOrder() const MM_NONBLOCKING { return false; } + /// Copy that wiring-order frame into the output, all of it when `force` says the output holds something else. + virtual void copyWiringFrame(uint8_t* /*out*/, size_t /*bytes*/, bool /*force*/) {} + /// The parent, always a Layer, defined in Layer.h once Layer is complete. Layer* layer() const; diff --git a/src/light/effects/FseqPlayerEffect.h b/src/light/effects/FseqPlayerEffect.h new file mode 100644 index 000000000..8b8e0cc99 --- /dev/null +++ b/src/light/effects/FseqPlayerEffect.h @@ -0,0 +1,54 @@ +#pragma once + +#include "light/effects/ChannelDataSink.h" // where the frames land, held and placed, as Network Receive's do +#include "light/effects/EffectBase.h" + +namespace mm { + +/// Effect: an FSEQ show played from a file, xLights' and Falcon Player's frame-by-frame channel data. +/// +/// FSEQ is mapped by the sender, so its frames land in the channel-data sink as a sender-mapped Network Receive stream does, one copy alone on its layer. +/// +/// It reads uncompressed v2 as FPP's FSEQ_Sequence_File_Format.txt lays it out, and `moondeck/run/fseq_play.py` writes reference files. +/// +/// It reads no file yet and says so: the reading lands in prepare, which opens the file and sizes the sink, and in tick, which writes the clock's frame. +class FseqPlayerEffect : public EffectBase { +public: + /// Catalog tags: a recorded show, played back. + const char* tags() const override { return "🎞️"; } + /// The show to play, a path on flash or SD. + char file[64] = ""; + /// Start again at the end. + bool loop = true; + /// The playback speed, in percent of the file's step time. + uint8_t speed = 100; + + /// Publish the file, looping and the speed. + void defineControls() override { + controls_.addText("file", file, sizeof(file)); // a path: the file picker opens a text editor, which a binary show has no use for + controls_.addControl("loop", loop); + controls_.addControl("speed", speed, 1, 255); + } + + /// A new file sizes the sink again. + bool affectsPrepare(const char* name) const override { return std::strcmp(name, "file") == 0; } + + /// Size the sink to the layer in wiring order, a frame of the file being one light per physical light. + void prepare() override { + sink_.prepare(/*senderMapped=*/true); + setStatus("reads no FSEQ file yet: playback is the next step"); + } + + /// Let the sink place the held frame. + void tick() MM_NONBLOCKING override { sink_.tick(); } + + /// An FSEQ frame is in wiring order. + bool deliversWiringOrder() const MM_NONBLOCKING override { return sink_.wiringOrder(); } + /// The sink hands its held frame to the output. + void copyWiringFrame(uint8_t* out, size_t bytes, bool force) override { sink_.copyWiringFrame(out, bytes, force); } + +private: + ChannelDataSink sink_{*this}; ///< where the frames land, held and placed +}; + +} // namespace mm diff --git a/src/light/effects/NetworkReceiveEffect.h b/src/light/effects/NetworkReceiveEffect.h index 22005d1e6..70e6529ab 100644 --- a/src/light/effects/NetworkReceiveEffect.h +++ b/src/light/effects/NetworkReceiveEffect.h @@ -1,13 +1,12 @@ #pragma once -#include "core/util/TryLock.h" // the staging buffer, shared by the receive task and the render copy #include "core/util/format.h" // formatTo: nonblocking formatting into a fixed buffer +#include "light/effects/ChannelDataSink.h" // staging, the held frame and its placement #include "light/effects/EffectBase.h" #include "light/util/ArtNetPacket.h" // shared ArtNet wire formats (build + parse) #include "light/util/DdpPacket.h" // shared DDP wire format #include "light/util/E131Packet.h" // shared E1.31/sACN wire format -#include "light/layers/Layer.h" // Layer::bufferGen: is the held frame still ours? #include "platform/platform.h" // platform::UdpSocket: the three receive sockets #include <atomic> @@ -32,13 +31,7 @@ namespace mm { /// /// ## Mapped by the receiver or by the sender /// -/// A receiver-mapped frame arrives row by row, and the layer applies this board's wiring; a sender-mapped one (xLights, FSEQ, Network Send) arrives in wiring order. -/// On an identity mapping that is one copy, at any number of layers. Any other mapping sends the frame back to the grid and forward again, 4.5 ms against 1.1 ms on an S3 at 12,288 lights. -/// -/// ## Held frames, drained by a task -/// -/// A frame is a burst of packets that lwIP holds in a small mailbox, so a task on core 1 drains them into staging as they arrive. -/// The render tick copies a changed frame into the layer, so the lights hold the last frame rather than strobe black. +/// A receiver-mapped frame arrives row by row and the layer applies this board's wiring; a sender-mapped one arrives in wiring order, placed by the channel-data sink. class NetworkReceiveEffect : public EffectBase { public: /// Catalog tags: network input, of MoonLight and v1 lineage. @@ -116,43 +109,25 @@ class NetworkReceiveEffect : public EffectBase { } else { setStatus(kBindFailMsg, Severity::Error); } - // One byte per channel byte, zero-filled so a fresh grid starts dark; a sender-mapped frame is indexed by physical light. - const auto* layer = static_cast<const Layer*>(parent()); - const size_t lights = mappedBy == kMappedBySender && layer ? layer->physicalLightCount() : nrOfLights(); - staging_.resize(lights * channelsPerLight()); - dirty_.store(true, std::memory_order_release); // a resize (or a re-enable) must repaint the layer from staging once + sink_.prepare(mappedBy == kMappedBySender); packets_.exchange(0, std::memory_order_relaxed); // the rates restart with the stream, not from boot frames_.exchange(0, std::memory_order_relaxed); rateSinceMs_ = platform::millis(); startReceiveTask(); } - /// Drain all three sockets, then copy staging to the layer when anything changed. @xref{held-frames-drained-by-a-task} + /// Drain the sockets where no task does, then let the sink place a changed frame, the status naming the path it took. void tick() MM_NONBLOCKING override { - if (!staging_) return; if (!receiveTask_.impl) drainSockets<false>(); // a board without tasks, the Teensy, drains here as packets wait - const auto* layer = static_cast<const Layer*>(parent()); - // A sender-mapped frame through a mapping that is not the identity is mapped back and forward again, which the status shows. - const bool sender = mappedBy == kMappedBySender && layer; - publishSender(sender ? (layer->lut().hasLUT() ? kPathTwice : kPathOneCopy) : kPathNone); - // An unchanged buffer needs no copy, so the layer's write generation decides. - uint8_t* buf = buffer(); - if (!buf) return; - const uint32_t gen = layer ? layer->bufferGen() : 0; - if (!dirty_.load(std::memory_order_acquire) && layer && gen == lastGen_) return; - LockGuard guard(stagingLock_); - if (!guard) return; // the task is writing a packet: copy on the next tick - dirty_.store(false, std::memory_order_release); - const size_t bufBytes = static_cast<size_t>(nrOfLights()) * channelsPerLight(); - if (sender) { - layer->lut().gatherFromPhysical(buf, bufBytes, staging_.data(), staging_.bytes(), channelsPerLight()); - } else { - std::memcpy(buf, staging_.data(), staging_.bytes() < bufBytes ? staging_.bytes() : bufBytes); - } - // The Layer bumps the generation after this tick, so record what our own copy produces. - lastGen_ = gen + 1; + publishSender(sink_.tick()); } + /// A sender-mapped frame is in wiring order. + bool deliversWiringOrder() const MM_NONBLOCKING override { return sink_.wiringOrder(); } + + /// The sink hands its held frame to the output. + void copyWiringFrame(uint8_t* out, size_t bytes, bool force) override { sink_.copyWiringFrame(out, bytes, force); } + /// Place one universe's payload, clamped to its stride and ignoring universes outside the buffer; `kWait` as for applyBytes. template <bool kWait = true> void applyDmx(uint16_t universe, const uint8_t* data, uint16_t len) { @@ -161,30 +136,17 @@ class NetworkReceiveEffect : public EffectBase { applyBytes<kWait>(static_cast<size_t>(universe - universeStart) * channelsPerUniverse, data, len); } - /// The one clamped write into staging, bound-checked before any addition against an overflow. + /// Write a payload into the sink at its byte offset; the task waits out one render copy, the tick drains only where no task runs. template <bool kWait = true> - void applyBytes(size_t offset, const uint8_t* data, uint16_t len) { - if (!staging_ || offset >= staging_.bytes()) return; - size_t n = len; - if (offset + n > staging_.bytes()) n = staging_.bytes() - offset; - // The task waits out the render copy, one frame at most; the tick drains only where no task runs, so it never finds the lock taken. - if constexpr (kWait) { - while (!stagingLock_.tryAcquire()) platform::yield(); - } else if (!stagingLock_.tryAcquire()) { - return; - } - std::memcpy(staging_.data() + offset, data, n); - stagingLock_.release(); - dirty_.store(true, std::memory_order_release); // the one write into staging, which is what tick() copies out - } + void applyBytes(size_t offset, const uint8_t* data, uint16_t len) { sink_.write<kWait>(offset, data, len); } /// Whether a receive task drains the sockets, rather than the render tick: the test seam for the fallback. bool receivesInTask() const { return receiveTask_.impl != nullptr; } /// Test seam: the staging bytes, so a test can pin the buffer's lifecycle. - const uint8_t* stagingData() const { return staging_.data(); } + const uint8_t* stagingData() const { return sink_.data(); } /// Test seam: how many staging bytes are allocated. - size_t stagingBytes() const { return staging_.bytes(); } + size_t stagingBytes() const { return sink_.bytes(); } /// Note the last sender, per packet from the receive path; a test calls it to stand in for one. void recordSender(const char* proto, const uint8_t ip[4]) { @@ -201,10 +163,7 @@ class NetworkReceiveEffect : public EffectBase { char recvStatus_[56] = ""; ///< "receiving <protocol> from <ip>, <path>", sized for the longest form const char* lastProto_ = nullptr; ///< the last protocol literal the status was built from uint8_t lastPath_ = 0; ///< the last path the status was built from - /// What a frame costs on its way to the drivers: no note for receiver mapping, which maps once as every layer does. - static constexpr uint8_t kPathNone = 0; - static constexpr uint8_t kPathOneCopy = 1; ///< sender mapping through an identity mapping - static constexpr uint8_t kPathTwice = 2; ///< sender mapping mapped back to the grid and forward again + /// The status note for each of the sink's paths: none for receiver mapping, which maps once as every layer does. static constexpr const char* kPathNotes[3] = {"", ", one copy", ", mapped twice"}; uint8_t lastIp_[4] = {}; ///< the last sender IP the status was built from @@ -212,13 +171,10 @@ class NetworkReceiveEffect : public EffectBase { platform::UdpSocket e131Socket_; ///< E1.31, port 5568 platform::UdpSocket ddpSocket_; ///< DDP, port 4048 uint8_t pkt_[1500] = {}; ///< one datagram of any protocol, DDP reaching 1450 - ScratchBuffer<uint8_t> staging_{*this}; ///< the held frame, sized to the layer - /// Staging changed since the last copy, starting true so a fresh buffer paints once. - std::atomic<bool> dirty_{true}; + ChannelDataSink sink_{*this}; ///< where the frames land, held and placed std::atomic<uint32_t> packets_{0}; ///< packets since the last tick1s, counted by the receive path std::atomic<uint32_t> frames_{0}; ///< frames since the last tick1s, counted by the receive path uint32_t rateSinceMs_ = 0; ///< when the counts last restarted, which the rates divide by - TryLock stagingLock_; ///< staging, between the receive task's writes and the render copy platform::WorkerTask receiveTask_; ///< drains the sockets as packets arrive, on core 1 std::atomic<bool> receiveStop_{false}; std::atomic<const char*> senderProto_{nullptr}; ///< the last sender's protocol, which the render tick publishes @@ -234,7 +190,7 @@ class NetworkReceiveEffect : public EffectBase { } } - // Core 1, away from the render loop; a single-core chip runs it unpinned, and a board without tasks drains in the tick. + // lwIP holds a frame's burst in a small mailbox, so a task on core 1 drains it as it arrives; a board without tasks drains in the tick. void startReceiveTask() { receiveStop_.store(false, std::memory_order_release); platform::spawnPinnedTask(receiveTask_, "mmReceive", &receiveTrampoline, this, 4096, 3, 1); @@ -295,7 +251,7 @@ class NetworkReceiveEffect : public EffectBase { static uint16_t saturate16(uint32_t v) { return v > 0xFFFF ? 0xFFFF : static_cast<uint16_t>(v); } /// Show the last sender in the status, from the render side, which owns the status. - void publishSender(uint8_t path) { + void publishSender(ChannelDataSink::Path path) { const char* proto = senderProto_.load(std::memory_order_acquire); if (!proto) return; const uint32_t v = senderIp_.load(std::memory_order_relaxed); @@ -303,8 +259,6 @@ class NetworkReceiveEffect : public EffectBase { static_cast<uint8_t>(v >> 8), static_cast<uint8_t>(v)}; noteReceiving(proto, ip, path); } - /// The layer's write generation as of our last copy, which is what makes skipping it safe. - uint32_t lastGen_ = 0; /// Update the receiving diagnostic, short-circuiting the common case since this is the hot path. void noteReceiving(const char* proto, const uint8_t ip[4], uint8_t path) { diff --git a/src/light/effects/PictureEffect.h b/src/light/effects/PictureEffect.h new file mode 100644 index 000000000..0c8391639 --- /dev/null +++ b/src/light/effects/PictureEffect.h @@ -0,0 +1,164 @@ +#pragma once + +#include "light/effects/EffectBase.h" +#include "core/services/PictureService.h" // the picture it samples, as audio effects include AudioService + +namespace mm { + +/// Effect: the active Picture service's picture, sampled onto the layer, so a picture of any size shows on a layout of any size. +/// +/// Geometry stays with the modifiers: a rotate, mirror or tile transforms the picture as it does any effect. A cube shows it on every slice. +/// +/// A cell's source rectangle is a column range times a row range, so two small tables sized at prepare hold them, refilled without allocating. +/// +/// Area sampling averages in linear light, squaring each value and taking the root of the mean, gamma 2 standing in for the display curve. +class PictureEffect : public EffectBase { +public: + /// Catalog tags for the visual catalog. + const char* tags() const override { return "🖼️"; } + /// A picture is a plane, and a cube takes it extruded. + Dim dimensions() const override { return Dim::D2; } + + /// The ways `fit` places the picture, in option order. + enum Fit : uint8_t { kFit, kFill, kStretch, kFitCount }; + /// Keep the picture's aspect with bands or cropped, or distort it to the grid. + uint8_t fit = kFit; + /// The ways `sampling` reads a cell's part of the picture, in option order. + enum Sampling : uint8_t { kPoint, kArea, kSamplingCount }; + /// One pixel per cell, or the average of the cell's rectangle. + uint8_t sampling = kPoint; + + /// Publish the placement and the sampling. + void defineControls() override { + static constexpr const char* kFits[] = {"fit", "fill", "stretch"}; + static constexpr const char* kSamplings[] = {"point", "area"}; + controls_.addSelect("fit", fit, kFits, kFitCount); + controls_.addSelect("sampling", sampling, kSamplings, kSamplingCount); + } + /// The placement changes the ranges, so it refills them. + bool affectsPrepare(const char* name) const override { return std::strcmp(name, "fit") == 0; } + + /// Size the range tables to the grid; the first picture fills them. + void prepare() override { + cols_.resize(width()); + rows_.resize(height()); + picW_ = picH_ = 0; // stale, so the next tick refills and draws + } + + /// Sample a picture that is new, or redraw one the layer overwrote; with nothing to sample the layer is cleared once. + void tick() MM_NONBLOCKING override { + const PictureService::Reader reader; + if (!reader) return; // the source is swapping or filling its only picture: sample on the next tick + const PictureFrame* f = &reader.frame(); + const Layer* l = layer(); + const uint32_t layerGen = l ? l->bufferGen() : 0; + if (!f->rgb || !cols_ || !rows_) { + setStatus("no picture: add a Picture service under Services"); + // An effect owns its background, so a missing picture shows as black rather than the last frame. + if (lastGen_ != kNoPicture || layerGen != lastLayerGen_) draw::fill(canvas(), RGB{0, 0, 0}); + lastGen_ = kNoPicture; + lastLayerGen_ = layerGen + 1; + picW_ = picH_ = 0; // a picture arriving later refills the ranges + return; + } + if (status()) clearStatus(); + bool redraw = false; + if (f->width != picW_ || f->height != picH_) { + fillRanges(cols_.data(), static_cast<uint16_t>(cols_.count()), f->width, f->height, true); + fillRanges(rows_.data(), static_cast<uint16_t>(rows_.count()), f->width, f->height, false); + picW_ = f->width; + picH_ = f->height; + redraw = true; + } + if (!redraw && f->generation == lastGen_ && layerGen == lastLayerGen_) return; + gather(*f); + lastGen_ = f->generation; + lastLayerGen_ = layerGen + 1; // the Layer bumps the generation after this tick, for our own write + } + +private: + /// One axis of a cell's source rectangle, [lo, hi) in picture pixels; empty outside a fitted picture. + struct Range { uint16_t lo, hi; }; + + /// Each cell's range along one axis: the grid's `n` cells over the picture's `picW` × `picH`, `columns` naming the axis. + void fillRanges(Range* out, uint16_t n, uint16_t picW, uint16_t picH, bool columns) const { + const uint32_t gw = cols_.count(), gh = rows_.count(); + uint32_t usedW = gw, usedH = gh; + spannedCells(picW, picH, usedW, usedH); + const int32_t used = static_cast<int32_t>(columns ? usedW : usedH); + const uint32_t pic = columns ? picW : picH; + const int32_t offset = (static_cast<int32_t>(columns ? gw : gh) - used) / 2; // negative when cropped + for (uint32_t i = 0; i < n; i++) { + const int32_t k = static_cast<int32_t>(i) - offset; + if (k < 0 || k >= used) { out[i] = {0, 0}; continue; } + uint32_t lo = static_cast<uint32_t>(k) * pic / static_cast<uint32_t>(used), hi = (static_cast<uint32_t>(k) + 1) * pic / static_cast<uint32_t>(used); + if (hi <= lo) hi = lo + 1; // a picture smaller than the grid: neighboring cells share a pixel + out[i] = {static_cast<uint16_t>(lo), static_cast<uint16_t>(hi)}; + } + } + + /// The cells the picture spans: the grid itself when stretched, else its aspect sets one side, smaller than the grid with bands or larger and cropped. + void spannedCells(uint16_t picW, uint16_t picH, uint32_t& usedW, uint32_t& usedH) const { + if (fit == kStretch || !picW || !picH) return; + const bool wider = static_cast<uint32_t>(picW) * usedH >= static_cast<uint32_t>(picH) * usedW; + const uint32_t scaledH = (static_cast<uint32_t>(picH) * usedW + picW / 2) / picW; // its height at the grid's width + const uint32_t scaledW = (static_cast<uint32_t>(picW) * usedH + picH / 2) / picH; // its width at the grid's height + if (wider == (fit == kFit)) usedH = scaledH ? scaledH : 1; + else usedW = scaledW ? scaledW : 1; + } + + /// One pass over the grid, each cell from its rectangle. + void gather(const PictureFrame& f) { + const draw::Canvas cv = canvas(); + const uint32_t gw = cols_.count(), gh = rows_.count(); + for (uint32_t y = 0; y < gh; y++) { + const Range r = rows_[y]; + for (uint32_t x = 0; x < gw; x++) { + const Range c = cols_[x]; + const size_t cell = static_cast<size_t>(y) * gw + x; + if (r.hi == r.lo || c.hi == c.lo) { draw::pixel(cv, cell, RGB{0, 0, 0}); continue; } + draw::pixel(cv, cell, sampling == kArea ? average(f, c, r) : at(f, (c.lo + c.hi - 1) / 2, (r.lo + r.hi - 1) / 2)); + } + } + } + + /// The pixel at (x, y). + static RGB at(const PictureFrame& f, uint32_t x, uint32_t y) { + const uint8_t* p = f.rgb + (static_cast<size_t>(y) * f.width + x) * 3; + return RGB{p[0], p[1], p[2]}; + } + + /// The rectangle's mean in linear light, from at most four reads per axis. + static RGB average(const PictureFrame& f, Range c, Range r) { + const uint32_t sx = (c.hi - c.lo + 3u) / 4u, sy = (r.hi - r.lo + 3u) / 4u; + uint32_t sum[3] = {0, 0, 0}, n = 0; + for (uint32_t y = r.lo; y < r.hi; y += sy) + for (uint32_t x = c.lo; x < c.hi; x += sx) { + const RGB p = at(f, x, y); + sum[0] += static_cast<uint32_t>(p.r) * p.r; + sum[1] += static_cast<uint32_t>(p.g) * p.g; + sum[2] += static_cast<uint32_t>(p.b) * p.b; + n++; + } + return RGB{isqrt(sum[0] / n), isqrt(sum[1] / n), isqrt(sum[2] / n)}; + } + + /// The integer square root of a value below 65,536, the bitwise method. + static uint8_t isqrt(uint32_t v) { + uint32_t root = 0; + for (uint32_t bit = 1u << 14; bit; bit >>= 2) { + if (v >= root + bit) { v -= root + bit; root = (root >> 1) + bit; } + else root >>= 1; + } + return static_cast<uint8_t>(root); + } + + ScratchBuffer<Range> cols_{*this}; ///< each grid column's picture columns + ScratchBuffer<Range> rows_{*this}; ///< each grid row's picture rows + uint16_t picW_ = 0, picH_ = 0; ///< the picture size the tables were filled for + static constexpr uint32_t kNoPicture = UINT32_MAX; ///< what lastGen_ holds after clearing for a missing picture + uint32_t lastGen_ = 0; ///< the picture last sampled + uint32_t lastLayerGen_ = 0; ///< the layer's write generation after that sample +}; + +} // namespace mm diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index f99e2e1c1..42589bf89 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -310,6 +310,53 @@ class Layer : public MoonModule { /// The physical box depth. lengthType physicalDepth() const { return physicalDepth_; } + /// The effect whose wiring-order frame goes to the output unmapped, or null: the sole enabled layer, mapped, no enabled modifier, one enabled effect that delivers wiring order. + EffectBase* wiringSource() const { + if (!lut_.hasLUT()) return nullptr; // an identity mapping is already one copy + EffectBase* source = soleEffect(); + return source && source->deliversWiringOrder() && soleLayer() ? source : nullptr; + } + + /// The one enabled effect, or null when there is none, there are two sharing the buffer, or a modifier transforms the frame. + EffectBase* soleEffect() const { + EffectBase* source = nullptr; + for (uint8_t i = 0; i < childCount(); i++) { + MoonModule* c = child(i); + if (!c->enabled() || (c->role() != ModuleRole::Modifier && c->role() != ModuleRole::Effect)) continue; + if (c->role() == ModuleRole::Modifier || source) return nullptr; + source = static_cast<EffectBase*>(c); + } + return source; + } + + /// Whether this is the only enabled layer, since a stack blends logical buffers. + bool soleLayer() const { + const MoonModule* p = parent(); + if (!p) return true; + uint8_t layers = 0; + for (uint8_t i = 0; i < p->childCount(); i++) + if (p->child(i)->role() == ModuleRole::Layer && p->child(i)->enabled()) layers++; + return layers == 1; + } + + /// Whether static modifiers fold the box into the mapping, so its inverse would undo them. + bool foldsStatically() const { return staticCount_ > 0; } + + /// Each logical cell's physical light by the layout's own positions, `kNoDriver` where none: a wiring-order frame placed this way lands before the static modifiers, which then transform it. + void placeByLayout(nrOfLightsType* out, nrOfLightsType cells) const { + for (nrOfLightsType c = 0; c < cells; c++) out[c] = kNoDriver; + if (!layouts_) return; + struct Ctx { nrOfLightsType* out; nrOfLightsType cells; lengthType w, h, d; }; + Ctx ctx{out, cells, width_, height_, depth_}; + // A gap stays dark, so its slot is not placed; a position outside the folded box has no cell. + layouts_->placeLights(CoordSink{[](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) { + auto* k = static_cast<Ctx*>(c); + if (x >= k->w || y >= k->h || z >= k->d) return; + const nrOfLightsType cell = static_cast<nrOfLightsType>(z) * k->w * k->h + static_cast<nrOfLightsType>(y) * k->w + x; + if (cell < k->cells && k->out[cell] == kNoDriver) k->out[cell] = idx; + }, [](void*, nrOfLightsType, lengthType, lengthType, lengthType) {}, &ctx}); + } + /// Whether a mapping was wanted but could not be built, so the layer degraded to identity. bool lutSkipped() const { return lutSkipped_; } @@ -320,7 +367,7 @@ class Layer : public MoonModule { clearStatus(); // re-evaluated below if a degrade path is taken // Each modifier stashes its output size, so the per-light fold reads the box at its stage. - uint8_t staticCount = 0; + staticCount_ = 0; hasLive_ = false; Coord3D box{physicalWidth_, physicalHeight_, physicalDepth_}; for (uint8_t i = 0; i < childCount(); i++) { @@ -329,7 +376,7 @@ class Layer : public MoonModule { if (m->hasModifyLive()) { hasLive_ = true; continue; } // dynamic: per-frame, not baked m->modifyLogicalSize(box); clampLogical(box); - staticCount++; + staticCount_++; } // Final logical box = the running box after the last static modifier. @@ -345,7 +392,7 @@ class Layer : public MoonModule { const bool anyGap = layouts_ && layouts_->hasBlackPixels(); // The frame-rate floor for the common case: box cell i is driver light i. Keep it first. - if (staticCount == 0 && dense && !anyGap && isNaturalOrder()) { + if (staticCount_ == 0 && dense && !anyGap && isNaturalOrder()) { lut_.setIdentity(boxCount); allocateBuffer(boxCount); return; @@ -514,6 +561,7 @@ class Layer : public MoonModule { uint32_t bufferGen_ = 0; // bumped by every write to buffer_; see bufferGen() char statusBuf_[20] = {}; // "999×999×999" fits; owned (setStatus borrows the pointer) bool hasLive_ = false; // any enabled modifier animates per frame (gates the live pass) + uint8_t staticCount_ = 0; // enabled modifiers folded into the mapping at build time uint8_t* liveScratch_ = nullptr; // snapshot for the live pass; allocated only when hasLive_ size_t liveScratchBytes_ = 0; diff --git a/src/light/layers/MappingLUT.h b/src/light/layers/MappingLUT.h index 74e48542f..f8e6e729f 100644 --- a/src/light/layers/MappingLUT.h +++ b/src/light/layers/MappingLUT.h @@ -192,13 +192,19 @@ class MappingLUT { if (here == start) continue; // a cell no light shows keeps its value const size_t first = dest(here); if (first >= physLights) continue; - const uint8_t* s = physical + first * cpl; - uint8_t* d = logical + li * cpl; - if (cpl == 3) { d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; } - else std::memcpy(d, s, cpl); + copyLight(logical + li * cpl, physical + first * cpl, cpl); } } +public: + /// One light's channels, the common three unrolled since it runs per light on a gather. + static void copyLight(uint8_t* d, const uint8_t* s, size_t cpl) MM_NONBLOCKING { + if (cpl == 3) { d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; } + else std::memcpy(d, s, cpl); + } + +private: + // Tier the destinations allocation, false only on tier 3. @xref{paged-destinations} bool allocateDestinations(size_t destBytes, nrOfLightsType maxDestinations) { if (destBytes == 0) { paged_ = false; return true; } // nothing to map diff --git a/src/module_types.cpp b/src/module_types.cpp index bccb04f55..37ea1823c 100644 --- a/src/module_types.cpp +++ b/src/module_types.cpp @@ -63,6 +63,8 @@ #include "light/effects/FixedPointEffect.h" #include "light/effects/MovingHeadEffect.h" #include "light/effects/PacmanEffect.h" +#include "light/effects/FseqPlayerEffect.h" +#include "light/effects/PictureEffect.h" #include "light/effects/PlasmaEffect.h" #include "light/effects/PulseEffect.h" #include "light/effects/MetaballsEffect.h" @@ -175,6 +177,7 @@ #include "core/system/ControlModule.h" #include "core/services/Services.h" #include "core/services/AudioService.h" +#include "core/services/PictureService.h" #include "core/services/OscModule.h" #include "core/system/I2cBusModule.h" #include "core/system/TasksModule.h" @@ -261,6 +264,8 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType<mm::NoiseMeterEffect>("NoiseMeterEffect", "light/effects.md#noisemeter"); mm::ModuleFactory::registerType<mm::PaintBrushEffect>("PaintBrushEffect", "light/effects.md#paintbrush"); mm::ModuleFactory::registerType<mm::ParticlesEffect>("ParticlesEffect", "light/effects.md#particles"); + mm::ModuleFactory::registerType<mm::FseqPlayerEffect>("FseqPlayerEffect", "light/effects.md#fseqplayer"); + mm::ModuleFactory::registerType<mm::PictureEffect>("PictureEffect", "light/effects.md#picture"); mm::ModuleFactory::registerType<mm::PlasmaEffect>("PlasmaEffect", "light/effects.md#plasma"); mm::ModuleFactory::registerType<mm::PraxisEffect>("PraxisEffect", "light/effects.md#praxis"); mm::ModuleFactory::registerType<mm::PulseEffect>("PulseEffect", "light/effects.md#pulse"); @@ -355,6 +360,7 @@ void mm::registerModuleTypes() { mm::ModuleFactory::registerType<mm::ControlModule>("ControlModule", "core/system.md#control"); mm::ModuleFactory::registerType<mm::Services>("Services", "core/services.md#services"); mm::ModuleFactory::registerType<mm::AudioService>("AudioService", "core/services.md#audio"); + mm::ModuleFactory::registerType<mm::PictureService>("PictureService", "core/services.md#picture"); mm::ModuleFactory::registerType<mm::OscModule>("OscModule", "core/services.md#osc"); mm::ModuleFactory::registerType<mm::I2cBusModule>("I2cBusModule", "core/system.md#i2c-bus"); mm::ModuleFactory::registerType<mm::TasksModule>("TasksModule", "core/system.md#tasks"); diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index b0b2bcbe2..0dbd7965e 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1793,6 +1793,9 @@ bool wifiStaInit(const char* ssid, const char* /*password*/) { static uint8_t testStaIp_[4] = {}; void setTestWifiStaIPv4(const uint8_t* ip) { for (int i = 0; i < 4; i++) testStaIp_[i] = ip ? ip[i] : 0; } bool wifiStaConnected() MM_NONBLOCKING { return testStaIp_[0] || testStaIp_[1] || testStaIp_[2] || testStaIp_[3]; } +static std::atomic<bool> testStaAssociated_{false}; +void setTestWifiStaAssociated(bool associated) { testStaAssociated_.store(associated, std::memory_order_relaxed); } +bool wifiStaAssociated() MM_NONBLOCKING { return testStaAssociated_.load(std::memory_order_relaxed) || wifiStaConnected(); } void wifiStaGetIPv4(uint8_t out[4]) MM_NONBLOCKING { for (int i = 0; i < 4; i++) out[i] = testStaIp_[i]; } // Addressing is managed by the system here, so the setters reach only the test seams: a per-interface counter, and the faked cable's address. static std::atomic<uint32_t> testStaticApplies[2] = {}; // indexed by NetIface diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index e9ed36769..fb9e92f79 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -1355,6 +1355,10 @@ bool wifiStaConnected() MM_NONBLOCKING { return wifiStaConnected_.load(std::memory_order_relaxed); } +bool wifiStaAssociated() MM_NONBLOCKING { + return wifiStaAssociated_.load(std::memory_order_relaxed); +} + void wifiStaGetIPv4(uint8_t out[4]) MM_NONBLOCKING { netifIPv4(staNetif_, out); } @@ -1551,6 +1555,7 @@ bool wifiSetTxPower(int8_t quarterDbm) { // Stubs so the linker is satisfied: platform.h declares these and NetworkModule's discarded `if constexpr (hasWiFi)` branch still ODR-uses them. With hasWiFi false the calls are not generated, so --gc-sections drops the stubs. bool wifiStaInit(const char* /*ssid*/, const char* /*password*/) { return false; } bool wifiStaConnected() MM_NONBLOCKING { return false; } +bool wifiStaAssociated() MM_NONBLOCKING { return false; } void wifiStaGetIPv4(uint8_t out[4]) MM_NONBLOCKING { out[0] = out[1] = out[2] = out[3] = 0; } void wifiStaStop() {} int wifiStaRssi() { return 0; } diff --git a/src/platform/esp32/platform_esp32_es8311.cpp b/src/platform/esp32/platform_esp32_es8311.cpp index 3666653a6..57c772792 100644 --- a/src/platform/esp32/platform_esp32_es8311.cpp +++ b/src/platform/esp32/platform_esp32_es8311.cpp @@ -8,7 +8,7 @@ /// ## The domain code is unchanged /// /// It calls the codec init, a no-op on a board with a direct microphone, before the microphone init, and reads samples as always. -/// The driver is the vendor's own managed component, built for the chips whose boards carry one. +/// The driver is the vendor's own managed component, an ES8311 or an ES7210, built into the images whose boards carry one. /// The codec is a device on the board's I2C bus, which platform_esp32_i2c.cpp opens and owns. /// Everything else gets an inert stub that reports success, having nothing to bring up, so the one call works everywhere. @@ -17,12 +17,12 @@ #include "sdkconfig.h" #include "soc/soc_caps.h" -// esp_codec_dev is pulled for the S31 and the P4 (idf_component.yml rule), so the implementation is gated on its presence and the file compiles on every other target. -#if SOC_I2S_SUPPORTED && __has_include("esp_codec_dev.h") -#define MM_HAS_ES8311 1 +// esp_codec_dev is pulled where MM_AUDIO_CODEC is set (idf_component.yml rule), so the implementation is gated on it and the file compiles in every other image. +#if SOC_I2S_SUPPORTED && CONFIG_MM_AUDIO_CODEC +#define MM_HAS_AUDIO_CODEC 1 #endif -#if MM_HAS_ES8311 +#if MM_HAS_AUDIO_CODEC #include "driver/i2c_master.h" #include "esp_codec_dev.h" @@ -35,7 +35,7 @@ namespace mm::platform { namespace { -const char* ES_TAG = "mm_es8311"; +const char* ES_TAG = "mm_codec"; // The codec device and its interfaces, kept alive between init and deinit: once opened it keeps streaming, and the I2S read drains it. struct CodecState { @@ -68,9 +68,30 @@ void deinitState(CodecState* st) { i2c_master_bus_handle_t i2cBusHandle(); // platform_esp32_i2c.cpp: the board's bus, which the codec attaches to +namespace { +// The codec's own part, set up for a microphone: MCLK at 256 times the sample rate, the ratio both parts key their tables on. +const audio_codec_if_t* newCodec(CodecType type, const audio_codec_ctrl_if_t* ctrl) { + if (type == CodecType::Es8311) { + es8311_codec_cfg_t cfg = {}; + cfg.ctrl_if = ctrl; + cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_ADC; // record / mic only + cfg.use_mclk = true; // the microphone's mclkPin clocks the codec + cfg.mclk_div = 256; + cfg.pa_pin = -1; // mic path needs no power amp + return es8311_codec_new(&cfg); + } + // The first microphone only, in the left slot the mono read takes; a board's second one serves beamforming, which the spectrum does not use. + es7210_codec_cfg_t cfg = {}; + cfg.ctrl_if = ctrl; + cfg.mic_selected = ES7210_SEL_MIC1; + cfg.mclk_div = 256; + return es7210_codec_new(&cfg); +} +} // namespace + bool audioCodecInit(CodecType type, uint8_t i2cAddr, uint32_t sampleRate) { if (type == CodecType::None) return true; // direct-mic board: nothing to do - if (type != CodecType::Es8311) return false; // unknown codec for this build + if (type != CodecType::Es8311 && type != CodecType::Es7210) return false; // unknown codec for this build if (!i2cBusHandle()) { ESP_LOGE(ES_TAG, "no I2C bus open for the codec"); return false; } audioCodecDeinit(); // idempotent: a re-init (pin/rate change) rebuilds cleanly @@ -86,15 +107,8 @@ bool audioCodecInit(CodecType type, uint8_t i2cAddr, uint32_t sampleRate) { st->ctrl = audio_codec_new_i2c_ctrl(&i2cCtrlCfg); if (!st->ctrl) { ESP_LOGE(ES_TAG, "codec i2c ctrl failed"); deinitState(st); return false; } - es8311_codec_cfg_t es8311Cfg = {}; - es8311Cfg.ctrl_if = st->ctrl; - es8311Cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_ADC; // record / mic only - es8311Cfg.use_mclk = true; // the microphone's mclkPin clocks the codec - es8311Cfg.mclk_div = 256; // MCLK = 256 * sample_rate (the standard - // I2S ratio; the codec's coeff table is keyed on it, 0 fails "configure rate"). - es8311Cfg.pa_pin = -1; // mic path needs no power amp - st->codec = es8311_codec_new(&es8311Cfg); - if (!st->codec) { ESP_LOGE(ES_TAG, "es8311_codec_new failed"); deinitState(st); return false; } + st->codec = newCodec(type, st->ctrl); + if (!st->codec) { ESP_LOGE(ES_TAG, "codec_new failed"); deinitState(st); return false; } esp_codec_dev_cfg_t devCfg = {}; devCfg.codec_if = st->codec; @@ -124,7 +138,7 @@ void audioCodecDeinit() { } // namespace mm::platform -#else // !MM_HAS_ES8311 — no codec on this target: inert stub. +#else // !MM_HAS_AUDIO_CODEC: no codec in this image, an inert stub. #include <new> @@ -136,4 +150,4 @@ bool audioCodecInit(CodecType type, uint8_t, uint32_t) { void audioCodecDeinit() {} } // namespace mm::platform -#endif // MM_HAS_ES8311 +#endif // MM_HAS_AUDIO_CODEC diff --git a/src/platform/esp32/platform_esp32_improv.cpp b/src/platform/esp32/platform_esp32_improv.cpp index 9df4efd1d..61670c2bd 100644 --- a/src/platform/esp32/platform_esp32_improv.cpp +++ b/src/platform/esp32/platform_esp32_improv.cpp @@ -15,7 +15,8 @@ /// /// Several chips have a built-in USB peripheral exposing a serial endpoint with no bridge chip, and many cheap boards wire the socket to it rather than to the hardware port. /// So the bytes arrive on one or the other, and both are listened on, which makes one firmware work on either kind of board. -/// The install is soft-failing: when the secondary console grabbed the peripheral first, that path is skipped and the hardware port carries on alone. +/// Installing the USB driver takes the peripheral from the console's direct writes, so the console is switched to the driver too, as IDF's own console setup does. +/// Log text and Improv frames then share the port, each frame carrying its own header; a failed install leaves the hardware port carrying on alone. /// /// One parser per transport, each keeping its own framing state and buffer. /// A shared one would let a partial frame on one side be corrupted by bytes arriving on the other, its state machine not knowing they came from different sources. @@ -43,6 +44,7 @@ #include "core/util/ImprovFrame.h" #include "core/util/ImprovOpReassembler.h" +#include "core/util/JoinWindow.h" #include "driver/uart.h" #include "esp_log.h" @@ -57,6 +59,7 @@ // The built-in USB peripheral, which many boards wire their socket to instead of the hardware port: @xref{two-transports-because-the-port-may-be-either|why both are listened on}. #if SOC_USB_SERIAL_JTAG_SUPPORTED #include "driver/usb_serial_jtag.h" +#include "driver/usb_serial_jtag_vfs.h" // the console's writes through the driver, once it owns the port #endif #include <atomic> @@ -207,18 +210,20 @@ static void improvHandleProvision(const improv::ImprovCommand& cmd) { g_improv.ready->store(true, std::memory_order_release); improvSendCurrentState(improv::STATE_PROVISIONING); - // Wait up to 30 s for a usable IP. Polls existing platform state, no extra wiring. Loop until the lease actually lands (a non-zero address), not merely until association: DHCP completes shortly after WIFI_EVENT_STA_CONNECTED, so breaking on wifiStaConnected() alone could read 0.0.0.0. + // Wait for a usable address over the station's own window, so a wrong password or a missing network is answered when the cascade gives up, inside the installer's 30 s. The module starts the join on its next one-second tick and checks once a second, which the 2 s covers. uint8_t ip[4] = {}; - for (int i = 0; i < 300; i++) { // 30 s @ 100 ms + bool associated = false; + for (uint32_t waited = 0; waited < mm::staWindowMs(associated) + 2000; waited += 100) { vTaskDelay(pdMS_TO_TICKS(100)); + associated = wifiStaAssociated(); if (wifiStaConnected()) { wifiStaGetIPv4(ip); - if (ip[0] || ip[1] || ip[2] || ip[3]) break; // have a real address + if (ip[0] || ip[1] || ip[2] || ip[3]) break; // the lease landed, not merely the association } } - // No usable lease (never associated, or associated but DHCP never completed) is a failure, same as the timeout, http://0.0.0.0/ would be worse than an honest error. + // No usable lease is a failure: http://0.0.0.0/ would be worse than an honest error. if (!ip[0] && !ip[1] && !ip[2] && !ip[3]) { - improvSetStatus("error: no IP after 30s"); + improvSetStatus(associated ? "error: joined but no address" : "error: could not join: wrong password or network not found"); improvSendError(improv::ERROR_UNABLE_TO_CONNECT); return; } @@ -371,6 +376,7 @@ static void improvTask(void* /*arg*/) { usb_serial_jtag_driver_config_t jtag_cfg = USB_SERIAL_JTAG_DRIVER_CONFIG_DEFAULT(); esp_err_t jtag_err = usb_serial_jtag_driver_install(&jtag_cfg); if (jtag_err == ESP_OK) { + usb_serial_jtag_vfs_use_driver(); // or the log's direct FIFO writes collide with the driver and the port goes silent g_jtagReady = true; } else { ESP_LOGW(IMPROV_TAG, "usb_serial_jtag_install failed: %s", diff --git a/src/platform/platform.h b/src/platform/platform.h index 3e2ead9af..e87d80e7a 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -620,6 +620,8 @@ uint32_t ethRestartCountForTest(); bool wifiStaInit(const char* ssid, const char* password); /// Whether the station is associated and holds an IP. bool wifiStaConnected() MM_NONBLOCKING; +/// Whether the station has joined its network, with or without an address yet: WiFi's counterpart of ethLinkUp. +bool wifiStaAssociated() MM_NONBLOCKING; /// The station's IP as raw octets, on ethGetIPv4's contract. void wifiStaGetIPv4(uint8_t out[4]) MM_NONBLOCKING; /// This device's IP from whichever interface is up, Ethernet first, all-zero before either is. @@ -684,6 +686,8 @@ void netGetIPv4(NetIface iface, uint8_t ip[4], uint8_t gw[4], uint8_t mask[4], u #ifndef ESP_PLATFORM // a host-test seam, which no ESP32 code calls /// Make wifiStaInit succeed, so a host test can drive the station cascade a radio-less desktop never enters. void setTestWifiStaAvailable(bool available); +/// Make the station joined to its network while it waits for an address, for a host test. +void setTestWifiStaAssociated(bool associated); #endif #ifndef ESP_PLATFORM // a host-test seam, which no ESP32 code calls /// The network the station was last asked to join, so a host test sees the order the cascade tries them in. @@ -1313,7 +1317,7 @@ void hub75Deinit(Hub75Handle& h); // I2S audio input: two seams only, the read and the FFT, with everything between them domain code. /// Which codec sits in front of the microphone, none for a microphone that speaks I2S itself. -enum class CodecType : uint8_t { None = 0, Es8311 = 1 }; +enum class CodecType : uint8_t { None = 0, Es8311 = 1, Es7210 = 2 }; // The codec is a device on the board's I2C bus (i2cBusOpen), so only its address is its own; its master clock is the microphone's `mclkPin`. /// Configure the audio codec at this 7-bit address, where the board has one; true when there is nothing to do. diff --git a/src/ui/app.js b/src/ui/app.js index 6f5e8328b..6603f9b93 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -5395,6 +5395,8 @@ const EMOJI_LABEL = { "\u{1F3B5}": "reacts to volume: how loud the room is", "\u{1F3B6}": "reacts to frequency: which notes are playing", "\u{1F4E1}": "takes its picture from the network", + "\u{1F5BC}\uFE0F": "samples the Picture service's picture", + "\u{1F39E}\uFE0F": "plays a recorded show from a file", "\u{1F3AF}": "aims moving heads", "\u{1F47E}": "pixel art: games and sprites", "\u{1F9EC}": "a simulation: cells evolving off their own last frame", diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d02bad042..f2cefcded 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -19,6 +19,7 @@ add_executable(mm_tests unit/core/unit_fields.cpp unit/core/unit_crc.cpp unit/core/unit_AudioService_codec.cpp + unit/core/unit_PictureService.cpp unit/core/unit_I2cBusModule.cpp unit/core/unit_SdCardService.cpp unit/core/unit_AudioService_sync.cpp @@ -142,6 +143,8 @@ add_executable(mm_tests unit/light/unit_MirrorModifier.cpp unit/light/unit_NoiseMeterEffect.cpp unit/light/unit_PaintBrushEffect.cpp + unit/light/unit_PictureEffect.cpp + unit/light/unit_FseqPlayerEffect.cpp unit/light/unit_PraxisEffect.cpp unit/light/unit_RandomEffect.cpp unit/light/unit_RippleXZModifier.cpp diff --git a/test/js/improv-frame.test.mjs b/test/js/improv-frame.test.mjs index 07e1e5244..fcf93085a 100644 --- a/test/js/improv-frame.test.mjs +++ b/test/js/improv-frame.test.mjs @@ -15,6 +15,11 @@ import { IMPROV_CMD_APPLY_OP, IMPROV_FRAME_TYPE_RPC, IMPROV_MAGIC, + IMPROV_FRAME_TYPE_ERROR, + IMPROV_FRAME_TYPE_RPC_RESULT, + IMPROV_ERROR_OP_REFUSED, + applyOpReply, + parseImprovFrames, } from "../../mooninstaller/improv-frame.js"; const hex = (u8) => Array.from(u8).map((b) => b.toString(16).padStart(2, "0")).join(" "); @@ -125,3 +130,45 @@ test("no catalog entry has a container document the device cannot reassemble (51 } } }); + +const join = (...parts) => { + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +}; +const ack = () => buildImprovFrame(IMPROV_FRAME_TYPE_RPC_RESULT, new Uint8Array([IMPROV_CMD_APPLY_OP, 0])); +const refusal = () => buildImprovFrame(IMPROV_FRAME_TYPE_ERROR, new Uint8Array([IMPROV_ERROR_OP_REFUSED])); + +test("a reply is found between lines of log text", () => { + const text = new TextEncoder().encode("I (123) tick 125 fps\n"); + const { frames, rest } = parseImprovFrames(join(text, ack(), text)); + assert.equal(frames.length, 1); + assert.equal(applyOpReply(frames[0]), "ack"); + assert.ok(rest.length < IMPROV_MAGIC.length, "the log text after it is not kept"); +}); + +test("a reply split across two reads is kept until it is whole", () => { + const frame = refusal(); + const first = parseImprovFrames(frame.subarray(0, 7)); + assert.equal(first.frames.length, 0); + const second = parseImprovFrames(join(first.rest, frame.subarray(7))); + assert.equal(second.frames.length, 1); + assert.equal(applyOpReply(second.frames[0]), "refused"); +}); + +test("a frame whose checksum fails is skipped and the next one still read", () => { + const bad = ack(); + bad[bad.length - 1] ^= 0xff; + const { frames } = parseImprovFrames(join(bad, refusal())); + assert.deepEqual(frames.map(applyOpReply), ["refused"]); +}); + +test("an APPLY_OP reply is an ack, a refusal to resend, or an operation the firmware lacks", () => { + assert.equal(applyOpReply({ type: IMPROV_FRAME_TYPE_ERROR, payload: new Uint8Array([0x03]) }), "refused", "an error left from a failed join is resent past"); + assert.equal(applyOpReply({ type: IMPROV_FRAME_TYPE_RPC_RESULT, payload: new Uint8Array([IMPROV_CMD_APPLY_OP, 0]) }), "ack"); + assert.equal(applyOpReply({ type: IMPROV_FRAME_TYPE_ERROR, payload: new Uint8Array([IMPROV_ERROR_OP_REFUSED]) }), "refused"); + assert.equal(applyOpReply({ type: IMPROV_FRAME_TYPE_ERROR, payload: new Uint8Array([0x02]) }), "unsupported"); + assert.equal(applyOpReply({ type: IMPROV_FRAME_TYPE_ERROR, payload: new Uint8Array([0x00]) }), null, "no error is no reply"); + assert.equal(applyOpReply({ type: 0x01, payload: new Uint8Array([0x04]) }), null, "a state frame is not a reply"); +}); diff --git a/test/python/test_fseq_play.py b/test/python/test_fseq_play.py new file mode 100644 index 000000000..d91e4b55f --- /dev/null +++ b/test/python/test_fseq_play.py @@ -0,0 +1,67 @@ +# /// script +# dependencies = ["pytest"] +# /// +"""FSEQ reference sender tests: the file format FPP documents, and the DDP framing Network Receive parses.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "moondeck" / "run")) + +import fseq_play # noqa: E402 + + +def test_a_written_file_reads_back_as_uncompressed_v2(tmp_path): + """The header carries the version, the channel count, the frame count and the step time a player needs.""" + path = tmp_path / "show.fseq" + fseq_play.write_fseq(path, [bytes(12), bytes(12)], 50) + info = fseq_play.read_header(path) + assert info["version"] == "2.0" + assert info["channels"] == 12 and info["frames"] == 2 and info["step_ms"] == 50 + assert info["compression"] == 0 and info["sparse_ranges"] == 0 + assert info["data_offset"] >= info["header_len"] and info["data_offset"] % 4 == 0 + + +def test_frames_follow_the_header_at_the_data_offset(tmp_path): + """A player seeks to the data offset and reads one channel count per frame.""" + path = tmp_path / "show.fseq" + frames = [bytes([1, 2, 3]), bytes([4, 5, 6])] + fseq_play.write_fseq(path, frames, 25) + info = fseq_play.read_header(path) + raw = path.read_bytes()[info["data_offset"]:] + assert raw == b"".join(frames) + + +def test_a_file_that_is_not_fseq_is_refused(tmp_path): + """Anything without the PSEQ identifier stops the tool rather than playing noise.""" + path = tmp_path / "not.fseq" + path.write_bytes(b"GIF89a" + bytes(40)) + try: + fseq_play.read_header(path) + except SystemExit: + return + raise AssertionError("a non-FSEQ file was accepted") + + +def test_serpentine_wiring_reverses_every_other_row(): + """Wiring order is what xLights writes: row 1 of a serpentine panel runs right to left.""" + assert fseq_play.wiring_index(0, 0, 4, True) == 0 + assert fseq_play.wiring_index(3, 0, 4, True) == 3 + assert fseq_play.wiring_index(0, 1, 4, True) == 7 + assert fseq_play.wiring_index(3, 1, 4, True) == 4 + assert fseq_play.wiring_index(0, 1, 4, False) == 4 + + +def test_the_sweep_marks_the_logical_corner_in_wiring_order(): + """The white light sits at logical (0, 0) wherever the wiring puts it, so a wrong mapping shows.""" + frame = fseq_play.sweep_frame(4, 2, 0, True) + assert frame[0:3] == b"\xff\xff\xff" + + +def test_a_frame_becomes_ddp_packets_with_the_push_on_the_last(): + """1440-byte chunks, byte offsets, the version bits, and the push flag only on the final packet.""" + packets = fseq_play.ddp_packets(bytes(3000)) + assert [len(p) - fseq_play.DDP_HEADER for p in packets] == [1440, 1440, 120] + assert [int.from_bytes(p[4:8], "big") for p in packets] == [0, 1440, 2880] + assert all(p[0] & 0xC0 == 0x40 for p in packets) + assert [p[0] & 0x01 for p in packets] == [0, 0, 1] diff --git a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json index b48891058..3cd1053e7 100644 --- a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json +++ b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json @@ -175,13 +175,13 @@ "desktop-macos": { "tick_us": { "p50": 8, - "p95": 324, + "p95": 13, "min": 5, - "max": 462, + "max": 14, "n": 32, - "samples": [462, 324, 5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11, 8, 11, 9] + "samples": [5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11, 8, 11, 9, 8, 8] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json index 5a24f899b..87d7d023c 100644 --- a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json +++ b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json @@ -70,13 +70,13 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 387, + "p95": 9, "min": 1, - "max": 555, + "max": 387, "n": 32, - "samples": [367, 555, 1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8, 9, 7, 7] + "samples": [1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8, 9, 7, 7, 7, 7] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Drivers_output_and_brightness.json b/test/scenarios/light/scenario_Drivers_output_and_brightness.json index 17c66944a..2e2bfd3f4 100644 --- a/test/scenarios/light/scenario_Drivers_output_and_brightness.json +++ b/test/scenarios/light/scenario_Drivers_output_and_brightness.json @@ -107,9 +107,9 @@ "min": 6, "max": 17, "n": 32, - "samples": [7, 7, 17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8, 7, 7, 7] + "samples": [17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8, 7, 7, 7, 7, 7] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json index 5d7ecbcac..db9988ec0 100644 --- a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json +++ b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json @@ -53,11 +53,11 @@ "p50": 6, "p95": 7, "min": 1, - "max": 7, - "n": 22, - "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7, 6, 6, 7] + "max": 9, + "n": 24, + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7, 6, 6, 7, 6, 9] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -83,14 +83,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 1, + "p50": 2, "p95": 8, "min": 1, "max": 8, "n": 32, - "samples": [2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8, 8, 7, 7] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8, 8, 7, 7, 7, 7] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "desktop-windows": { "tick_us": { @@ -114,13 +114,13 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 10, + "p95": 9, "min": 6, - "max": 46, + "max": 10, "n": 32, - "samples": [46, 7, 6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6, 7, 7, 9] + "samples": [6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6, 7, 7, 9, 7, 7] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_swap_while_running.json b/test/scenarios/light/scenario_Effects_swap_while_running.json index 3ea73fc73..eef985824 100644 --- a/test/scenarios/light/scenario_Effects_swap_while_running.json +++ b/test/scenarios/light/scenario_Effects_swap_while_running.json @@ -86,9 +86,9 @@ "min": 1, "max": 2, "n": 32, - "samples": [1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "desktop-windows": { "tick_us": { @@ -195,9 +195,9 @@ "min": 1, "max": 4, "n": 32, - "samples": [1, 1, 1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json index 1195f932b..aafd7ed96 100644 --- a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json +++ b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json @@ -63,14 +63,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 7, + "p50": 8, "p95": 18, "min": 6, "max": 81, "n": 32, - "samples": [7, 7, 7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10, 8, 10, 8] + "samples": [7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10, 8, 10, 8, 8, 7] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -176,9 +176,9 @@ "min": 4, "max": 32, "n": 32, - "samples": [4, 5, 5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9, 6, 5, 7] + "samples": [5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9, 6, 5, 7, 5, 5] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -280,9 +280,9 @@ "min": 6, "max": 19, "n": 32, - "samples": [7, 7, 7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9, 8, 7, 7] + "samples": [7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9, 8, 7, 7, 8, 7] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -394,9 +394,9 @@ "min": 6, "max": 18, "n": 32, - "samples": [7, 7, 7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10, 8, 7, 7] + "samples": [7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10, 8, 7, 7, 7, 7] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -514,9 +514,9 @@ "min": 6, "max": 31, "n": 32, - "samples": [7, 7, 7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11, 7, 9, 7] + "samples": [7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11, 7, 9, 7, 7, 6] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -630,9 +630,9 @@ "min": 3, "max": 25, "n": 32, - "samples": [4, 4, 4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 7, 5] + "samples": [4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 7, 5, 4, 5] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -762,9 +762,9 @@ "min": 3, "max": 20, "n": 32, - "samples": [4, 4, 4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7, 4, 5, 5] + "samples": [4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7, 4, 5, 5, 4, 4] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json index a8b84d774..dd165e24c 100644 --- a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json +++ b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json @@ -92,9 +92,9 @@ "min": 8, "max": 24, "n": 32, - "samples": [8, 8, 8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10, 8, 9, 8] + "samples": [8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10, 8, 9, 8, 8, 8] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -136,9 +136,9 @@ "min": 13, "max": 37, "n": 32, - "samples": [15, 15, 15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15, 15, 15, 15] + "samples": [15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15, 15, 15, 15, 15, 13] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -224,9 +224,9 @@ "min": 18, "max": 46, "n": 32, - "samples": [21, 20, 20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20, 20, 20, 20] + "samples": [20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20, 20, 20, 20, 22, 20] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -264,9 +264,9 @@ "min": 121, "max": 225, "n": 32, - "samples": [124, 124, 123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124, 125, 128, 122] + "samples": [123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124, 125, 128, 122, 124, 123] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -304,9 +304,9 @@ "min": 262, "max": 454, "n": 32, - "samples": [267, 266, 267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272, 268, 265, 264] + "samples": [267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272, 268, 265, 264, 267, 271] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -338,12 +338,12 @@ "tick_us": { "p50": 56, "p95": 85, - "min": 51, + "min": 49, "max": 100, "n": 32, - "samples": [58, 57, 60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51, 56, 55, 55] + "samples": [60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51, 56, 55, 55, 56, 49] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -362,14 +362,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 53, + "p50": 52, "p95": 97, - "min": 51, + "min": 46, "max": 103, "n": 32, - "samples": [53, 54, 53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53, 52, 52, 52] + "samples": [53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53, 52, 52, 52, 52, 46] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -398,14 +398,14 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 52, + "p50": 51, "p95": 205, - "min": 48, + "min": 46, "max": 281, "n": 32, - "samples": [52, 57, 51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48, 52, 51, 51] + "samples": [51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48, 52, 51, 51, 51, 46] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } } diff --git a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json index dd0928205..2395c5404 100644 --- a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json +++ b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json @@ -82,9 +82,9 @@ "min": 6, "max": 23, "n": 32, - "samples": [6, 7, 7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7, 8] + "samples": [7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7, 8, 8, 11] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "desktop-windows": { "tick_us": { @@ -191,9 +191,9 @@ "min": 9, "max": 22, "n": 32, - "samples": [11, 10, 11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9, 10, 10, 11] + "samples": [11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9, 10, 10, 11, 11, 10] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "desktop-windows": { "tick_us": { @@ -307,9 +307,9 @@ "min": 6, "max": 14, "n": 32, - "samples": [6, 7, 7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 10, 7, 7] + "samples": [7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 10, 7, 7, 7, 6] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "desktop-windows": { "tick_us": { diff --git a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json index 0cf62d67f..891a9741c 100644 --- a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json +++ b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json @@ -67,9 +67,9 @@ "min": 6, "max": 19, "n": 32, - "samples": [7, 7, 7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7, 9, 7, 7] + "samples": [7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7, 9, 7, 7, 7, 7] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -183,9 +183,9 @@ "min": 3, "max": 11, "n": 32, - "samples": [3, 3, 3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 5, 3, 3] + "samples": [3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 5, 3, 3, 3, 4] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -292,9 +292,9 @@ "min": 3, "max": 9, "n": 32, - "samples": [3, 3, 3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4, 4, 3, 3] + "samples": [3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4, 4, 3, 3, 3, 4] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -400,9 +400,9 @@ "min": 2, "max": 6, "n": 32, - "samples": [2, 2, 2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3] + "samples": [2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -514,9 +514,9 @@ "min": 3, "max": 11, "n": 32, - "samples": [3, 3, 3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4, 4, 3, 3] + "samples": [3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 4] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -630,9 +630,9 @@ "min": 3, "max": 9, "n": 32, - "samples": [3, 4, 3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4, 4, 3, 3] + "samples": [3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4, 4, 3, 3, 3, 4] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { @@ -744,9 +744,9 @@ "min": 6, "max": 20, "n": 32, - "samples": [7, 7, 7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8, 8, 7, 7] + "samples": [7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8, 8, 7, 7, 7, 11] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" }, "esp32s3-n16r8": { "tick_us": { diff --git a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json index 0621a8ab9..ca7ac4c69 100644 --- a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json +++ b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json @@ -103,13 +103,13 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 74, + "p95": 12, "min": 6, "max": 74, "n": 32, - "samples": [6, 74, 74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9, 8, 7, 7] + "samples": [74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9, 8, 7, 7, 7, 12] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } }, @@ -171,13 +171,13 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 108, + "p95": 11, "min": 6, - "max": 115, + "max": 108, "n": 32, - "samples": [7, 115, 108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7] + "samples": [108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 8] }, - "last_updated": "2026-10-13" + "last_updated": "2026-10-14" } } } diff --git a/test/unit/core/unit_AudioService_codec.cpp b/test/unit/core/unit_AudioService_codec.cpp index 32997d782..272ea5a32 100644 --- a/test/unit/core/unit_AudioService_codec.cpp +++ b/test/unit/core/unit_AudioService_codec.cpp @@ -14,6 +14,8 @@ TEST_CASE("a microphone asks for no codec until one is named") { CHECK(audio.codecType() == mm::platform::CodecType::None); audio.codec = mm::AudioService::kCodecEs8311; CHECK(audio.codecType() == mm::platform::CodecType::Es8311); + audio.codec = mm::AudioService::kCodecEs7210; + CHECK(audio.codecType() == mm::platform::CodecType::Es7210); } // Every codec setting re-initializes the microphone, as a pin change does, so an edit applies live. @@ -22,7 +24,7 @@ TEST_CASE("changing the codec or its address re-initializes the microphone") { for (const char* name : {"codec", "codecAddr"}) CHECK(audio.affectsPrepare(name)); } -// The bus scan reads 0x18 as this codec only while the wired microphone actually uses one. +// The bus scan names the codec at its address only while the wired microphone uses one. TEST_CASE("the codec is reported on the bus only while the local microphone uses one") { mm::AudioService audio; mm::MoonModule::I2cDevice dev[2]; @@ -32,6 +34,11 @@ TEST_CASE("the codec is reported on the bus only while the local microphone uses REQUIRE(audio.i2cDevices(dev, 2) == 1); CHECK(dev[0].addr == 0x18); CHECK(std::string(dev[0].role) == "ES8311"); + audio.codec = mm::AudioService::kCodecEs7210; // a microphone ADC, at the address its strapping gives + audio.codecAddr = 0x40; + REQUIRE(audio.i2cDevices(dev, 2) == 1); + CHECK(dev[0].addr == 0x40); + CHECK(std::string(dev[0].role) == "ES7210"); audio.mode = mm::AudioService::kSimMode; CHECK(audio.i2cDevices(dev, 2) == 0); // a synthesized signal drives no chip } diff --git a/test/unit/core/unit_NetworkModule.cpp b/test/unit/core/unit_NetworkModule.cpp index d791c85fd..72d03d65a 100644 --- a/test/unit/core/unit_NetworkModule.cpp +++ b/test/unit/core/unit_NetworkModule.cpp @@ -155,6 +155,27 @@ TEST_CASE("the station tries each known network in priority order before the acc mm::platform::setTestWifiStaAvailable(false); // reset, so cases stay independent } +// A router can take seconds to lease an address, so a joined station waits for the lease on top of the join's grace before the next network. +TEST_CASE("a station that joined waits for its lease before the next network is tried") { + FrozenClock clock; + mm::platform::setTestWifiStaAvailable(true); + { + WiFiNetwork w; + w.wifi.remember("second", "pw2"); + w.wifi.remember("first", "pw1"); + w.net.setup(); + mm::platform::setTestWifiStaAssociated(true); // joined, no address yet + mm::platform::setTestNowMs(1000 + 11000); // past the join's grace + w.net.tick1s(); + CHECK(std::strcmp(mm::platform::testLastStaSsid(), "first") == 0); + mm::platform::setTestNowMs(1000 + 26000); // past the lease's wait as well + w.net.tick1s(); + CHECK(std::strcmp(mm::platform::testLastStaSsid(), "second") == 0); + } + mm::platform::setTestWifiStaAssociated(false); + mm::platform::setTestWifiStaAvailable(false); +} + namespace { mm::ListSource* availableOf(mm::WiFiModule& w) { for (uint8_t i = 0; i < w.controls().count(); i++) @@ -414,14 +435,9 @@ TEST_CASE("editing the joined network's IP settings applies them live") { TEST_CASE("a switch back to DHCP waits for the lease rather than falling back to the access point") { FrozenClock clock; mm::platform::setTestWifiStaAvailable(true); - const uint8_t staIp[4] = {192, 168, 1, 30}; const auto run = [&](uint32_t gapMs, bool stillOnStation) { WiFiNetwork w; - w.wifi.remember("home", "pw"); - w.net.setup(); - mm::platform::setTestWifiStaIPv4(staIp); - w.net.tick1s(); - REQUIRE(std::string(modeOf(w.net)) == "WiFi STA"); + joinHomeOverWifi(w); constexpr uint32_t kFirstRowId = 1; REQUIRE(w.wifi.setListRowField(kFirstRowId, "ipSettings", "{\"value\":1}")); REQUIRE(w.wifi.setListRowField(kFirstRowId, "ip", "{\"value\":\"192.168.1.240\"}")); @@ -444,6 +460,29 @@ TEST_CASE("a switch back to DHCP waits for the lease rather than falling back to mm::platform::setTestWifiStaAvailable(false); } +// A dropout the radio rejoins from still needs a lease, which gets the same wait rather than the access point opening on a station that is back. +TEST_CASE("a station that rejoins after a dropout waits for its lease") { + FrozenClock clock; + mm::platform::setTestWifiStaAvailable(true); + const uint8_t staIp[4] = {192, 168, 1, 30}; + { + WiFiNetwork w; + joinHomeOverWifi(w); + mm::platform::setTestWifiStaIPv4(nullptr); // dropped + w.net.tick1s(); + mm::platform::setTestWifiStaAssociated(true); // rejoined, the lease pending + mm::platform::setTestNowMs(1000 + 12000); // past the join's grace + w.net.tick1s(); + CHECK(std::string(modeOf(w.net)) == "WiFi STA"); + mm::platform::setTestWifiStaIPv4(staIp); + w.net.tick1s(); + CHECK(std::string(modeOf(w.net)) == "WiFi STA"); + } + mm::platform::setTestWifiStaIPv4(nullptr); + mm::platform::setTestWifiStaAssociated(false); + mm::platform::setTestWifiStaAvailable(false); +} + // A known network the scan saw is tried before one higher in the list that it did not, and the unseen one still gets its turn. TEST_CASE("the cascade tries the networks in range first, then the rest") { FrozenClock clock; diff --git a/test/unit/core/unit_PictureService.cpp b/test/unit/core/unit_PictureService.cpp new file mode 100644 index 000000000..dbfefe96e --- /dev/null +++ b/test/unit/core/unit_PictureService.cpp @@ -0,0 +1,126 @@ +/// @module PictureService + +/// The picture source: the test pattern it publishes, the generation that tells a consumer it changed, and the empty frame when nothing publishes. + +#include "doctest.h" +#include "core/services/PictureService.h" + +#include <atomic> +#include <cstring> +#include <string> +#include <thread> + +namespace { +// A service drawing a `w` x `h` test pattern, as the UI's prepare does. +struct Picture { + mm::PictureService svc; + Picture(uint16_t w, uint16_t h) { + svc.defineControls(); + svc.width = w; + svc.height = h; + svc.prepare(); + } +}; +const uint8_t* pixel(const mm::PictureFrame& f, uint32_t x, uint32_t y) { return f.rgb + (y * f.width + x) * 3; } +// The active service's frame, read through a Reader as an effect does. +mm::PictureFrame current() { + const mm::PictureService::Reader r; + REQUIRE(bool(r)); + return r.frame(); +} +} // namespace + +// The test card at 160 x 120: the color bars and the gray steps inside the round circle, a colored block and the gray grid outside it. +TEST_CASE("the test pattern publishes the test card") { + Picture p(160, 120); + const mm::PictureFrame f = current(); + REQUIRE(f.rgb != nullptr); + CHECK(f.width == 160); + CHECK(f.height == 120); + const auto is = [&](uint32_t x, uint32_t y, uint8_t r, uint8_t g, uint8_t b) { + const uint8_t* px = pixel(f, x, y); + return px[0] == r && px[1] == g && px[2] == b; + }; + CHECK(is(37, 51, 190, 190, 50)); // the yellow bar at the circle's left + CHECK(is(124, 51, 0, 30, 190)); // the blue bar at its right + CHECK(is(48, 89, 50, 50, 50)); // the gray steps start dark + CHECK(is(117, 89, 255, 255, 255)); // and end white + CHECK(is(22, 38, 80, 160, 140)); // the left side's tall block, upper half + CHECK(is(5, 2, 128, 128, 128)); // a grid square's gray +} + +// A consumer compares generations to skip a picture it already sampled, so a new size is a new generation. +TEST_CASE("a new picture size publishes a new generation") { + Picture p(16, 9); + const uint32_t first = current().generation; + p.svc.width = 32; + p.svc.prepare(); + CHECK(current().generation != first); + CHECK(current().width == 32); +} + +// An effect with no source reads an empty frame rather than a dangling pointer, before a service exists and after it leaves. +TEST_CASE("with no Picture service the frame is empty") { + CHECK(current().rgb == nullptr); + { + Picture p(8, 8); + CHECK(current().rgb != nullptr); + p.svc.release(); + CHECK(current().rgb == nullptr); + } + CHECK(current().rgb == nullptr); +} + +namespace { +// A writer task publishing `pictures` whole pictures, each one value throughout, while this thread reads: every picture read must be whole. +void writeWhileReading(Picture& p, int pictures) { + // A uniform first picture, since the test pattern the service starts with is not one value throughout. + const size_t seed = static_cast<size_t>(p.svc.width) * p.svc.height * 3; + std::memset(p.svc.beginWrite(), 0, seed); + p.svc.endWrite(); + std::atomic<bool> done{false}; + std::thread writer([&] { + const size_t bytes = static_cast<size_t>(p.svc.width) * p.svc.height * 3; + for (int k = 1; k <= pictures; k++) { + uint8_t* px = p.svc.beginWrite(); + for (size_t i = 0; i < bytes; i++) px[i] = static_cast<uint8_t>(k); + p.svc.endWrite(); + } + done = true; + }); + int torn = 0, reads = 0; + while (!done) { + const mm::PictureService::Reader r; + if (!r) continue; // the writer holds it: an effect samples on the next tick + const mm::PictureFrame& f = r.frame(); + const size_t bytes = static_cast<size_t>(f.width) * f.height * 3; + for (size_t i = 1; i < bytes; i++) if (f.rgb[i] != f.rgb[0]) { torn++; break; } + reads++; + } + writer.join(); + CHECK(torn == 0); + CHECK(reads > 0); + CHECK(current().rgb[0] == static_cast<uint8_t>(pictures)); // the last picture is the one shown +} +} // namespace + +// A source writing from its own task fills the back picture and swaps, so a reader never sees half a picture. +TEST_CASE("a picture written from another task is always read whole, with two pictures") { + Picture p(64, 36); + REQUIRE(p.svc.prepareBuffers(/*writerTask=*/true)); + REQUIRE(p.svc.twoBuffers()); + writeWhileReading(p, 300); +} + +// Short of memory for the second picture, the one picture is locked while written: frames may be skipped, never torn, and the status says why. +TEST_CASE("without room for a second picture the service degrades to one, locked while written") { + Picture p(64, 36); + const size_t bytes = 64u * 36u * 3u; + mm::platform::setTestMaxAllocBlock(bytes - 1); // the optional picture finds no block + REQUIRE(p.svc.prepareBuffers(/*writerTask=*/true)); + mm::platform::setTestMaxAllocBlock(0); + CHECK_FALSE(p.svc.twoBuffers()); + REQUIRE(p.svc.status() != nullptr); + CHECK(std::string(p.svc.status()) == "one picture buffer: not enough memory, a capture may skip frames"); + writeWhileReading(p, 300); +} diff --git a/test/unit/core/unit_platform_worker.cpp b/test/unit/core/unit_platform_worker.cpp index 803a06868..d30d2ce70 100644 --- a/test/unit/core/unit_platform_worker.cpp +++ b/test/unit/core/unit_platform_worker.cpp @@ -1,6 +1,6 @@ /// @module platform -/// Pins the worker-task seam the multicore render/encode split is built on: on the host a std::thread and a condition variable, so these tests run the real handoff where CI can catch a race; the core pin is a no-op. +/// Pins the worker-task seam the multicore render/encode split is built on. On the host the seam is a std::thread and a condition variable, so these tests run the real handoff where CI can catch a race. The core pin is a no-op there. #include "doctest.h" #include "platform/platform.h" diff --git a/test/unit/light/unit_BeatRipples.cpp b/test/unit/light/unit_BeatRipples.cpp index cc9bfd063..514fff6fe 100644 --- a/test/unit/light/unit_BeatRipples.cpp +++ b/test/unit/light/unit_BeatRipples.cpp @@ -1,7 +1,7 @@ /// @module BeatRipplesEffect /// @also golden_frame -/// The wave surface. A golden hash pins which pixels light, but it passes just as happily on a frame that is entirely black, which is how this effect once shipped rendering nothing at all: the stone pressed into the surface was scaled in units of tens while the render reads a slope in units of thousands, so every ripple fell below the threshold that lights a pixel. What is pinned here is that water is VISIBLE and that it MOVES. +/// The wave surface is pinned as visible and moving. A golden hash passes on an all-black frame too, which is how this effect once shipped: its stone was scaled far below the slope that lights a pixel. #include "doctest.h" #include "golden_frame.h" @@ -38,3 +38,15 @@ TEST_CASE("still water shows its ripples, and they spread") { for (int f = 40; f < 200; f++) { platform::setTestNowMs(1000 + f * 20u); layer.tick(); } CHECK_MESSAGE(brightestLight(layer) > 16, "the water keeps moving while the rain falls"); } + +// Ripples a few hundred high cover most of the palette rather than its middle, and each stone moves where the colors start. +TEST_CASE("ripples walk the whole palette, and each stone shifts it") { + bool seen[256] = {}; + int distinct = 0; + for (int h = -500; h <= 500; h++) { + const uint8_t i = mm::BeatRipplesEffect::paletteIndex(static_cast<int16_t>(h), 0); + if (!seen[i]) { seen[i] = true; distinct++; } + } + CHECK(distinct > 100); + CHECK(mm::BeatRipplesEffect::paletteIndex(0, 1) != mm::BeatRipplesEffect::paletteIndex(0, 0)); +} diff --git a/test/unit/light/unit_FseqPlayerEffect.cpp b/test/unit/light/unit_FseqPlayerEffect.cpp new file mode 100644 index 000000000..3f82ed683 --- /dev/null +++ b/test/unit/light/unit_FseqPlayerEffect.cpp @@ -0,0 +1,46 @@ +/// @module FseqPlayerEffect +/// @also ChannelDataSink + +/// The FSEQ player as it stands: its settings, its frames landing where a sender-mapped stream's do, and a status saying it reads no file yet. + +#include "doctest.h" +#include "light/effects/FseqPlayerEffect.h" +#include "light/layouts/GridLayout.h" +#include "light/layouts/Layouts.h" + +#include <string> + +namespace { +struct FseqRig { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::FseqPlayerEffect fx; + FseqRig() { + grid.width = 4; + grid.height = 4; + grid.depth = 1; + grid.serpentine = true; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.addChild(&fx); + fx.defineControls(); + layer.applyState(); + } +}; +} // namespace + +// An FSEQ frame is in wiring order, so alone on its layer it goes to the output as it is, on a serpentine grid too. +TEST_CASE("an FSEQ player alone on its layer hands its frame to the output") { + FseqRig r; + CHECK(r.fx.deliversWiringOrder()); + CHECK(r.layer.wiringSource() == &r.fx); +} + +// Until playback lands the player says so, rather than showing black with no reason. +TEST_CASE("an FSEQ player says it reads no file yet") { + FseqRig r; + REQUIRE(r.fx.status() != nullptr); + CHECK(std::string(r.fx.status()) == "reads no FSEQ file yet: playback is the next step"); +} diff --git a/test/unit/light/unit_NetworkReceiveEffect.cpp b/test/unit/light/unit_NetworkReceiveEffect.cpp index edfad257f..1d938ff36 100644 --- a/test/unit/light/unit_NetworkReceiveEffect.cpp +++ b/test/unit/light/unit_NetworkReceiveEffect.cpp @@ -6,6 +6,8 @@ #include "light/util/ArtNetPacket.h" #include "light/effects/NetworkReceiveEffect.h" #include "light/layouts/GridLayout.h" +#include "light/modifiers/MirrorModifier.h" +#include "light/modifiers/TransposeModifier.h" #include "platform/platform.h" #include <cstring> @@ -240,7 +242,7 @@ TEST_CASE("NetworkReceiveEffect receives over localhost UDP") { r.fx.release(); } -// The per-second counts are what a scenario compares with what the sender sent: one DDP frame of two packets, the second pushing, reads as two packets and one frame over one second, and the next second starts from zero. +// A scenario compares these counts with what the sender sent: one DDP frame of two packets reads as two packets and one frame, and the next second starts at zero. TEST_CASE("NetworkReceiveEffect counts the packets and frames of the last second") { mm::platform::setTestNowMs(1000); // the rate window starts when the stream does, on the test clock Rig r; @@ -275,10 +277,16 @@ TEST_CASE("NetworkReceiveEffect counts the packets and frames of the last second namespace { -// Which value each physical light shows once the layer maps its buffer out: index = driver index, value = the red byte that reached it, 0 for none. +// Which value each physical light shows, by Drivers' rule: a wiring-order source copied as it is, else the layer's buffer mapped out. Index = driver index, value = the red byte that reached it, 0 for none. std::vector<uint8_t> redPerDriverLight(const mm::Layer& layer) { const mm::nrOfLightsType n = layer.physicalLightCount(); std::vector<uint8_t> out(n, 0); + if (mm::EffectBase* wired = layer.wiringSource()) { + std::vector<uint8_t> frame(static_cast<size_t>(n) * 3, 0); + wired->copyWiringFrame(frame.data(), frame.size(), /*force=*/true); + for (mm::nrOfLightsType k = 0; k < n; k++) out[k] = frame[static_cast<size_t>(k) * 3]; + return out; + } const uint8_t* buf = layer.buffer().data(); const mm::MappingLUT& lut = layer.lut(); for (mm::nrOfLightsType cell = 0; cell < layer.buffer().count(); cell++) { @@ -302,7 +310,8 @@ struct OrderRig { mm::Layer layer; mm::NetworkReceiveEffect fx; - OrderRig(mm::lengthType w, mm::lengthType h, mm::lengthType d, uint8_t mappedBy, bool serpentine = true) { + OrderRig(mm::lengthType w, mm::lengthType h, mm::lengthType d, uint8_t mappedBy, bool serpentine = true, + mm::ModifierBase* modifier = nullptr) { grid.width = w; grid.height = h; grid.depth = d; @@ -312,9 +321,14 @@ struct OrderRig { layer.setLayouts(&layouts); layer.setChannelsPerLight(3); layer.addChild(&fx); + if (modifier) layer.addChild(modifier); fx.defineControls(); layer.applyState(); } + ~OrderRig() { + // The modifier is the test's, not the tree's to free. + for (uint8_t i = layer.childCount(); i-- > 0;) if (layer.child(i) != &fx) layer.removeChild(layer.child(i)); + } // A numbered frame of `lights` lights into staging, then one frame of the layer. void sendNumbered(size_t lights) { const std::vector<uint8_t> frame = numberedFrame(lights); @@ -343,9 +357,10 @@ TEST_CASE("NetworkReceiveEffect mapped by the receiver lets the layer apply this r.checkCellsAsSent(16); } -// A mapping sender (xLights, FSEQ, Network Send) sends in wiring order, so light k shows what was sent for k; copied by index, every other row would reverse. +// A mapping sender (xLights, FSEQ, Network Send) sends in wiring order, so light k shows what was sent for k; alone on its layer that is one copy. TEST_CASE("NetworkReceiveEffect mapped by the sender reaches each light as sent, on a serpentine grid") { OrderRig r(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender); + CHECK(r.layer.wiringSource() == &r.fx); r.sendNumbered(16); r.checkLightsAsSent(16); } @@ -353,10 +368,56 @@ TEST_CASE("NetworkReceiveEffect mapped by the sender reaches each light as sent, // The same on a 3D layout, two slices of a serpentine grid, the shape of TouchMyLight's cube in small. TEST_CASE("NetworkReceiveEffect mapped by the sender reaches each light as sent, on a 3D layout") { OrderRig r(4, 4, 2, mm::NetworkReceiveEffect::kMappedBySender); + CHECK(r.layer.wiringSource() == &r.fx); r.sendNumbered(32); r.checkLightsAsSent(32); } +// A layer with a modifier transforms its logical frame, so a sender-mapped stream there is placed in the grid first; a receiver-mapped one never passes through. +TEST_CASE("a frame goes to the output as it is only when nothing on its layer transforms it") { + mm::MirrorModifier mirror; + OrderRig mirrored(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender, true, &mirror); + CHECK(mirrored.layer.wiringSource() == nullptr); + OrderRig receiver(4, 4, 1, mm::NetworkReceiveEffect::kMappedByReceiver); + CHECK(receiver.layer.wiringSource() == nullptr); + OrderRig plain(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender, /*serpentine=*/false); + CHECK(plain.layer.wiringSource() == nullptr); // an identity layout is already one copy through the layer +} + +namespace { +// The driver index of the light at (x, y, z) on a serpentine w x h grid, each slice wired row by row. +size_t serpentineIndex(size_t x, size_t y, size_t z, size_t w, size_t h) { + return z * w * h + y * w + (y % 2 ? w - 1 - x : x); +} +} // namespace + +// A mirror on a sender-mapped stream mirrors the frame: each light shows what was sent for the light its position folds onto, rather than the mirror undoing itself. +TEST_CASE("a mirror on a sender-mapped serpentine grid mirrors the frame") { + mm::MirrorModifier mirror; + mirror.mirrorY = false; + mirror.mirrorZ = false; + OrderRig r(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender, true, &mirror); + r.sendNumbered(16); + const std::vector<uint8_t> seen = redPerDriverLight(r.layer); + for (size_t y = 0; y < 4; y++) + for (size_t x = 0; x < 4; x++) { + const size_t folded = x < 2 ? x : 3 - x; + CHECK(seen[serpentineIndex(x, y, 0, 4, 4)] == serpentineIndex(folded, y, 0, 4, 4) + 1); + } +} + +// A transpose on a sender-mapped 3D layout swaps the frame's axes in every slice. +TEST_CASE("a transpose on a sender-mapped 3D layout transposes the frame") { + mm::TransposeModifier transpose; // x and y swap by default + OrderRig r(4, 4, 2, mm::NetworkReceiveEffect::kMappedBySender, true, &transpose); + r.sendNumbered(32); + const std::vector<uint8_t> seen = redPerDriverLight(r.layer); + for (size_t z = 0; z < 2; z++) + for (size_t y = 0; y < 4; y++) + for (size_t x = 0; x < 4; x++) + CHECK(seen[serpentineIndex(x, y, z, 4, 4)] == serpentineIndex(y, x, z, 4, 4) + 1); +} + // On a layout already in wiring order, a plain grid with no modifiers, the two coincide and sender mapping is the same single copy. TEST_CASE("NetworkReceiveEffect mapped by the sender on an identity layout is the same copy as mapped by the receiver") { OrderRig r(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender, /*serpentine=*/false); @@ -365,7 +426,7 @@ TEST_CASE("NetworkReceiveEffect mapped by the sender on an identity layout is th r.checkCellsAsSent(16); } -// The status tells the user what sender mapping costs: one copy on an identity mapping, mapped back and forward again on any other. +// The status tells the user what sender mapping costs: one copy on an identity mapping or alone on its layer, mapped back and forward again under a modifier. TEST_CASE("NetworkReceiveEffect mapped by the sender says in its status whether a frame is one copy or mapped twice") { const uint8_t ip[4] = {10, 0, 0, 7}; OrderRig plain(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender, /*serpentine=*/false); @@ -379,8 +440,16 @@ TEST_CASE("NetworkReceiveEffect mapped by the sender says in its status whether serpentine.fx.recordSender("DDP", ip); serpentine.layer.tick(); REQUIRE(serpentine.fx.status() != nullptr); - CHECK(std::string(serpentine.fx.status()) == "receiving DDP from 10.0.0.7, mapped twice"); + CHECK(std::string(serpentine.fx.status()) == "receiving DDP from 10.0.0.7, one copy"); // straight to the output serpentine.fx.release(); + + mm::MirrorModifier mirror; + OrderRig mirrored(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender, true, &mirror); + mirrored.fx.recordSender("DDP", ip); + mirrored.layer.tick(); + REQUIRE(mirrored.fx.status() != nullptr); + CHECK(std::string(mirrored.fx.status()) == "receiving DDP from 10.0.0.7, mapped twice"); + mirrored.fx.release(); } // Receiver mapping maps once, as every layer does, so the status names only the sender. @@ -408,9 +477,12 @@ TEST_CASE("NetworkReceiveEffect reports its counts per second of elapsed time") uint8_t payload[3] = {static_cast<uint8_t>(10 + k), 0, 0}; REQUIRE(tx.sendToAddr(loopback, mm::DDP_PORT, pkt, mm::buildDdpPacket(pkt, k * 3u, true, payload, 3))); } - // A packet is counted before it is written, so the last light landing means all four were counted; bounded at about 100 ms. - for (int i = 0; i < 100 && r.fx.stagingData()[9] != 13; i++) mm::platform::delayMs(1); - REQUIRE(r.fx.stagingData()[9] == 13); + // Counted before written, so the last light landing in the layer means all four were counted; bounded at about 100 ms. + for (int i = 0; i < 100 && r.layer.buffer().data()[9] != 13; i++) { + r.layer.tick(); + mm::platform::delayMs(1); + } + REQUIRE(r.layer.buffer().data()[9] == 13); mm::platform::setTestNowMs(3000); // two seconds into the stream r.fx.tick1s(); CHECK(r.fx.packetsPerS == 2); // four packets over two seconds diff --git a/test/unit/light/unit_PictureEffect.cpp b/test/unit/light/unit_PictureEffect.cpp new file mode 100644 index 000000000..0c29f3a0c --- /dev/null +++ b/test/unit/light/unit_PictureEffect.cpp @@ -0,0 +1,134 @@ +/// @module PictureEffect +/// @also PictureService + +/// The picture effect: each cell reads its part of the active picture, point or area, fitted or stretched, on a plane or a cube. + +#include "doctest.h" +#include "core/services/PictureService.h" +#include "light/effects/PictureEffect.h" +#include "light/layouts/GridLayout.h" +#include "light/layouts/Layouts.h" + +#include <cstring> +#include <string> + +namespace { +// Eight bars across the top two thirds (white, yellow, cyan, green, magenta, red, blue, black) over a black-to-white ramp, written as a source writes. +void writeBars(mm::PictureService& pic) { + static constexpr uint8_t kBars[8][3] = {{255, 255, 255}, {255, 255, 0}, {0, 255, 255}, {0, 255, 0}, + {255, 0, 255}, {255, 0, 0}, {0, 0, 255}, {0, 0, 0}}; + const uint32_t w = pic.width, h = pic.height, barsBottom = (h * 2 + 2) / 3; + uint8_t* rgb = pic.beginWrite(); + for (uint32_t y = 0; y < h; y++) + for (uint32_t x = 0; x < w; x++) { + uint8_t* p = rgb + (y * w + x) * 3; + if (y < barsBottom) std::memcpy(p, kBars[x * 8 / w], 3); + else p[0] = p[1] = p[2] = static_cast<uint8_t>(w > 1 ? x * 255 / (w - 1) : 255); + } + pic.endWrite(); +} + +// A plain grid with the effect alone on its layer, and `pw` x `ph` bars as the active picture. +struct PictureRig { + mm::PictureService pic; + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + mm::PictureEffect fx; + + PictureRig(uint16_t pw, uint16_t ph, mm::lengthType w, mm::lengthType h, mm::lengthType d = 1, + uint8_t fit = mm::PictureEffect::kStretch, uint8_t sampling = mm::PictureEffect::kPoint) { + pic.defineControls(); + pic.width = pw; + pic.height = ph; + pic.prepare(); + writeBars(pic); + grid.width = w; + grid.height = h; + grid.depth = d; + grid.serpentine = false; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + fx.fit = fit; + fx.sampling = sampling; + layer.addChild(&fx); + fx.defineControls(); + layer.applyState(); + } + // The cell at (x, y, z) as red, green, blue. + const uint8_t* cell(size_t x, size_t y, size_t z = 0) const { + const size_t w = layer.width(), h = layer.height(); + return layer.buffer().data() + ((z * h + y) * w + x) * 3; + } +}; +} // namespace + +// On a grid the picture's size, each cell is its own pixel: the bars on top, the ramp at the bottom. +TEST_CASE("PictureEffect shows a picture the grid's size pixel for pixel") { + PictureRig r(8, 3, 8, 3); + r.layer.tick(); + CHECK(r.cell(0, 0)[2] == 255); // white bar + CHECK(r.cell(1, 0)[2] == 0); // yellow bar: no blue + CHECK(r.cell(7, 0)[0] == 0); // black bar + CHECK(r.cell(0, 2)[0] == 0); // the ramp starts black + CHECK(r.cell(7, 2)[0] == 255); // and ends white +} + +// Two picture columns per cell: point takes one, area averages both in linear light, so white and yellow give a blue of 180, not the 127 a plain mean would. +TEST_CASE("PictureEffect area sampling averages in linear light where point sampling picks one pixel") { + PictureRig point(8, 3, 4, 3, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kPoint); + point.layer.tick(); + CHECK(point.cell(0, 0)[2] == 255); // the white pixel alone + PictureRig area(8, 3, 4, 3, 1, mm::PictureEffect::kStretch, mm::PictureEffect::kArea); + area.layer.tick(); + CHECK(area.cell(0, 0)[0] == 255); + CHECK(area.cell(0, 0)[2] == 180); // sqrt((255² + 0²) / 2) +} + +// A wide picture on a square grid: fit keeps its aspect with black rows above and below, fill keeps it by cropping the sides, stretch fills every row. +TEST_CASE("PictureEffect fits a wide picture with black bands, fills it cropped, and stretches it") { + PictureRig fit(8, 3, 4, 4, 1, mm::PictureEffect::kFit); + fit.layer.tick(); + CHECK(fit.cell(0, 0)[0] == 0); // above the picture + CHECK(fit.cell(0, 3)[0] == 0); // below it + CHECK(fit.cell(0, 1)[0] == 255); // the white bar, inside + PictureRig stretch(8, 3, 4, 4, 1, mm::PictureEffect::kStretch); + stretch.layer.tick(); + CHECK(stretch.cell(0, 0)[0] == 255); // the top row is the picture's + CHECK(stretch.cell(0, 0)[2] == 255); // the white bar at the left edge + PictureRig fill(8, 3, 4, 4, 1, mm::PictureEffect::kFill); + fill.layer.tick(); + // 8 x 3 at the grid's height spans 11 columns, 3 cropped each side: the top-left cell is the cyan bar, picture column 2. + CHECK(fill.cell(0, 0)[1] == 255); // no band: the top row shows the picture + CHECK(fill.cell(0, 0)[0] == 0); // cropped past the white and yellow bars +} + +// A cube shows the picture on every slice, the layer extruding the plane through the depth. +TEST_CASE("PictureEffect on a 3D layout shows the picture on every slice") { + PictureRig r(8, 3, 8, 3, 2); + r.layer.tick(); + for (size_t x = 0; x < 8; x++) CHECK(r.cell(x, 0, 1)[0] == r.cell(x, 0, 0)[0]); +} + +// A picture already on the layer costs nothing again: a later writer's bytes survive until the picture changes. +TEST_CASE("PictureEffect does not sample again without a new picture") { + PictureRig r(8, 3, 8, 3); + r.layer.tick(); + uint8_t* buf = r.layer.buffer().data(); + buf[0] = 7; // stand in for a later writer + r.layer.tick(); + CHECK(buf[0] == 7); + writeBars(r.pic); // the same picture written again: a new generation + r.layer.tick(); + CHECK(buf[0] == 255); +} + +// Without a Picture service the effect says what to add and leaves the layer alone. +TEST_CASE("PictureEffect without a Picture service says so") { + PictureRig r(8, 3, 8, 3); + r.pic.release(); + r.layer.tick(); + REQUIRE(r.fx.status() != nullptr); + CHECK(std::string(r.fx.status()) == "no picture: add a Picture service under Services"); +} From bed82e419a54c835321358309f3e3c3289b9bf92 Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Wed, 14 Oct 2026 20:44:53 +0200 Subject: [PATCH 4/4] Process the branch reviews: one placement table, live sampling, safer installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-merge reviews' fixes. A sender-mapped stream that shares its layer is placed by one rule, a Picture effect's sampling change shows at once, and the installer no longer pairs a late acknowledgement with the wrong chunk. The Waveshare model shows BeatRipples alone, and the smoke test checks a provisioned board's network too. KPI: 256lights | Desktop:2326KB | tick:7/7/7/2/7/7/267/7/7/10us(FPS:142857/142857/142857/500000/142857/142857/3745/142857/142857/100000) | ESP32:2282KB | src:331(84686) | test:243(55796) | functions-over:353 Commit: src +38/-63 lines | test +136/-95 lines | duplicated lines 3654 -> 3654 (+0) **Core** - Desktop `waitReadable` skips descriptors past FD_SETSIZE and waits out its period on a select error rather than spinning the receive loop. - ESP32 `sleepUs` says once when it falls back to a scheduler tick; Improv switches the console to the USB driver when the driver was installed before it too. **Light domain** - One placement for a sender-mapped frame the layer maps: the layout-position table, now built whenever the frame shares its layer or sits under a modifier; `MappingLUT::gatherFromPhysical` and its walk are gone (`copyLight` keeps the per-light copy). - `ChannelDataSink::write` spins briefly before it sleeps a tick and marks staging dirty inside the lock; Picture's `sampling` redraws as `fit` does; BeatRipples' palette index in one unsigned type. **UI** - Installer: late replies are drained before a document is resent, so an acknowledgement never pairs with the wrong chunk. - Waveshare ESP32-S3-RGB-Matrix model: `replace` on its Layer, so BeatRipples is its only effect. **Scripts/MoonDeck** - improv_smoke_test reads a provisioned board's URL from the probe, so the network step runs when provisioning keeps its credentials; fseq_play limits `--step` to the header's byte. - screenshot_modules names each capture by its type without the role suffix, treats a retried create's 409 as done, and sweeps only the names it creates; the monitor prefers the online board on a reused port. **Tests** - A sender-mapped frame beside another effect reaches each light as sent (fails with the placement off); Picture shows black without a service; the smoke test's URL reading. **Docs/CI** - Installer README: the stop-and-wait push, the WiFi retry, apply-now and `replace` per level; the plan's status, G1 row and a heading-like line; the `packet_gap` card; the how-to's placement; the Teensy `.local` symptom in the index. **Reviews** - 👾 the plan's status described the state before the third commit: fixed. - 👾 two placement mechanisms for one job: fixed, the table kept (product owner's choice). - 👾 the `packet_gap` card recommended 0 for wired receivers against the driver's own rationale: fixed. - 👾 a sink writer slept a whole tick on a collision: fixed, it spins first. - 👾 the index's audio codec row is stale: deferred, its M3 status is the product owner's to state. - 👾 a Teensy `.local` name waits without saying so: named in the index's Boards known by name item. - 👾 `sleepUs` degraded silently: fixed, logged once. - 🐇 late acknowledgement paired with a resent chunk, sampling not redrawing, Improv VFS on a reused driver, FD_SETSIZE and select errors, dirty_ set after the lock, the palette arithmetic, fseq `--step`, the screenshot tool's retry and sweep, the reused-port record, a heading-like plan line, two comments: fixed. - 🐇 the repo-health coverage count: not a defect, 28 and 28; the 7 to 8 us baseline move: host noise on unchanged code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/how-to/adding-a-stream-source.md | 2 +- docs/moonmodules/light/drivers.md | 2 +- docs/reference/metrics/code.md | 8 +- docs/reference/metrics/prose.md | 8 +- docs/reference/metrics/repo-health.json | 60 +++++++-------- docs/reference/metrics/repo-health.md | 76 +++++++++---------- ...013 - Streams, every frame from outside.md | 8 +- docs/work/present/index.md | 2 +- moondeck/_moondeck_config.py | 9 ++- moondeck/build/improv_smoke_test.py | 23 ++---- moondeck/docs/screenshot_modules.py | 21 +++-- moondeck/run/fseq_play.py | 3 +- mooninstaller/README.md | 7 +- mooninstaller/deviceModels.json | 1 + mooninstaller/install-orchestrator.js | 7 +- src/core/util/JoinWindow.h | 2 +- src/light/effects/BeatRipplesEffect.h | 2 +- src/light/effects/ChannelDataSink.h | 32 ++++---- src/light/effects/PictureEffect.h | 4 +- src/light/layers/Layer.h | 5 +- src/light/layers/MappingLUT.h | 34 --------- src/platform/desktop/platform_desktop.cpp | 6 +- src/platform/esp32/platform_esp32_improv.cpp | 3 +- src/platform/esp32/platform_esp32_worker.cpp | 13 +++- test/python/test_improv_smoke_test.py | 23 ++++++ ...lette_preset_changes_only_the_palette.json | 4 +- ...ment_adds_an_effect_with_its_controls.json | 6 +- ...cenario_Drivers_output_and_brightness.json | 6 +- ...o_Effects_pipeline_builds_and_renders.json | 16 ++-- .../scenario_Effects_swap_while_running.json | 8 +- ...cts_teardown_under_a_running_pipeline.json | 44 +++++------ .../scenario_Layers_stack_and_blend_live.json | 38 +++++----- ...nario_Layouts_resize_reallocates_live.json | 8 +- ...cenario_Modifiers_reshape_the_mapping.json | 42 +++++----- ..._Palettes_a_script_drives_the_effects.json | 12 +-- test/unit/core/unit_platform_worker.cpp | 2 +- test/unit/light/unit_NetworkReceiveEffect.cpp | 15 ++++ test/unit/light/unit_PictureEffect.cpp | 7 +- 38 files changed, 301 insertions(+), 268 deletions(-) create mode 100644 test/python/test_improv_smoke_test.py diff --git a/docs/how-to/adding-a-stream-source.md b/docs/how-to/adding-a-stream-source.md index 749c43280..2969bffc2 100644 --- a/docs/how-to/adding-a-stream-source.md +++ b/docs/how-to/adding-a-stream-source.md @@ -77,7 +77,7 @@ private: 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 it is mapped back to the grid, and under a modifier it is placed by the layout's positions, so the modifier transforms it. +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<false>`. **What a channel-data source brings:** diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index df72867e4..41742145a 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -133,7 +133,7 @@ Streams the buffer over UDP as **Art-Net**, **E1.31 / sACN** or **DDP**, its pac - `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 to 120). -- `packet_gap`: µs between a frame's packets (default 500); 0 bursts, for wired receivers. +- `packet_gap`: µs between a frame's packets (default 500); 0 sends the frame as one burst. [Tests](../../reference/tests/unit-tests.md#networksenddriver) diff --git a/docs/reference/metrics/code.md b/docs/reference/metrics/code.md index d9ec2a39a..153009eef 100644 --- a/docs/reference/metrics/code.md +++ b/docs/reference/metrics/code.md @@ -276,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:22`, 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 | @@ -287,13 +287,13 @@ 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 1237 | +| `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 1789 | -| `mooninstaller/install-orchestrator.js` | 1 | `install-orchestrator.js`, large file 1468 | +| `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 | @@ -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 22-43, 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/prose.md b/docs/reference/metrics/prose.md index d04a80c77..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. -**2850 finding(s)** across 361 file(s). +**2847 finding(s)** across 361 file(s). | rule | findings | |---|---| -| `MoonLight.EmDash` | 1456 | -| `MoonLight.Weasel` | 672 | +| `MoonLight.EmDash` | 1455 | +| `MoonLight.Weasel` | 671 | | `MoonLight.SentenceLength` | 426 | -| `MoonLight.Temporal` | 211 | +| `MoonLight.Temporal` | 210 | | `MoonLight.Spelling` | 42 | | `MoonLight.SelfReference` | 30 | | `MoonLight.NegatedHeading` | 13 | diff --git a/docs/reference/metrics/repo-health.json b/docs/reference/metrics/repo-health.json index ca27e504a..b17e60763 100644 --- a/docs/reference/metrics/repo-health.json +++ b/docs/reference/metrics/repo-health.json @@ -1,9 +1,9 @@ { - "commit": "4d3ed5b6", + "commit": "f487420a", "flash": { - "esp32s3-n16r8": 2453120, - "esp32": 2258768, - "esp32p4rev1-eth": 2396736, + "esp32s3-n16r8": 2452928, + "esp32": 2258576, + "esp32p4rev1-eth": 2396368, "esp32p4rev1-eth-wifi": 2623840, "esp32s3-n8r8": 2437792, "esp32s31": 2744032, @@ -14,11 +14,11 @@ "esp32p4rev3-eth": 2231792, "esp32s3-zero": 2224816, "esp32-pico": 2107168, - "desktop-macos": 2382184, + "desktop-macos": 2382040, "desktop-windows": 1925120, "esp32c3": 2335072, "desktop-linux": 2548000, - "esp32s3-n32r16": 2474800, + "esp32s3-n32r16": 2475808, "teensy41": 1271788 }, "measured": { @@ -50,7 +50,7 @@ "Control_a_palette_preset_changes_only_the_palette": { "desktop-macos": { "p50": 8, - "p95": 13, + "p95": 14, "n": 32, "last": "2026-10-14" }, @@ -128,7 +128,7 @@ "State_one_document_adds_an_effect_with_its_controls": { "desktop-macos": { "p50": 7, - "p95": 9, + "p95": 8, "n": 32, "last": "2026-10-14" }, @@ -592,7 +592,7 @@ "Drivers_output_and_brightness": { "desktop-macos": { "p50": 7, - "p95": 15, + "p95": 9, "n": 32, "last": "2026-10-14" }, @@ -624,7 +624,7 @@ "Effects_pipeline_builds_and_renders": { "desktop-macos": { "p50": 7, - "p95": 9, + "p95": 10, "n": 32, "last": "2026-10-14" }, @@ -638,7 +638,7 @@ "Effects_swap_while_running": { "desktop-macos": { "p50": 1, - "p95": 2, + "p95": 1, "n": 32, "last": "2026-10-14" }, @@ -670,7 +670,7 @@ "Effects_teardown_under_a_running_pipeline": { "desktop-macos": { "p50": 8, - "p95": 18, + "p95": 15, "n": 32, "last": "2026-10-14" }, @@ -702,7 +702,7 @@ "Layers_stack_and_blend_live": { "desktop-macos": { "p50": 267, - "p95": 355, + "p95": 309, "n": 32, "last": "2026-10-14" } @@ -742,7 +742,7 @@ "Modifiers_reshape_the_mapping": { "desktop-macos": { "p50": 7, - "p95": 11, + "p95": 10, "n": 32, "last": "2026-10-14" }, @@ -774,19 +774,19 @@ "Palettes_a_script_drives_the_effects": { "desktop-macos": { "p50": 7, - "p95": 12, + "p95": 11, "n": 32, "last": "2026-10-14" } } }, "desktop-macos": { - "tick_us": 6, - "fps": 166666, + "tick_us": 7, + "fps": 142857, "scenario_p50": { "Effects_pipeline_builds_and_renders": { "p50": 7, - "p95": 9, + "p95": 10, "n": 32, "last": "2026-10-14" }, @@ -823,11 +823,11 @@ }, "loc": { "core": 30403, - "light": 31915, - "platform": 21621, + "light": 31876, + "platform": 21635, "ui": 12473, - "test": 68310, - "moondeck": 31595 + "test": 68328, + "moondeck": 31610 }, "comments": { "core": { @@ -835,11 +835,11 @@ "ratio": 0.236 }, "light": { - "lines": 7599, - "ratio": 0.267 + "lines": 7597, + "ratio": 0.268 }, "platform": { - "lines": 4171, + "lines": 4172, "ratio": 0.216 }, "ui": { @@ -847,28 +847,28 @@ "ratio": 0.295 }, "test": { - "lines": 7192, + "lines": 7193, "ratio": 0.122 }, "moondeck": { - "lines": 4941, + "lines": 4942, "ratio": 0.178 } }, "tests": { - "cases": 2554, + "cases": 2555, "scenarios": 32 }, "docs": { "md_files": 161, - "md_lines": 34201, + "md_lines": 34209, "plans_files": 50, "backlog_lines": 3393, "lessons_lines": 532, "claude_md_lines": 287 }, "complexity": { - "functions": 5090, + "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 3c0ba7424..11986bfc0 100644 --- a/docs/reference/metrics/repo-health.md +++ b/docs/reference/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `4d3ed5b6`. 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,15 +9,15 @@ 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,326 KB | +363.3 KB (+18.5%) | -| flash esp32 | 2,061 KB | 2,206 KB | +144.6 KB (+7.0%) | +| 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,341 KB | +318.8 KB (+15.8%) | +| 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,396 KB | +284.1 KB (+13.5%) | -| flash esp32s3-n32r16 | - | 2,417 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%) | @@ -25,12 +25,12 @@ Current state only; the trend is this file's git history (`git log -p docs/refer | 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,403 | +8,104 (+36.3%) | -| lines light | 31,054 | 31,915 | +861 (+2.8%) | -| lines platform | 16,798 | 21,621 | +4,823 (+28.7%) | +| 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,310 | +10,551 (+18.3%) | -| lines moondeck | 29,325 | 31,595 | +2,270 (+7.7%) | -| unit tests | 2,120 | 2,554 | +434 (+20.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. @@ -40,19 +40,19 @@ 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 2d | -| desktop-macos | 2,326 KB (+23 KB) ⚠ | - | - | yes | +| desktop-macos | 2,326 KB (−144 B) ✓ | - | - | yes | | desktop-windows | 1,880 KB | - | - | **STALE 14d** | -| esp32 | 2,206 KB (+10 KB) ⚠ | 2,496 KB | 88% | yes | +| 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 4d | -| esp32p4rev1-eth | 2,341 KB (+16 KB) ⚠ | 4,096 KB | 57% | yes | +| 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,396 KB (+9 KB) ⚠ | 4,096 KB | 58% | yes | -| esp32s3-n32r16 | 2,417 KB (+36 KB) ⚠ | 4,096 KB | 59% | yes | +| 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 | @@ -65,19 +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 | |---|---:|---:|---:|---:|---:|---:|---| -| esp32 | +9.5 KB | +8.5 KB | -1.5 KB | +2.4 KB | -0.6 KB | +0.7 KB | `4d3ed5b6` | -| esp32p4rev1-eth | +15.8 KB | +14.3 KB | -1.5 KB | +2.9 KB | -0.4 KB | +0.5 KB | `4d3ed5b6` | -| esp32p4rev1-eth-wifi | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | +0.0 KB | `4d3ed5b6` | -| esp32s3-n16r8 | +9.3 KB | +8.3 KB | -1.2 KB | +2.2 KB | -0.5 KB | +0.5 KB | `4d3ed5b6` | -| esp32s3-n32r16 | +35.8 KB | +31.5 KB | -2.1 KB | +6.1 KB | -2.1 KB | +2.3 KB | `4d3ed5b6` | +| 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: -- **esp32**: `mm::PictureService::drawTestPattern(unsigned char*, unsigned` +1,089 B, `mm::PictureEffect::tick()` +916 B, `mm::PictureEffect::gather(mm::PictureFrame const&)` +672 B, `mm::ChannelDataSink::copyIntoLayer(mm::Layer const*, bool)` +471 B, `mm::SystemModule::SystemModule()` -434 B -- **esp32p4rev1-eth**: `mm::PictureService::drawTestPattern(unsigned char*, unsigned` +1,070 B, `mm::PictureEffect::tick()` +946 B, `es7210_open` +882 B, `es7210_mic_select.part.0` +766 B, `mm::PictureEffect::gather(mm::PictureFrame const&)` +690 B +- **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::PictureService::drawTestPattern(unsigned char*, unsigned` +1,089 B, `mm::PictureEffect::tick()` +751 B, `mm::PictureEffect::gather(mm::PictureFrame const&)` +672 B, `mm::SystemModule::SystemModule()` -434 B, `mm::PanelCardDriver::PanelCardDriver()` +408 B -- **esp32s3-n32r16**: `coeff_div` +2,144 B, `void mm::NetworkSendDriver::sendFrame<true>(unsigned char co` +1,293 B, `void mm::NetworkReceiveEffect::drainSockets<true>()` +1,188 B, `void mm::NetworkReceiveEffect::drainSockets<false>()` +1,175 B, `mm::PictureService::cardCircle(float, float)` +965 B +- **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<true>()` +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. @@ -86,7 +86,7 @@ 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 | 6 µs (−1 µs) ✓ | 166,666 (+23,809) ✓ | +| desktop-macos | 7 µs (+1 µs) ⚠ | 142,857 (−23,809) ⚠ | | desktop-windows | 4 µs | 250,000 | | esp32 | 1,716 µs | 582 | @@ -99,7 +99,7 @@ What this commit's flash went to, symbol by symbol against the measurement at `S | 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 (+1) ⚠ | 17 ? | - | 9,080 | - | - | - | - | 2,159 ? | 482 ? | - | - | - | - | - | - | - | - | +| 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 ? | - | - | - | - | - | - | - | @@ -124,7 +124,7 @@ Microseconds. `?` marks a cell backed by fewer than 4 samples, which is a first | Scenario | p50 | p95 | n | |---|---:|---:|---:| -| Effects_pipeline_builds_and_renders | 7 µs | 9 µs | 32 | +| Effects_pipeline_builds_and_renders | 7 µs | 10 µs | 32 | | Layouts_resize_reallocates_live | 10 µs | 19 µs | 32 | These build a bare pipeline with no optional modules, so a change here is a change in the pipeline itself rather than in what was measured. A new module belongs in an advanced scenario, which keeps its own numbers. @@ -142,25 +142,25 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 30,403 (+264) ⚠ | 6,486 | 23.6 % (−0.1 %) ✓ | -| light | 31,915 (+373) ⚠ | 7,599 | 26.7 % (−0.1 %) ✓ | -| platform | 21,621 (+32) ⚠ | 4,171 | 21.6 % | -| ui | 12,473 (+2) ⚠ | 3,471 | 29.5 % | -| test | 68,310 (+509) ⚠ | 7,192 | 12.2 % | -| moondeck | 31,595 (+191) ⚠ | 4,941 | 17.8 % (−0.1 %) ✓ | +| 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,554 (+19) ✓ | +| unit cases | 2,555 (+1) ✓ | | scenarios | 32 | ## Complexity | Metric | Value | |---|---:| -| functions | 5,090 (+81) ✓ | +| functions | 5,087 (−3) ⚠ | | over threshold | 353 | | worst CCN | 128 | @@ -168,8 +168,8 @@ These build a bare pipeline with no optional modules, so a change here is a chan | Metric | Value | |---|---:| -| markdown files | 161 (+1) ⚠ | -| markdown lines | 34,201 (+218) ⚠ | +| markdown files | 161 | +| markdown lines | 34,209 (+8) ⚠ | | plan files | 50 | | backlog lines | 3,393 | | lessons lines | 532 | 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 index bea38b467..ffb637fc5 100644 --- a/docs/work/present/Plan-20261013 - Streams, every frame from outside.md +++ b/docs/work/present/Plan-20261013 - Streams, every frame from outside.md @@ -289,7 +289,7 @@ What MoonLight builds first, so that every contribution after it is one source i | # | Piece | Where | What it does | |---|---|---|---| -| G1 | **Light-list placement** ✅ built | `MappingLUT::gatherFromPhysical` | the mapping's own inverse, needing no table of its own: each logical cell takes the value its first physical light was sent, read from the CSR runs the layer already builds; one copy on the identity path ; it inverts the whole mapping today, modifiers included, so a mirror or a rotation on a sender-mapped stream cancels instead of applying: G5 inverts the layout's mapping alone, accepted on a rotated layer | +| 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 | @@ -315,7 +315,7 @@ Each later piece is a small pull request, one topic, based on current main, with | Browser pages: converter, baking to FSEQ | open | G1 to G4, G8 | | Recording, HDMI out on the ESP32-P4-PC, clock sync | open | FSEQ playback | -#138 and #139 close with thanks once G3 and G4 land, their good parts credited in the groundwork; a stacked pull request waits for the one below it, as [CLAUDE.md](../../../CLAUDE.md) says. +Pull requests #138 and #139 close with thanks once G3 and G4 land, their good parts credited in the groundwork; a stacked pull request waits for the one below it, as [CLAUDE.md](../../../CLAUDE.md) says. ## Skeletons for the contributions @@ -380,7 +380,7 @@ Tests: a trim scaling one channel and leaving the others; a trim of 255 being th ## Where it stands (2026-10-14) -Built and uncommitted: G1, G2 (`mapped_by`) and G5, and around them what measuring Network Receive on real boards turned up. +Built on this branch: G1 to G7, and around them what measuring Network Receive on real boards turned up. <img src="../../assets/light/effects/NetworkReceiveEffect.png" width="404" alt="Network Receive receiving a sender-mapped stream in one copy"> @@ -401,7 +401,7 @@ Received at 12,288 lights, 40 frames/s sent (1,040 packets/s), mapped by the rec | S3 WiFi to P4 Ethernet | 984 | 32 | 0.3 ms | | P4 Ethernet to P4 Ethernet | 790 to 846 | 8 to 19 | 0.8 ms | -P4 to P4 started the day at about 200 packets/s and 0 to 1 frames. What remains is the sender's burst: the access point drops it on WiFi, and on Ethernet the push packet at its end is the one most often lost. Network Send paces its packets, set by `packet_gap` and off at 0 (a 0.5 ms gap recovered 96% on WiFi), which took P4 to P4 to 26 to 36 frames/s. G5 removes the double mapping that sender mapping cost on a layout that is not the identity (4 ms against 1.6 ms on the S3): a stream alone on its layer goes to the output in one copy, and a static modifier on its layer transforms it. G6 is next. The rest is in the backlog below. +P4 to P4 started the day at about 200 packets/s and 0 to 1 frames. What remains is the sender's burst: the access point drops it on WiFi, and on Ethernet the push packet at its end is the one most often lost. Network Send paces its packets, set by `packet_gap` and off at 0 (a 0.5 ms gap recovered 96% on WiFi), which took P4 to P4 to 26 to 36 frames/s. G5 removes the double mapping that sender mapping cost on a layout that is not the identity (4 ms against 1.6 ms on the S3): a stream alone on its layer goes to the output in one copy, and a static modifier on its layer transforms it. G8 is next. The rest is in the backlog below. ## Backlog, found while measuring diff --git a/docs/work/present/index.md b/docs/work/present/index.md index 685c6a39c..18cb41fac 100644 --- a/docs/work/present/index.md +++ b/docs/work/present/index.md @@ -8,7 +8,7 @@ A file here that no longer has work behind it is either finished, and belongs in ## Next (2026-10-13) -1. **[Boards known by name](Plan-20261009%20-%20Boards%20known%20by%20name.md)**, not started. A setting that points at another board resolves its name through MoonLight's own Devices discovery before mDNS, and cards name their peers. It also gives the Teensy board names, since its lwIP has no mDNS lookup and a `.local` name fails there today. +1. **[Boards known by name](Plan-20261009%20-%20Boards%20known%20by%20name.md)**, not started. A setting that points at another board resolves its name through MoonLight's own Devices discovery before mDNS, and cards name their peers. It also gives the Teensy board names, since its lwIP has no mDNS lookup: a `.local` name never resolves there, and a driver waits on it rather than saying so. 2. **[Layer compositing](Plan-20261011%20-%20Layer%20compositing%2C%20masks%20and%20transitions.md), step 3: transitions.** Steps 1 and 2 shipped in #137. The old effect fades out while the new one fades in, with one ramp type shared with the boot soft start; a Discord request. 3. **Downloads on the tick path.** Every upload now moves through the transfer slots; a flash download still writes its whole file on one tick, bounded per chunk by the write deadline. Moving downloads onto the slots too needs a look at how many files the UI reads at once, since two slots answer a third request with 503. Firmware uploads drain whole by design and feed the watchdog. diff --git a/moondeck/_moondeck_config.py b/moondeck/_moondeck_config.py index 452d005e5..7e5a0d92e 100644 --- a/moondeck/_moondeck_config.py +++ b/moondeck/_moondeck_config.py @@ -28,9 +28,12 @@ def device_ips_for_port(port): state = json.loads(_STATE.read_text(encoding="utf-8")) active = next((n for n in (state.get("networks") or []) if n.get("name") == state.get("active_network")), None) - return [d["ip"] for d in (active or {}).get("devices", []) - if d.get("ip") and ":" not in d["ip"] - and Path(d.get("last_port") or "").name.replace("tty.", "cu.", 1) == key][:1] + matches = [d for d in (active or {}).get("devices", []) + if d.get("ip") and ":" not in d["ip"] + and Path(d.get("last_port") or "").name.replace("tty.", "cu.", 1) == key] + # A port reused by a new board can leave the old one's record behind, offline: the online board is the one on the port. + matches.sort(key=lambda d: not d.get("online")) + return [d["ip"] for d in matches[:1]] except Exception: return [] diff --git a/moondeck/build/improv_smoke_test.py b/moondeck/build/improv_smoke_test.py index fb793d30c..1dda6ff44 100755 --- a/moondeck/build/improv_smoke_test.py +++ b/moondeck/build/improv_smoke_test.py @@ -72,17 +72,12 @@ def _run(script: Path, args: list[str], timeout: float) -> tuple[int, str, str]: return 124, "", f"timeout after {timeout}s" -def _device_url_from_provision_output(out: str) -> str | None: - """Extract `==> provisioned: http://...` URL from improv_provision.py stdout. - - The line shape is set by improv_provision.py at the success exit; - pinning that contract here would make sense as a test, but for now we - just string-match. If the format changes, the reachability check - silently skips with a clearer message than a regex fail.""" +def _device_url(out: str) -> str | None: + """The device URL in a step's output: `==> provisioned: http://...` from improv_provision.py, or `url: http://...` from the probe's current state.""" for line in out.splitlines(): line = line.strip() - if line.startswith("==> provisioned:"): - return line.split(":", 1)[1].strip() + if line.startswith(("==> provisioned:", "url:")): + return line.split(":", 1)[1].strip() or None return None @@ -163,6 +158,7 @@ def finish(exit_code: int) -> int: ["--port", args.port, "--timeout", str(args.probe_timeout)], timeout=args.probe_timeout + 5) sys.stdout.write(_indent(out)) + probe_url = _device_url(out) # a provisioned board reports it, so the network step runs when provisioning keeps its credentials if err.strip(): sys.stderr.write(_indent(err)) if rc != 0: @@ -187,7 +183,7 @@ def finish(exit_code: int) -> int: return finish(1) summary["provision"] = True - url = _device_url_from_provision_output(out) + url = _device_url(out) or probe_url summary["device_url"] = url # ----- Step 3: reachable (optional) ----------------------------------- @@ -195,11 +191,8 @@ def finish(exit_code: int) -> int: print("==> [3/3] network skipped (--no-network)") return finish(0) if not url: - # Provisioning succeeded but we couldn't parse the URL from the - # output — older firmware variants may not report the URL line. - # Don't fail (device IS provisioned), but skip the reachability - # check with a clear message. - print("==> [3/3] network skipped (no device URL in provision output)") + # Neither step reported a URL; the device is provisioned, so the reachability check is skipped with the reason. + print("==> [3/3] network skipped (no device URL in the probe or provision output)") summary["failure"] = "provision succeeded but device URL not parseable from output" return finish(0) diff --git a/moondeck/docs/screenshot_modules.py b/moondeck/docs/screenshot_modules.py index 3c3ba276e..9b62d7480 100644 --- a/moondeck/docs/screenshot_modules.py +++ b/moondeck/docs/screenshot_modules.py @@ -98,6 +98,15 @@ def asset_dir_for(type_name: str) -> Path: # The longest module name the device accepts: MoonModule's kNameLen less its terminator. MAX_NAME = 15 + +def capture_name(type_name: str) -> str: + """The name a capture gives a module: its type without the role suffix, as the UI names one, cut to what the device accepts.""" + for suffix in ("Effect", "Modifier", "Layout", "Driver", "Service", "Module"): + if type_name.endswith(suffix) and len(type_name) > len(suffix): + type_name = type_name[: -len(suffix)] + break + return type_name[:MAX_NAME] + MODULES = [ # Layouts ("GridLayout", "Layouts", {}, True), @@ -348,6 +357,8 @@ def add_module(host: str, id_: str | None, type_: str, parent_id: str | None, # `props` is deliberately NOT sent: the create endpoint ignores it (no handler reads it), so a # caller that needs a control set gets it through /api/control after the module exists. r = _post(f"http://{host}/api/modules", json=body, timeout=5) + if r.status_code == 409 and id_: + return id_ # a retried create whose first attempt landed: the module is there under this name if not r.ok: return None named = r.json().get("name") if r.headers.get("content-type", "").startswith("application/json") else None @@ -905,8 +916,8 @@ def main() -> int: if args.no_modifier or args.grid: prepare_pipeline(args.host, args.no_modifier, args.grid or None) - # Known name prefixes for orphan sweep (each type name as the device would cut it). - known_prefixes = {t[:MAX_NAME] for t, _, _, _ in MODULES} + # The names this tool gives its modules, so the orphan sweep removes only what a previous run left. + known_names = {capture_name(t) for t, _, _, _ in MODULES} # Sweep orphans from a previous aborted run. Per-delete try/except # so one failed cleanup doesn't abort the whole recursion; outer @@ -918,7 +929,7 @@ def main() -> int: def _sweep_orphans(modules: list) -> None: for m in modules: n = m.get("name", "") - if any(n.startswith(p) for p in known_prefixes): + if n in known_names: try: # delete_module returns False on HTTP failure # (4xx/5xx) — log that the same way as a raised @@ -1078,7 +1089,7 @@ def _sweep_orphans(modules: list) -> None: parent_id = parents.get(parent_type) nav_root = nav_roots.get(parent_type, "") - req_id = None # the device names it, as the UI's add does + req_id = capture_name(type_name) print(f" {type_name} …", end=" ", flush=True) # The bed effect, removed again below, so the pipeline holds exactly one effect @@ -1188,7 +1199,7 @@ def _sweep_orphans(modules: list) -> None: def _sweep(modules: list) -> None: for m in modules: n = m.get("name", "") - if any(n.startswith(p) for p in known_prefixes): + if n in known_names: try: # Same False-return check as the # opening sweep — HTTP failure is diff --git a/moondeck/run/fseq_play.py b/moondeck/run/fseq_play.py index 33e9e7b4a..b656e8d64 100644 --- a/moondeck/run/fseq_play.py +++ b/moondeck/run/fseq_play.py @@ -116,7 +116,8 @@ def main() -> None: p.add_argument("--height", type=int, default=64) p.add_argument("--serpentine", action="store_true", help="every other row reversed, as most panels are wired") p.add_argument("--frames", type=int, default=400) - p.add_argument("--step", type=int, default=25, help="milliseconds per frame") + p.add_argument("--step", type=int, default=25, choices=range(1, 256), metavar="1-255", + help="milliseconds per frame, one byte in the header") a = p.parse_args() if a.info: diff --git a/mooninstaller/README.md b/mooninstaller/README.md index 17facf561..c58152b1f 100644 --- a/mooninstaller/README.md +++ b/mooninstaller/README.md @@ -16,17 +16,20 @@ A browser blocks an HTTPS page from POSTing to a plain-`http://` device (mixed c - [`install-orchestrator.js`](install-orchestrator.js): owns the SerialPort across flash → reboot → Improv provision → config push. Replaces the ESP Web Tools install button, whose `state-changed` event (EWT 10.x) fires inside a shadow DOM the host page cannot see. Owning the whole flow lets the orchestrator write its own vendor RPC frames. + A join the board reports as failed brings the WiFi form back with the reason, and Connect retries on the same port. After provisioning it pushes the picked device model's `state` over serial as APPLY_OP documents, one `{"<Root>": …}` per top-level container (see `stateFrames` in [`improv-frame.js`](improv-frame.js)). Each is the body `PATCH /api/state` takes, applied by the same device-side engine. + Each chunk waits for the board's acknowledgement, and a refused or unanswered document is sent again from its first chunk, for up to 10 s. An **Apply device defaults** checkbox gates that push. It ticks itself with **Erase chip first**, since a clean slate wants defaults, and starts unticked otherwise, so re-flashing a configured device keeps its modules and controls. The board's `txPower` brown-out cap goes first, through its own `SET_TX_POWER` RPC **before** provisioning, since a weak-power board fails its first association without it. A device that doesn't speak Improv back (the typed-IP or eth-only path) gets no serial push. Its defaults are applied later from MoonDeck on the LAN, over plain HTTP REST with no mixed content. + **Apply device defaults now, without flashing** sends the same documents over USB to a board that already runs MoonLight. - [`devices.js`](devices.js): the *Your devices* list. Stores devices the user provisioned from the installer so they can be revisited, erased or forgotten (Visit / Erase / Forget). The device-model defaults are applied during the install over serial, so this list has no "inject" button. - MoonDeck on the LAN re-applies a model to a running device. + A running device takes them again over USB with **Apply device defaults now, without flashing**, or from MoonDeck on the LAN. - [`deviceModels.json`](deviceModels.json): the device-model catalog (name → firmware variants + the `state` document). The installer slices it into the APPLY_OP documents; MoonDeck and the picker also read it. Schema below. @@ -82,7 +85,7 @@ The `deviceModel` identity is part of it: `System.deviceModel`. **`"$patch": "replace"`** (optional, on a container): applying a state document is a merge, so an entry's children land *alongside* the container's current children. `replace` makes the entry's children the only ones, and the device keeps code-wired children such as `WiFi`. -Used when an entry wants its own effects to replace the defaults rather than stack with them (the testbench above swaps the default `NoiseEffect` for `AudioSpectrumEffect` + `RandomMapModifier`). +It applies to its own level, so a child the entry names again, such as the `Layer`, is merged. A layer that holds only the model's effects says `replace` too. **Drivers goes last.** The installer sends the documents in the entry's key order with `Drivers` last. Starting an output prints on UART0, which a classic board shares with Improv, and LED pins 1 or 3 end serial reception. diff --git a/mooninstaller/deviceModels.json b/mooninstaller/deviceModels.json index fa2e3e93b..587af1b93 100644 --- a/mooninstaller/deviceModels.json +++ b/mooninstaller/deviceModels.json @@ -822,6 +822,7 @@ "$patch": "replace", "Layer": { "type": "Layer", + "$patch": "replace", "BeatRipples": { "type": "BeatRipplesEffect", "rain": 73, diff --git a/mooninstaller/install-orchestrator.js b/mooninstaller/install-orchestrator.js index 4a7f73f04..f713bf9d1 100644 --- a/mooninstaller/install-orchestrator.js +++ b/mooninstaller/install-orchestrator.js @@ -170,6 +170,11 @@ function applyOpReplies(port) { } return queue.shift(); }, + // Discard every reply until `ms` pass quietly, so a late acknowledgement is not taken for the resent chunk's. + async drain(ms) { + queue.length = 0; + while ((await this.next(ms)) !== "timeout") { /* a reply to a chunk already given up on */ } + }, async close() { try { await reader.cancel(); } catch (_) { /* ignore */ } try { reader.releaseLock(); } catch (_) { /* ignore */ } @@ -192,11 +197,11 @@ async function sendApplyOpDocument(port, replies, doc, onLog) { if (reply !== "ack") { again = true; break; } } if (!again) return true; + await replies.drain(500); // a late acknowledgement would pair with the wrong chunk if (Date.now() > giveUp) { if (onLog) onLog(`[orchestrator] no acknowledgement from the device within 10 s`); return false; } - await new Promise(r => setTimeout(r, 100)); } } finally { writer.releaseLock(); diff --git a/src/core/util/JoinWindow.h b/src/core/util/JoinWindow.h index 9b67d7568..c0d00636b 100644 --- a/src/core/util/JoinWindow.h +++ b/src/core/util/JoinWindow.h @@ -12,7 +12,7 @@ namespace mm { inline constexpr uint32_t kStaGraceMs = 10000; /// How long a lease may take: a live Ethernet link or a joined station without an address, or an interface re-leasing after a switch to DHCP. inline constexpr uint32_t kDhcpWaitMs = 15000; -/// How long a station gets before the next network or the fallback: the join's grace, plus the lease's wait once it has joined, as Ethernet waits on a live link. +/// How long a station gets before the next network or the fallback. A joined station adds the lease's wait to the join's grace, as Ethernet waits on a live link. inline constexpr uint32_t staWindowMs(bool associated) { return associated ? kStaGraceMs + kDhcpWaitMs : kStaGraceMs; } /// @} diff --git a/src/light/effects/BeatRipplesEffect.h b/src/light/effects/BeatRipplesEffect.h index a2f75f586..556621c1e 100644 --- a/src/light/effects/BeatRipplesEffect.h +++ b/src/light/effects/BeatRipplesEffect.h @@ -143,7 +143,7 @@ class BeatRipplesEffect : public EffectBase { /// The palette index at a height: it wraps with height, so ripples a few hundred high walk the whole palette, and each stone moves the start along it. static uint8_t paletteIndex(int16_t height, uint32_t stones) { - return static_cast<uint8_t>(128 + static_cast<int32_t>(stones * 8u) + height / 8); + return static_cast<uint8_t>(128u + stones * 8u + static_cast<uint32_t>(height / 8)); // unsigned, so the wrap is modulo 256 by definition } private: diff --git a/src/light/effects/ChannelDataSink.h b/src/light/effects/ChannelDataSink.h index b12ef834a..9612f0566 100644 --- a/src/light/effects/ChannelDataSink.h +++ b/src/light/effects/ChannelDataSink.h @@ -19,26 +19,27 @@ namespace mm { /// /// A receiver-mapped frame is copied into the layer, which applies this board's wiring. A sender-mapped one alone on its layer goes to the output as it is. /// -/// Beside another writer a sender-mapped frame is mapped back to the grid, and under a static modifier it is placed by the layout's positions. +/// Beside another writer or under a modifier a sender-mapped frame is placed by the layout's positions, so a modifier transforms it. class ChannelDataSink { public: /// What a frame costs on its way to the drivers, for a status to name. enum Path : uint8_t { kPathNone, ///< receiver mapping, which maps once as every layer does kPathOneCopy, ///< sender mapping straight to the output, or through an identity mapping - kPathTwice, ///< sender mapping mapped back to the grid and forward again + kPathTwice, ///< sender mapping placed on the grid and mapped forward again }; /// Declared as a member of the effect it serves, which owns the memory. explicit ChannelDataSink(EffectBase& owner) : owner_(owner) {} - /// Size staging to the layer, by logical cell or, for a sender-mapped frame, by physical light; static modifiers add the layout placement. + /// Size staging by logical cell, or by physical light for a sender-mapped frame. One the layer maps gets a placement by layout position, unless it goes straight to the output. void prepare(bool senderMapped) { const Layer* layer = owner_.layer(); senderMapped_ = senderMapped && layer; const size_t lights = senderMapped_ ? layer->physicalLightCount() : owner_.nrOfLights(); staging_.resize(lights * owner_.channelsPerLight()); - placement_.resize(senderMapped_ && layer->foldsStatically() ? owner_.nrOfLights() : 0); + const bool placed = senderMapped_ && layer->lut().hasLUT() && layer->wiringSource() != &owner_; + placement_.resize(placed ? owner_.nrOfLights() : 0); if (placement_) layer->placeByLayout(placement_.data(), static_cast<nrOfLightsType>(placement_.count())); dirty_.store(true, std::memory_order_release); // a resize, or a re-enable, paints the layer from staging once } @@ -50,11 +51,11 @@ class ChannelDataSink { Path tick() { const Layer* layer = owner_.layer(); if (!senderMapped_ || !layer) { - copyIntoLayer(layer, false); + copyIntoLayer(layer); return kPathNone; } if (layer->wiringSource() == &owner_) return kPathOneCopy; // Drivers copies it to the output - copyIntoLayer(layer, true); + copyIntoLayer(layer); return layer->lut().hasLUT() ? kPathTwice : kPathOneCopy; } @@ -67,19 +68,20 @@ class ChannelDataSink { std::memcpy(out, staging_.data(), staging_.bytes() < bytes ? staging_.bytes() : bytes); } - /// The one clamped write into staging, bound-checked before any addition; `kWait` for a writer task, which waits out one render copy at most, else a busy lock drops the write. + /// The one clamped write into staging, bound-checked before any addition. `kWait` is for a writer task, which waits out the render copy; without it a busy lock drops the write. template <bool kWait = true> void write(size_t offset, const uint8_t* data, size_t len) { if (!staging_ || offset >= staging_.bytes()) return; const size_t n = offset + len > staging_.bytes() ? staging_.bytes() - offset : len; if constexpr (kWait) { - while (!lock_.tryAcquire()) platform::yield(); + // A copy takes microseconds, while a yield costs a whole tick (10 ms at 100 Hz) in which the mailbox fills. + for (uint32_t spins = 0; !lock_.tryAcquire(); spins++) if (spins >= kSpins) platform::yield(); } else if (!lock_.tryAcquire()) { return; } std::memcpy(staging_.data() + offset, data, n); + dirty_.store(true, std::memory_order_release); // inside the lock, so a copy that takes it next sees this write lock_.release(); - dirty_.store(true, std::memory_order_release); } /// The held frame's bytes, for a test pinning the lifecycle. @@ -89,7 +91,7 @@ class ChannelDataSink { private: /// Copy staging into the layer when it changed or the layer was overwritten, through whichever placement the frame needs. - void copyIntoLayer(const Layer* layer, bool sender) { + void copyIntoLayer(const Layer* layer) { uint8_t* buf = owner_.buffer(); if (!buf || !staging_) return; const uint32_t gen = layer ? layer->bufferGen() : 0; @@ -97,16 +99,11 @@ class ChannelDataSink { LockGuard guard(lock_); if (!guard) return; // a write holds staging: copy on the next tick dirty_.store(false, std::memory_order_release); - place(buf, layer, sender); - lastGen_ = gen + 1; // the Layer bumps the generation after this tick, for our own copy - } - - /// Staging into the layer buffer: by the layout's positions under static modifiers, through the mapping's inverse when sender-mapped, else as it is. - void place(uint8_t* buf, const Layer* layer, bool sender) { + // By the layout's positions for a sender-mapped frame the layer maps, else as it is: row by row, or wiring order on an identity mapping. const size_t bufBytes = static_cast<size_t>(owner_.nrOfLights()) * owner_.channelsPerLight(); if (placement_) gatherPlaced(buf, bufBytes); - else if (sender) layer->lut().gatherFromPhysical(buf, bufBytes, staging_.data(), staging_.bytes(), owner_.channelsPerLight()); else std::memcpy(buf, staging_.data(), staging_.bytes() < bufBytes ? staging_.bytes() : bufBytes); + lastGen_ = gen + 1; // the Layer bumps the generation after this tick, for our own copy } /// Each cell takes the light the layout places at its position; a cell with none keeps its value. @@ -121,6 +118,7 @@ class ChannelDataSink { } } + static constexpr uint32_t kSpins = 256; ///< tries before a writer sleeps, a few microseconds of spinning EffectBase& owner_; ScratchBuffer<uint8_t> staging_{owner_}; ///< the held frame ScratchBuffer<nrOfLightsType> placement_{owner_}; ///< each cell's physical light by layout position, only under static modifiers diff --git a/src/light/effects/PictureEffect.h b/src/light/effects/PictureEffect.h index 0c8391639..77f3f7568 100644 --- a/src/light/effects/PictureEffect.h +++ b/src/light/effects/PictureEffect.h @@ -35,8 +35,8 @@ class PictureEffect : public EffectBase { controls_.addSelect("fit", fit, kFits, kFitCount); controls_.addSelect("sampling", sampling, kSamplings, kSamplingCount); } - /// The placement changes the ranges, so it refills them. - bool affectsPrepare(const char* name) const override { return std::strcmp(name, "fit") == 0; } + /// The placement changes the ranges and the sampling the picture drawn, so both redraw. + bool affectsPrepare(const char* name) const override { return std::strcmp(name, "fit") == 0 || std::strcmp(name, "sampling") == 0; } /// Size the range tables to the grid; the first picture fills them. void prepare() override { diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h index 42589bf89..ec633d787 100644 --- a/src/light/layers/Layer.h +++ b/src/light/layers/Layer.h @@ -339,10 +339,7 @@ class Layer : public MoonModule { return layers == 1; } - /// Whether static modifiers fold the box into the mapping, so its inverse would undo them. - bool foldsStatically() const { return staticCount_ > 0; } - - /// Each logical cell's physical light by the layout's own positions, `kNoDriver` where none: a wiring-order frame placed this way lands before the static modifiers, which then transform it. + /// Each logical cell's physical light by the layout's own positions, `kNoDriver` where none: a wiring-order frame placed this way lands before the modifiers, which then transform it. void placeByLayout(nrOfLightsType* out, nrOfLightsType cells) const { for (nrOfLightsType c = 0; c < cells; c++) out[c] = kNoDriver; if (!layouts_) return; diff --git a/src/light/layers/MappingLUT.h b/src/light/layers/MappingLUT.h index f8e6e729f..ddb4afd75 100644 --- a/src/light/layers/MappingLUT.h +++ b/src/light/layers/MappingLUT.h @@ -163,40 +163,6 @@ class MappingLUT { } } - /// Write a wiring-order frame into the logical buffer, inverting this mapping: each cell takes its first physical light's value; identity is one copy. - void gatherFromPhysical(uint8_t* logical, size_t logicalBytes, const uint8_t* physical, size_t physicalBytes, - uint8_t channelsPerLight) const { - if (identity_) { - std::memcpy(logical, physical, logicalBytes < physicalBytes ? logicalBytes : physicalBytes); - return; - } - if (!offsets_ || channelsPerLight == 0) return; - if (paged_) gatherWalk(logical, logicalBytes, physical, physicalBytes, channelsPerLight, - [this](nrOfLightsType s) { return pages_[s >> kPageShift][s & kPageMask]; }); - else gatherWalk(logical, logicalBytes, physical, physicalBytes, channelsPerLight, - [this](nrOfLightsType s) { return destinations_[s]; }); - } - -private: - /// The gather loop, one instance per storage tier so the page test leaves the loop; each offset is read once. - template <class Dest> - void gatherWalk(uint8_t* logical, size_t logicalBytes, const uint8_t* physical, size_t physicalBytes, - uint8_t channelsPerLight, Dest dest) const { - const size_t cpl = channelsPerLight; - const size_t physLights = physicalBytes / cpl; - const size_t cells = logicalBytes / cpl < logicalCount_ ? logicalBytes / cpl : logicalCount_; - nrOfLightsType start = offsets_[0]; - for (size_t li = 0; li < cells; li++) { - const nrOfLightsType here = start; - start = offsets_[li + 1]; - if (here == start) continue; // a cell no light shows keeps its value - const size_t first = dest(here); - if (first >= physLights) continue; - copyLight(logical + li * cpl, physical + first * cpl, cpl); - } - } - -public: /// One light's channels, the common three unrolled since it runs per light on a gather. static void copyLight(uint8_t* d, const uint8_t* s, size_t cpl) MM_NONBLOCKING { if (cpl == 3) { d[0] = s[0]; d[1] = s[1]; d[2] = s[2]; } diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 0dbd7965e..08b5e9bea 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -2157,6 +2157,9 @@ int UdpSocket::waitReadable(UdpSocket* const* sockets, int count, uint32_t timeo int maxFd = -1; for (int i = 0; i < count; i++) { if (!sockets[i] || sockets[i]->fd_ < 0) continue; +#ifndef _WIN32 + if (sockets[i]->fd_ >= FD_SETSIZE) continue; // FD_SET past the set's size is undefined on POSIX +#endif FD_SET(sock(sockets[i]->fd_), &readable); if (sockets[i]->fd_ > maxFd) maxFd = sockets[i]->fd_; } @@ -2165,7 +2168,8 @@ int UdpSocket::waitReadable(UdpSocket* const* sockets, int count, uint32_t timeo tv.tv_sec = static_cast<decltype(tv.tv_sec)>(timeoutMs / 1000); tv.tv_usec = static_cast<decltype(tv.tv_usec)>((timeoutMs % 1000) * 1000); const int r = ::select(maxFd + 1, &readable, nullptr, nullptr, &tv); - return r < 0 ? 0 : r; + if (r < 0) { delayMs(timeoutMs); return 0; } // an error must not spin the receive loop + return r; } // Test override forcing a multicast join to fail, since a test cannot take the host's network away. diff --git a/src/platform/esp32/platform_esp32_improv.cpp b/src/platform/esp32/platform_esp32_improv.cpp index 61670c2bd..b3ecced95 100644 --- a/src/platform/esp32/platform_esp32_improv.cpp +++ b/src/platform/esp32/platform_esp32_improv.cpp @@ -383,7 +383,8 @@ static void improvTask(void* /*arg*/) { esp_err_to_name(jtag_err)); } } else { - // Someone else already installed it (rare). We can still read + write through it. + // Someone else already installed it (rare). We read and write through it, and the console must too. + usb_serial_jtag_vfs_use_driver(); g_jtagReady = true; } #endif diff --git a/src/platform/esp32/platform_esp32_worker.cpp b/src/platform/esp32/platform_esp32_worker.cpp index dfd439c3c..7d6954aea 100644 --- a/src/platform/esp32/platform_esp32_worker.cpp +++ b/src/platform/esp32/platform_esp32_worker.cpp @@ -37,6 +37,7 @@ #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "esp_task_wdt.h" // the render loop and the encode worker each subscribe themselves so a wedge self-heals (reboot) not hangs +#include "esp_log.h" // sleepUs says once when it falls back to a scheduler tick #include "esp_timer.h" // sleepUs: a one-shot timer wakes the task, finer than the scheduler tick #include <atomic> @@ -59,6 +60,7 @@ struct EspWorker { std::atomic<bool> detached{false}; /// The one-shot timer sleepUs arms, created on the task's first sleep and deleted as the task ends. esp_timer_handle_t sleepTimer = nullptr; + bool sleepFellBack = false; ///< a timer failed once and the task slept a whole tick, logged that first time }; // The trampoline: run the caller's function, which owns its own loop and returns once it sees the stop flag, then delete the task. The function manages its own watchdog membership, subscribing at loop start and unsubscribing before returning; one that never subscribes leaves the feed a no-op, so this stays generic. @@ -106,6 +108,13 @@ bool waitNotify(WorkerTask& t, uint32_t timeoutMs) { return ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(timeoutMs)) != 0; } +// Without a timer the task sleeps a whole scheduler tick, which stretches a paced frame tenfold, so the first time says so. +static void sleepTick(EspWorker* w) { + if (!w->sleepFellBack) ESP_LOGW("mm_worker", "sleepUs: no timer, sleeping a scheduler tick per gap"); + w->sleepFellBack = true; + vTaskDelay(1); +} + // A one-shot esp_timer notifies the task, so a gap below the 10 ms scheduler tick costs no busy-wait; a stop's notification ends it early, as it should. void sleepUs(WorkerTask& t, uint32_t us) { auto* w = static_cast<EspWorker*>(t.impl); @@ -114,9 +123,9 @@ void sleepUs(WorkerTask& t, uint32_t us) { const esp_timer_create_args_t args = { .callback = [](void* arg) { xTaskNotifyGive(static_cast<EspWorker*>(arg)->handle); }, .arg = w, .dispatch_method = ESP_TIMER_TASK, .name = "sleepUs", .skip_unhandled_events = true}; - if (esp_timer_create(&args, &w->sleepTimer) != ESP_OK) { w->sleepTimer = nullptr; vTaskDelay(1); return; } + if (esp_timer_create(&args, &w->sleepTimer) != ESP_OK) { w->sleepTimer = nullptr; sleepTick(w); return; } } - if (esp_timer_start_once(w->sleepTimer, us) != ESP_OK) { vTaskDelay(1); return; } + if (esp_timer_start_once(w->sleepTimer, us) != ESP_OK) { sleepTick(w); return; } ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(us / 1000 + 20)); // bounded, so a lost wake costs one gap and no hang esp_timer_stop(w->sleepTimer); // a stop that woke it early leaves the timer armed } diff --git a/test/python/test_improv_smoke_test.py b/test/python/test_improv_smoke_test.py new file mode 100644 index 000000000..89db63237 --- /dev/null +++ b/test/python/test_improv_smoke_test.py @@ -0,0 +1,23 @@ +"""The smoke test's URL reading: a provisioned board's URL comes from either step, so the network check runs whenever the board reports one.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "moondeck" / "build")) + +import improv_smoke_test # noqa: E402 + + +def test_the_provision_step_reports_the_url(): + out = "==> using host SSID: home\n==> provisioned: http://192.168.1.20/\n" + assert improv_smoke_test._device_url(out) == "http://192.168.1.20/" + + +def test_the_probe_reports_a_provisioned_boards_url(): + """A board already provisioned keeps its credentials, so the probe's current state is where its URL is.""" + out = " → GET_CURRENT_STATE\n state: provisioned\n url: http://192.168.1.127/\n" + assert improv_smoke_test._device_url(out) == "http://192.168.1.127/" + + +def test_output_without_a_url_reports_none(): + assert improv_smoke_test._device_url("==> device reports PROVISIONED already\n") is None diff --git a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json index 3cd1053e7..c15b3f530 100644 --- a/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json +++ b/test/scenarios/core/scenario_Control_a_palette_preset_changes_only_the_palette.json @@ -175,11 +175,11 @@ "desktop-macos": { "tick_us": { "p50": 8, - "p95": 13, + "p95": 14, "min": 5, "max": 14, "n": 32, - "samples": [5, 5, 5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11, 8, 11, 9, 8, 8] + "samples": [5, 7, 5, 6, 5, 6, 6, 6, 5, 5, 13, 6, 5, 11, 8, 8, 8, 8, 8, 9, 14, 8, 8, 8, 11, 8, 11, 9, 8, 8, 14, 7] }, "last_updated": "2026-10-14" }, diff --git a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json index 87d7d023c..0797a64ed 100644 --- a/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json +++ b/test/scenarios/core/scenario_State_one_document_adds_an_effect_with_its_controls.json @@ -70,11 +70,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 9, + "p95": 8, "min": 1, - "max": 387, + "max": 9, "n": 32, - "samples": [1, 387, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8, 9, 7, 7, 7, 7] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 4, 1, 7, 7, 8, 7, 7, 8, 7, 7, 7, 7, 8, 9, 7, 7, 7, 7, 7, 6] }, "last_updated": "2026-10-14" }, diff --git a/test/scenarios/light/scenario_Drivers_output_and_brightness.json b/test/scenarios/light/scenario_Drivers_output_and_brightness.json index 2e2bfd3f4..15340566a 100644 --- a/test/scenarios/light/scenario_Drivers_output_and_brightness.json +++ b/test/scenarios/light/scenario_Drivers_output_and_brightness.json @@ -103,11 +103,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 15, + "p95": 9, "min": 6, - "max": 17, + "max": 15, "n": 32, - "samples": [17, 6, 7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8, 7, 7, 7, 7, 7] + "samples": [7, 7, 7, 7, 9, 8, 7, 7, 7, 6, 8, 15, 8, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 8, 7, 7, 7, 7, 7, 7, 6] }, "last_updated": "2026-10-14" }, diff --git a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json index db9988ec0..d57bbface 100644 --- a/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json +++ b/test/scenarios/light/scenario_Effects_pipeline_builds_and_renders.json @@ -51,11 +51,11 @@ "desktop-macos": { "tick_us": { "p50": 6, - "p95": 7, + "p95": 9, "min": 1, - "max": 9, - "n": 24, - "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7, 6, 6, 7, 6, 9] + "max": 11, + "n": 26, + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 7, 6, 6, 7, 6, 9, 6, 11] }, "last_updated": "2026-10-14" } @@ -83,12 +83,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 2, + "p50": 6, "p95": 8, "min": 1, "max": 8, "n": 32, - "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8, 8, 7, 7, 7, 7] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 8, 8, 7, 7, 7, 7, 8, 6] }, "last_updated": "2026-10-14" }, @@ -114,11 +114,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 9, + "p95": 10, "min": 6, "max": 10, "n": 32, - "samples": [6, 6, 6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6, 7, 7, 9, 7, 7] + "samples": [6, 6, 7, 7, 7, 7, 7, 6, 6, 10, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 6, 7, 7, 9, 7, 7, 10, 6] }, "last_updated": "2026-10-14" }, diff --git a/test/scenarios/light/scenario_Effects_swap_while_running.json b/test/scenarios/light/scenario_Effects_swap_while_running.json index eef985824..6a8d9755f 100644 --- a/test/scenarios/light/scenario_Effects_swap_while_running.json +++ b/test/scenarios/light/scenario_Effects_swap_while_running.json @@ -82,11 +82,11 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 2, + "p95": 1, "min": 1, "max": 2, "n": 32, - "samples": [1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-10-14" }, @@ -191,11 +191,11 @@ "desktop-macos": { "tick_us": { "p50": 1, - "p95": 4, + "p95": 2, "min": 1, "max": 4, "n": 32, - "samples": [1, 4, 1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] + "samples": [1, 1, 1, 4, 1, 1, 1, 1, 1, 2, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] }, "last_updated": "2026-10-14" }, diff --git a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json index aafd7ed96..e0d13aa48 100644 --- a/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json +++ b/test/scenarios/light/scenario_Effects_teardown_under_a_running_pipeline.json @@ -64,11 +64,11 @@ "desktop-macos": { "tick_us": { "p50": 8, - "p95": 18, + "p95": 15, "min": 6, "max": 81, "n": 32, - "samples": [7, 18, 7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10, 8, 10, 8, 8, 7] + "samples": [7, 6, 7, 81, 7, 7, 7, 7, 7, 15, 7, 7, 12, 7, 7, 8, 7, 8, 8, 8, 8, 8, 8, 8, 10, 8, 10, 8, 8, 7, 9, 8] }, "last_updated": "2026-10-14" }, @@ -172,11 +172,11 @@ "desktop-macos": { "tick_us": { "p50": 5, - "p95": 12, + "p95": 9, "min": 4, - "max": 32, + "max": 12, "n": 32, - "samples": [5, 32, 4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9, 6, 5, 7, 5, 5] + "samples": [4, 4, 4, 12, 5, 5, 5, 4, 6, 5, 5, 4, 7, 4, 4, 5, 4, 6, 6, 6, 6, 6, 4, 5, 9, 6, 5, 7, 5, 5, 6, 4] }, "last_updated": "2026-10-14" }, @@ -276,11 +276,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 15, - "min": 6, - "max": 19, + "p95": 9, + "min": 5, + "max": 15, "n": 32, - "samples": [7, 19, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9, 8, 7, 7, 8, 7] + "samples": [7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 6, 7, 15, 7, 7, 9, 7, 8, 8, 8, 7, 7, 7, 7, 9, 8, 7, 7, 8, 7, 7, 5] }, "last_updated": "2026-10-14" }, @@ -390,11 +390,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 14, - "min": 6, - "max": 18, + "p95": 10, + "min": 5, + "max": 14, "n": 32, - "samples": [7, 18, 6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10, 8, 7, 7, 7, 7] + "samples": [6, 6, 6, 6, 7, 7, 7, 7, 7, 7, 6, 7, 14, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 10, 8, 7, 7, 7, 7, 8, 5] }, "last_updated": "2026-10-14" }, @@ -510,11 +510,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 13, - "min": 6, - "max": 31, + "p95": 11, + "min": 5, + "max": 13, "n": 32, - "samples": [7, 31, 6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11, 7, 9, 7, 7, 6] + "samples": [6, 6, 6, 6, 7, 7, 7, 7, 7, 11, 6, 7, 13, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 11, 7, 9, 7, 7, 6, 7, 5] }, "last_updated": "2026-10-14" }, @@ -626,11 +626,11 @@ "desktop-macos": { "tick_us": { "p50": 4, - "p95": 9, + "p95": 7, "min": 3, - "max": 25, + "max": 9, "n": 32, - "samples": [4, 25, 4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 7, 5, 4, 5] + "samples": [4, 3, 3, 5, 4, 4, 4, 4, 4, 5, 4, 4, 9, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 7, 5, 4, 5, 6, 4] }, "last_updated": "2026-10-14" }, @@ -760,9 +760,9 @@ "p50": 4, "p95": 7, "min": 3, - "max": 20, + "max": 7, "n": 32, - "samples": [4, 20, 4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7, 4, 5, 5, 4, 4] + "samples": [4, 3, 3, 4, 4, 4, 4, 4, 4, 6, 4, 4, 7, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 7, 4, 5, 5, 4, 4, 4, 4] }, "last_updated": "2026-10-14" }, diff --git a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json index dd165e24c..dcf744c11 100644 --- a/test/scenarios/light/scenario_Layers_stack_and_blend_live.json +++ b/test/scenarios/light/scenario_Layers_stack_and_blend_live.json @@ -88,11 +88,11 @@ "desktop-macos": { "tick_us": { "p50": 8, - "p95": 12, + "p95": 11, "min": 8, - "max": 24, + "max": 12, "n": 32, - "samples": [8, 24, 8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10, 8, 9, 8, 8, 8] + "samples": [8, 8, 8, 9, 8, 8, 8, 8, 9, 11, 8, 8, 12, 9, 9, 8, 8, 8, 8, 9, 9, 10, 11, 9, 10, 8, 9, 8, 8, 8, 9, 10] }, "last_updated": "2026-10-14" } @@ -132,11 +132,11 @@ "desktop-macos": { "tick_us": { "p50": 15, - "p95": 35, + "p95": 19, "min": 13, "max": 37, "n": 32, - "samples": [15, 35, 15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15, 15, 15, 15, 15, 13] + "samples": [15, 15, 15, 15, 15, 15, 14, 15, 16, 37, 13, 15, 19, 15, 15, 15, 15, 15, 15, 16, 16, 16, 19, 16, 15, 15, 15, 15, 15, 13, 15, 13] }, "last_updated": "2026-10-14" } @@ -220,11 +220,11 @@ "desktop-macos": { "tick_us": { "p50": 20, - "p95": 29, + "p95": 24, "min": 18, - "max": 46, + "max": 29, "n": 32, - "samples": [20, 46, 21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20, 20, 20, 20, 22, 20] + "samples": [21, 20, 20, 21, 20, 20, 20, 20, 21, 29, 18, 20, 24, 20, 20, 21, 20, 21, 21, 21, 21, 21, 24, 21, 20, 20, 20, 20, 22, 20, 22, 18] }, "last_updated": "2026-10-14" } @@ -260,11 +260,11 @@ "desktop-macos": { "tick_us": { "p50": 124, - "p95": 162, + "p95": 141, "min": 121, - "max": 225, + "max": 162, "n": 32, - "samples": [123, 225, 124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124, 125, 128, 122, 124, 123] + "samples": [124, 122, 123, 126, 124, 123, 123, 123, 125, 141, 121, 123, 162, 124, 123, 123, 123, 123, 123, 125, 125, 125, 128, 124, 124, 125, 128, 122, 124, 123, 125, 125] }, "last_updated": "2026-10-14" } @@ -300,11 +300,11 @@ "desktop-macos": { "tick_us": { "p50": 267, - "p95": 355, + "p95": 309, "min": 262, - "max": 454, + "max": 355, "n": 32, - "samples": [267, 454, 280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272, 268, 265, 264, 267, 271] + "samples": [280, 262, 266, 304, 267, 265, 264, 275, 267, 309, 267, 265, 355, 265, 265, 265, 265, 264, 265, 274, 284, 291, 280, 267, 272, 268, 265, 264, 267, 271, 270, 287] }, "last_updated": "2026-10-14" } @@ -337,11 +337,11 @@ "desktop-macos": { "tick_us": { "p50": 56, - "p95": 85, + "p95": 69, "min": 49, "max": 100, "n": 32, - "samples": [60, 85, 56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51, 56, 55, 55, 56, 49] + "samples": [56, 56, 56, 65, 56, 65, 55, 56, 69, 56, 56, 56, 100, 56, 56, 56, 56, 56, 56, 56, 57, 61, 54, 56, 51, 56, 55, 55, 56, 49, 56, 62] }, "last_updated": "2026-10-14" } @@ -363,11 +363,11 @@ "desktop-macos": { "tick_us": { "p50": 52, - "p95": 97, + "p95": 76, "min": 46, "max": 103, "n": 32, - "samples": [53, 97, 52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53, 52, 52, 52, 52, 46] + "samples": [52, 52, 54, 57, 53, 54, 52, 52, 75, 103, 52, 52, 76, 52, 52, 58, 52, 52, 52, 53, 54, 55, 51, 55, 53, 52, 52, 52, 52, 46, 53, 48] }, "last_updated": "2026-10-14" } @@ -403,7 +403,7 @@ "min": 46, "max": 281, "n": 32, - "samples": [51, 85, 51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48, 52, 51, 51, 51, 46] + "samples": [51, 51, 52, 56, 51, 56, 51, 52, 51, 205, 52, 51, 281, 51, 51, 51, 52, 51, 55, 51, 54, 52, 54, 52, 48, 52, 51, 51, 51, 46, 51, 51] }, "last_updated": "2026-10-14" } diff --git a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json index 2395c5404..92d3f1352 100644 --- a/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json +++ b/test/scenarios/light/scenario_Layouts_resize_reallocates_live.json @@ -82,7 +82,7 @@ "min": 6, "max": 23, "n": 32, - "samples": [7, 14, 7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7, 8, 8, 11] + "samples": [7, 6, 7, 23, 7, 7, 7, 7, 7, 20, 8, 8, 22, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7, 8, 8, 11, 8, 8] }, "last_updated": "2026-10-14" }, @@ -191,7 +191,7 @@ "min": 9, "max": 22, "n": 32, - "samples": [11, 19, 10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9, 10, 10, 11, 11, 10] + "samples": [10, 11, 10, 22, 10, 11, 10, 10, 10, 17, 10, 10, 19, 10, 10, 11, 10, 11, 11, 11, 11, 10, 9, 11, 9, 10, 10, 11, 11, 10, 10, 10] }, "last_updated": "2026-10-14" }, @@ -303,11 +303,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 14, + "p95": 10, "min": 6, "max": 14, "n": 32, - "samples": [7, 14, 6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 10, 7, 7, 7, 6] + "samples": [6, 6, 6, 9, 6, 7, 7, 7, 7, 9, 7, 6, 14, 6, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 10, 7, 7, 7, 6, 7, 6] }, "last_updated": "2026-10-14" }, diff --git a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json index 891a9741c..e1d6159e9 100644 --- a/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json +++ b/test/scenarios/light/scenario_Modifiers_reshape_the_mapping.json @@ -63,11 +63,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 11, + "p95": 10, "min": 6, - "max": 19, + "max": 11, "n": 32, - "samples": [7, 19, 7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7, 9, 7, 7, 7, 7] + "samples": [7, 6, 6, 8, 6, 7, 8, 11, 7, 8, 8, 6, 10, 7, 7, 8, 7, 7, 7, 7, 7, 8, 7, 7, 7, 9, 7, 7, 7, 7, 7, 6] }, "last_updated": "2026-10-14" }, @@ -179,11 +179,11 @@ "desktop-macos": { "tick_us": { "p50": 3, - "p95": 9, + "p95": 5, "min": 3, - "max": 11, + "max": 9, "n": 32, - "samples": [3, 11, 3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 5, 3, 3, 3, 4] + "samples": [3, 3, 3, 4, 4, 4, 3, 3, 3, 5, 4, 3, 9, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 5, 3, 3, 3, 4, 3, 4] }, "last_updated": "2026-10-14" }, @@ -288,11 +288,11 @@ "desktop-macos": { "tick_us": { "p50": 3, - "p95": 8, + "p95": 5, "min": 3, "max": 9, "n": 32, - "samples": [3, 8, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4, 4, 3, 3, 3, 4] + "samples": [3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 3, 9, 3, 3, 4, 3, 4, 3, 3, 4, 4, 4, 4, 4, 4, 3, 3, 3, 4, 3, 4] }, "last_updated": "2026-10-14" }, @@ -396,11 +396,11 @@ "desktop-macos": { "tick_us": { "p50": 3, - "p95": 5, + "p95": 3, "min": 2, - "max": 6, + "max": 5, "n": 32, - "samples": [2, 6, 2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3] + "samples": [2, 2, 2, 3, 3, 2, 3, 2, 2, 3, 3, 3, 5, 3, 2, 3, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 3, 3, 3, 2, 3] }, "last_updated": "2026-10-14" }, @@ -509,12 +509,12 @@ "observed": { "desktop-macos": { "tick_us": { - "p50": 3, - "p95": 7, + "p50": 4, + "p95": 5, "min": 3, - "max": 11, + "max": 7, "n": 32, - "samples": [3, 11, 3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 4] + "samples": [3, 3, 3, 4, 3, 3, 3, 3, 3, 5, 4, 5, 7, 3, 3, 4, 3, 4, 3, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 4, 4, 4] }, "last_updated": "2026-10-14" }, @@ -626,11 +626,11 @@ "desktop-macos": { "tick_us": { "p50": 3, - "p95": 6, + "p95": 4, "min": 3, - "max": 9, + "max": 6, "n": 32, - "samples": [3, 9, 3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4, 4, 3, 3, 3, 4] + "samples": [3, 3, 3, 4, 3, 3, 3, 3, 3, 4, 4, 3, 6, 3, 3, 4, 3, 4, 3, 3, 4, 4, 3, 4, 4, 4, 3, 3, 3, 4, 3, 4] }, "last_updated": "2026-10-14" }, @@ -740,11 +740,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 12, + "p95": 11, "min": 6, - "max": 20, + "max": 12, "n": 32, - "samples": [7, 20, 7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8, 8, 7, 7, 7, 11] + "samples": [7, 6, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 12, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 8, 8, 7, 7, 7, 11, 7, 6] }, "last_updated": "2026-10-14" }, diff --git a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json index ca7ac4c69..654ccd5ef 100644 --- a/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json +++ b/test/scenarios/light/scenario_Palettes_a_script_drives_the_effects.json @@ -103,11 +103,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 12, + "p95": 11, "min": 6, - "max": 74, + "max": 12, "n": 32, - "samples": [74, 7, 6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9, 8, 7, 7, 7, 12] + "samples": [6, 6, 7, 6, 6, 6, 7, 8, 11, 7, 7, 9, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 9, 8, 7, 7, 7, 12, 7, 7] }, "last_updated": "2026-10-14" } @@ -171,11 +171,11 @@ "desktop-macos": { "tick_us": { "p50": 7, - "p95": 11, + "p95": 8, "min": 6, - "max": 108, + "max": 11, "n": 32, - "samples": [108, 8, 6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 8] + "samples": [6, 6, 7, 7, 7, 7, 7, 7, 8, 6, 6, 11, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 8, 7, 6] }, "last_updated": "2026-10-14" } diff --git a/test/unit/core/unit_platform_worker.cpp b/test/unit/core/unit_platform_worker.cpp index d30d2ce70..0e0a02bde 100644 --- a/test/unit/core/unit_platform_worker.cpp +++ b/test/unit/core/unit_platform_worker.cpp @@ -1,6 +1,6 @@ /// @module platform -/// Pins the worker-task seam the multicore render/encode split is built on. On the host the seam is a std::thread and a condition variable, so these tests run the real handoff where CI can catch a race. The core pin is a no-op there. +/// Pins the worker-task seam the multicore render/encode split is built on. On the host the seam is a std::thread and a condition variable, so these tests run the real handoff where CI can catch a race. The host leaves the core to the operating system. #include "doctest.h" #include "platform/platform.h" diff --git a/test/unit/light/unit_NetworkReceiveEffect.cpp b/test/unit/light/unit_NetworkReceiveEffect.cpp index 1d938ff36..a3f499f83 100644 --- a/test/unit/light/unit_NetworkReceiveEffect.cpp +++ b/test/unit/light/unit_NetworkReceiveEffect.cpp @@ -8,6 +8,7 @@ #include "light/layouts/GridLayout.h" #include "light/modifiers/MirrorModifier.h" #include "light/modifiers/TransposeModifier.h" +#include "light/effects/SolidEffect.h" #include "platform/platform.h" #include <cstring> @@ -391,6 +392,20 @@ size_t serpentineIndex(size_t x, size_t y, size_t z, size_t w, size_t h) { } } // namespace +// Beside another effect a sender-mapped frame shares the layer's buffer, so it is placed on the grid by the layout's positions and mapped forward like any effect. +TEST_CASE("a sender-mapped frame beside another effect still reaches each light as sent") { + mm::SolidEffect under; // ticks first, so the received frame is the last word on every cell + OrderRig r(4, 4, 1, mm::NetworkReceiveEffect::kMappedBySender); + r.layer.removeChild(&r.fx); + r.layer.addChild(&under); + r.layer.addChild(&r.fx); + under.defineControls(); + r.layer.applyState(); + CHECK(r.layer.wiringSource() == nullptr); + r.sendNumbered(16); + r.checkLightsAsSent(16); +} + // A mirror on a sender-mapped stream mirrors the frame: each light shows what was sent for the light its position folds onto, rather than the mirror undoing itself. TEST_CASE("a mirror on a sender-mapped serpentine grid mirrors the frame") { mm::MirrorModifier mirror; diff --git a/test/unit/light/unit_PictureEffect.cpp b/test/unit/light/unit_PictureEffect.cpp index 0c29f3a0c..890788295 100644 --- a/test/unit/light/unit_PictureEffect.cpp +++ b/test/unit/light/unit_PictureEffect.cpp @@ -124,11 +124,14 @@ TEST_CASE("PictureEffect does not sample again without a new picture") { CHECK(buf[0] == 255); } -// Without a Picture service the effect says what to add and leaves the layer alone. -TEST_CASE("PictureEffect without a Picture service says so") { +// Without a Picture service the effect says what to add and shows black rather than its last picture. +TEST_CASE("PictureEffect without a Picture service says so and shows black") { PictureRig r(8, 3, 8, 3); + r.layer.tick(); + REQUIRE(r.cell(0, 0)[0] == 255); // the white bar, while the service publishes r.pic.release(); r.layer.tick(); REQUIRE(r.fx.status() != nullptr); CHECK(std::string(r.fx.status()) == "no picture: add a Picture service under Services"); + CHECK(r.cell(0, 0)[0] == 0); }