diff --git a/CLAUDE.md b/CLAUDE.md
index 3fbebe1..62415b8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,7 +21,7 @@ See `docs/architecture.md` for system design. This file contains only rules and
- **Robust to any input.** A running device tolerates any sequence of UI actions or API calls: add, delete, replace, or reconfigure any module in any order, at any grid size, and it keeps running. Degraded or idle is acceptable; crashed is not. This robustness is a defining strongpoint of projectMM, and it's guarded by the test framework, not by hope: a discovered crash drives a new test that pins the fix (see the Hard Rule). Out of scope: power loss, malformed OTA, brown-out, and other physical/electrical faults the firmware can't intercept; this principle is about what the software accepts as input.
- **No reboot to apply a configuration change.** Every setting takes effect live, on the next render tick — change a pin map, a strand length, an output protocol, a mic pin or rate, anything, on a running device and it just works. There is no init-once-at-boot step, and no *config* change requires a restart, which sets projectMM apart from most LED-controller firmware (where a pin or protocol change means a reboot). Like robustness, this is a defining strongpoint, and it falls out of the architecture for free rather than being hand-built per module: any control whose change reshapes derived state routes through the generic `onBuildState()` rebuild sweep, so drivers, the audio peripheral, effects, layouts, modifiers and network I/O all inherit it. When adding a feature, don't reach for a reboot/restart to apply config; make the change live. Full mechanism + rationale: [architecture.md § Live reconfiguration](docs/architecture.md#live-reconfiguration-every-change-applies-without-a-reboot). The one exception is what you'd expect: a *firmware* OTA flash swaps the binary and needs the usual power cycle — that's not a configuration change, and (like power loss and brown-out) it's the same physical-fault boundary the robustness principle draws.
- **Domain-neutral core.** Separate core infrastructure from the light domain as much as practical. When mixing is necessary, use domain-neutral naming so the code stays open to future separation.
-- **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking).
+- **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. This bans not just future-tense ("will be", "planned") but **absence-narration**: phrases like "no longer", "anymore", "formerly", "used to", "X was removed", or "there's no longer a Y" describe a *change from a past state* a present-tense reader never saw — state what *is*, not what stopped being. (The test: "there is no MCLK pin" is a present-tense *property* — keep it; "there's no SET_DEVICE_MODEL RPC anymore" narrates a removal — cut it, just describe the path that exists.) Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking) — and `decisions.md` lessons, which legitimately contrast before/after because the contrast *is* the lesson.
## Hard Rules
diff --git a/docs/architecture.md b/docs/architecture.md
index 999134f..b1f8253 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -210,7 +210,7 @@ Only abstract what you actually need. Currently:
- **Time**: `millis()`, `micros()`. Monotonic, microsecond resolution. (`esp_timer` / `std::chrono`)
- **Memory**: `alloc(size)`, `free(ptr)`. Prefers PSRAM on ESP32, falls back to regular heap. `freeHeap()`, `maxAllocBlock()` for diagnostics. (`heap_caps_malloc` / `std::malloc`)
-- **Networking**: `UdpSocket` for ArtNet send. `TcpConnection` / `TcpServer` for HTTP + WebSocket; `TcpConnection::writeChunks` is a non-blocking scatter-gather write so a backpressured browser can't stall the render loop. (lwIP sockets / BSD sockets)
+- **Networking**: `UdpSocket` for ArtNet send. `TcpConnection` / `TcpServer` for HTTP + WebSocket; `TcpConnection::writeSome` is a non-blocking partial write (returns bytes written, 0 = would-block) so a backpressured browser can't stall the render loop. (lwIP sockets / BSD sockets)
- **Scheduling**: `yield()` (cooperative yield to OS/RTOS), `delayMs(ms)` (blocking sleep, off-path only), `delayUs(us)` (microsecond busy-wait, only for sub-millisecond hardware timing a driver owns — e.g. the WS2812 ≥300 µs inter-frame latch in `RmtLedDriver`; never for general pacing, which uses the non-blocking `millis()` gate), `reboot()`. (`vTaskDelay` / `esp_rom_delay_us` / `esp_restart` on ESP32; `std::this_thread::sleep_for` / `std::exit` on desktop)
- **Platform config**: `platform_config.h` per platform: compile-time constants like `hasPsram` and `hasWiFi`. Each platform provides its own version; `types.h` includes it without `#ifdef`. Core code branches on these via `if constexpr` (e.g. NetworkModule drops its WiFi cascade when `hasWiFi` is false), so the dead branch is removed from the binary with no `#ifdef` outside `src/platform/`.
@@ -311,6 +311,8 @@ Modules in the light pipeline can be added, replaced, or removed dynamically at
- *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path: each consumer holds a `const`-pointer and reads it per frame. The pointers are **(re)bound on every rebuild**, not just at boot: `Drivers::onBuildState()` re-resolves the active `Layer` (`Layers::activeLayer()`) and calls `passBufferToDrivers()`, which re-runs `setSourceBuffer()`/`setLayer()` on each child (clearing them to `nullptr` when there is no active Layer). So a held pointer is valid only until the next rebuild — which is exactly why the consumers re-read it each frame and tolerate a null (the [robustness rule](#robustness)): a Layer add/delete/replace re-binds or clears it live, no dangling reference.
- *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview: the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/drivers/PreviewDriver.md).
+**Graceful degradation under transport backpressure.** The preview is the project's worked example of a property worth naming generically, because it is the transport-side sibling of the memory-side [§ Degradation cascade](#degradation-cascade): when a consumer can't keep up, **shed quality, never the connection or the render loop.** The link to a browser is the slow consumer; a full-resolution frame (128² = 16384 lights = ~49 KB) may not drain in the budget one tick allows. Rather than block the loop until it drains (a stall) or drop the client (a black preview), the producer **degrades**: it streams from the producer buffer with no intermediate copy, sheds resolution via a spatial-lattice downsample when the link can't sustain full density, and adapts that factor from whether the previous frame reached every client — the same congestion-responsive, adaptive-bitrate idea behind HLS/DASH/WebRTC, applied to a binary WebSocket. The render loop is never charged more than a bounded slice per tick; the worst case is a coarser or slower preview, which is acceptable for a *view* of the output (the LEDs themselves are unaffected). This is *graceful degradation*: a fast link sees every light at full rate, a slow link sees a faithful coarser sample, and neither stalls the device. The mechanism (resumable cross-tick send + newest-wins backpressure + adaptive lattice) lives in `PreviewDriver` + `HttpServerModule` today; it is payload-agnostic, so a future bulky stream could ride the same transport.
+
**Naming convention.** Capital `Layouts`, `Layers`, `Drivers` are class names (always capitalised when referring to the class). Lowercase "layouts", "layers", "drivers" is the English plural, used freely when context makes it clear. Singular "layout", "layer", "driver" is an individual instance.
## 3D from the start
diff --git a/docs/backlog/backlog.md b/docs/backlog/backlog.md
index 30c9cfa..5939884 100644
--- a/docs/backlog/backlog.md
+++ b/docs/backlog/backlog.md
@@ -292,8 +292,6 @@ Several `platform.h` APIs still use `(buf, len)` pairs where `std::span` would c
Device-model injection over Improv shipped as **"Improv = REST over serial"** (the `APPLY_OP` vendor RPC pushes the whole `deviceModels.json` entry over serial during install; the device runs the same apply-core the HTTP REST API does, on WiFi *and* eth-only firmware). That subsumed the earlier multi-step "board injection + Improv as a general data injector" plan — the general injector *is* APPLY_OP. What remains:
-**Open follow-up: per-control validator hook on `ControlDescriptor`.** `SystemModule::setDeviceModel()` validates ASCII-printable (rejecting control bytes, embedded NUL); the HTTP `POST /api/control` write path uses the generic `applyControlValue()` in `Control.cpp` which has no per-control validator and writes the raw bytes through. Acceptable today (HTTP-write callers source values from `deviceModels.json` which the project controls), but the right fix is a per-control validator hook on `ControlDescriptor` so any control can declare an inline validation function pointer. Worth doing when the next control with non-trivial input constraints lands, or when the threat model grows (an integration accepts arbitrary external input and POSTs it through). Sketch: `ControlDescriptor` grows a `bool (*validate)(const void*, size_t)` slot defaulting to nullptr; `applyControlValue` calls it before writing and returns `ApplyResult::Malformed` on false; `addText` / `addPassword` get an optional validator argument. Touches ~5 sites; no protocol change.
-
**Open follow-up: closed-loop APPLY_OP pacing (read-back ack + retry).** The installer paces APPLY_OP frames open-loop (`sendApplyOpFrame` waits a fixed ~120 ms between ops) rather than reading the device's ack back, because a Web Serial duplex read while the writer lock is held is awkward. The delay covers the worst-case single-buffer consume window with headroom, and each op is idempotent (a lost op re-applies cleanly on a re-flash), so this is robust today. The closed-loop upgrade — read the RPC response, retry once on error `0x82` (buffer busy) — removes the fixed delay (faster install) and makes op-loss impossible rather than improbable. Worth doing if a real install is ever observed dropping an op, or when the config push grows large enough that the cumulative fixed delay is noticeable. Touches only `install-orchestrator.js`.
**Open follow-up: shared JS helpers across device-UI and web-installer.** `safeLocalGet` / `safeLocalSet` (3-line hostile-storage guards) are duplicated in `src/ui/install-picker.js` (device firmware, embedded as a C string via `embed_ui.cmake`) and `docs/install/devices.js` (web installer page, served from Pages). The two live in different build contexts so the shared extract isn't trivial — it'd need a new `src/ui/safe-storage.js` plus updates to: `embed_ui.cmake` (embed the new file), `ui_embedded.h` generator (new C array), HTTP server file routing (new path served), `release.yml` workflow staging, `preview_installer.py` staging. Five files for one 3-line helper is too much pre-merge. Worth doing when the next shared helper arrives — `relativeTime` and `formatBytes` are candidates. Two helpers earn the build-glue cost; one doesn't.
@@ -395,6 +393,22 @@ The preview index-downsamples a large layout to fit the WS send budget (e.g. 128
Not simple — own planning pass. Until then the preview is a faithful strided *sample* (correct shape/colour/motion, not per-pixel). A cheap interim (point-size scaled by stride to fatten samples into their cells) was tried and reverted as not what's wanted — it filled the volume but didn't add real points.
+### Self-describing preview frame header (mid term)
+
+The preview wire format is a private opcode protocol: `0x02` per-frame channels, `0x03` coordinate table, each a hand-rolled byte layout, and the colour payload is **always RGB** regardless of the buffer's `channelsPerLight`. Every new data kind (RGBW display, beam direction, …) means inventing another opcode and another fixed layout by hand. The minimal fix that stops that sprawl: a small **typed header** — `[type][format][count][stride]` where `format` enumerates `{RGB, RGBW, …}` — so one message kind carries any per-light channel layout and the browser shader reads `format` to interpret the payload. Do it concrete-first, when RGBW *display* (below) is actually wanted, not speculatively. Prereq for both items below.
+
+### RGBW preview end-to-end (mid term)
+
+The light `Buffer` already holds `channelsPerLight = 4` (RGBW), and the device output drivers handle it, but the **preview only ever sends/draws RGB** — the W channel is invisible in the UI. (The full-res fast path no longer penalises a cpl≥3 buffer — see the short-term fix — but it still drops W on the wire.) Once the self-describing header lands, carry the W channel on the wire and render it in the shader (W as a warm-white tint / brightness lift on the disc). Small, but gated on the header so it isn't another bespoke opcode.
+
+### Fixture model — moving heads, beams (long term)
+
+Today a "light" is a point at a static coordinate with a colour. A **moving head** is a fixture that emits a *beam* in a direction it controls live (pan + tilt), plus colour, beam-width, etc. — per-light **vector** state, not just colour, and a different draw (a cone/ray, not a disc). The static-positions-`0x03` + colour-`0x02` split can't express "this fixture's beam now points here." The industry-standard model is **DMX/GDTF fixtures**: a fixture has a position *and* a set of typed attributes (color, pan, tilt, beam). The preview becomes a fixture renderer (disc for a pixel, cone for a beam); this is also the "make Preview a general-purpose module, not light-specific" goal. A domain-model change (the fixture/attribute model), not just transport. Plan when moving heads are actually on the bench.
+
+### Extract the resumable backpressure transport as a domain-neutral channel (long term)
+
+The preview's transport — resumable cross-tick send from a stable buffer + newest-wins backpressure drop + adaptive graceful degradation (see [architecture.md § graceful degradation under transport backpressure](../architecture.md)) — is **payload-agnostic**: any bulky throttled stream (a future MJPEG/video preview, fixture-state streams, fleet telemetry) could ride it. The *payload* model (count/stride/RGB) is light-specific; the *byte-pump* is not. When a second consumer for this transport appears, promote the pump into a domain-neutral core primitive (a `ThrottledChannel`-style sink) that PreviewDriver becomes *a* producer on, rather than owning the protocol. Concrete-first: extract on the second use, not before — until then the seam stays inside HttpServerModule/PreviewDriver.
+
---
## Testing
diff --git a/docs/building.md b/docs/building.md
index 7359ab8..0a8dcff 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -188,7 +188,7 @@ If a firmware *key* changes its feature set (e.g. the classic `esp32` collapse t
Each ESP32-S3 SKU has its own firmware key because the sdkconfig fragment encodes flash size, partition table, and PSRAM mode — flashing an `n16r8` binary onto a different module (e.g. N8R2) either misaligns the partition table (boot loop) or fails PSRAM init. New SKUs become new keys (e.g. `esp32s3-n8r8`); there is no generic `esp32s3` shortcut.
-The Ethernet PHY type and pin map are runtime config, not baked into the build: each firmware carries the driver(s) its chip can host (RMII EMAC for classic/P4, W5500 SPI for S3), and `deviceModels.json` supplies the per-board PHY/pins (pushed into NetworkModule's eth controls at provision). The Olimex pins are the classic chip default, so a board with the same LAN8720 PHY but different pinout (e.g. WT32-ETH01 with reset on GPIO 16) just needs a different `deviceModels.json` entry — no rebuild.
+The Ethernet PHY type and pin map are runtime config, not baked into the build: each firmware carries the driver(s) its chip can host (RMII EMAC for classic/P4, W5500 SPI for S3), and `deviceModels.json` supplies the per-board PHY/pins (pushed into NetworkModule's eth controls at provision). The classic chip default is the common LAN8720 RMII wiring (reset GPIO 5, MDIO addr 0, clock GPIO 17 — e.g. the Olimex ESP32-Gateway), so a board with the same PHY but a different pinout (e.g. WT32-ETH01 with reset on GPIO 16) just needs a different `deviceModels.json` entry — no rebuild.
`--profile` is accepted one release for migration: `--profile default` → `--firmware esp32`, `--profile eth-only` → `--firmware esp32-eth`. The legacy `build_esp32_ethonly.py` wrapper still works (it now forwards `--firmware esp32-eth`).
diff --git a/docs/history/decisions.md b/docs/history/decisions.md
index 318f3f8..c5f5ae6 100644
--- a/docs/history/decisions.md
+++ b/docs/history/decisions.md
@@ -697,3 +697,7 @@ The installer was reworked so a board catalog ([`boards.json`](../install/boards
**`deviceName` (identity) vs `deviceModel` (product) vs board (bare PCB) — one term was doing three jobs.** "Board" had been overloaded to mean the per-unit network identity, the hardware product/catalog key, AND the bare PCB. Untangling it: `deviceName` is the **per-unit identity** — one string that drives mDNS (`.local`), the SoftAP name, and the DHCP hostname, so the device shows up under one name everywhere; it's RFC-1123-coerced (`sanitizeHostname`) because it becomes a hostname. `deviceModel` is the **hardware product** (the `deviceModels.json` catalog key, e.g. "projectMM testbench S3") — display-form, spaces allowed, never a hostname. "Device" is the umbrella noun; "board" now means **only the bare PCB**. This drove the BoardModule→SystemModule fold (the identity is core unit state, not a separate module), the `board`→`deviceModel` rename across catalog/installer/Improv (SET_BOARD→SET_DEVICE_MODEL, byte 0xFE unchanged), and the eth pin-map clarification (driver = firmware, pin map = firmware-seeded but **deviceModel-authoritative** so an Olimex entry can override). Lesson: when one noun answers three different questions ("what do I call this unit on the network?", "what product is it?", "what's the bare board?"), that's a naming smell — split it into the qualified terms, pick one umbrella word, and make the split visible in every layer (control names, RPC symbols, catalog keys, docs) so the three concepts can't re-merge.
**"Improv = REST over serial" — one apply-core, two transports, and the testability that follows from extracting the hard part.** The deployed HTTPS installer couldn't configure a flashed device: a browser blocks an HTTPS page from POSTing to an `http://` device (mixed-content), and the `?deviceModel=` pull/handoff that replaced it only ran if the user opened that exact link. The fix reframed the problem — the installer already owns the USB serial port during provisioning, so push the config over it as the *same REST operations the HTTP API runs*: a new `APPLY_OP` (0xFC) Improv vendor RPC whose payload is `{"op":"add|set|clearChildren",…}`, the same JSON a `POST /api/modules`/`/api/control` body carries. On the device the op routes to **one transport-free apply-core** (`HttpServerModule::applyAddModule/applySetControl/applyClearChildren/applyOp`) the HTTP handlers also call, so a network REST call and a serial APPLY_OP execute identical code; the handlers became thin `switch(applyX())` → status-code mappers. This **deleted** the whole browser handoff (device-side catalog fetch, `?deviceModel=` decoration, the inject button) — a net subtraction — and works on Ethernet-only firmware once the Improv listener is decoupled from WiFi (the vendor RPCs compile in unconditionally; only `WIFI_SETTINGS`/`GET_WIFI_NETWORKS` stay `#ifndef MM_NO_WIFI`). Lesson 1: when a push is blocked by the *medium* (mixed-content on HTTPS), look for a medium you already control (the serial port mid-flash) instead of bolting on a fragile pull. Lesson 2 (the one with legs): the way to make it *provable* was to **extract the hard part into a pure core primitive** — the chunk reassembly + out-of-order/duplicate sequence guard moved from the ESP32-only handler into `src/core/ImprovOpReassembler.h` (header-only state machine, returns `Continue/Ready/Error`), and the JS frame builders into `docs/install/improv-frame.js` so `node:test` imports them without the orchestrator's browser deps. Both are *Complexity lives in core; domain modules stay simple* applied for testability: the device handler keeps only its serial I/O, the algorithm gets unit-tested on the desktop, and a format implemented three times (device C++, Python, JS) is pinned by **one shared golden vector** asserted in `test/python` + `test/js` — a contract test is the right answer to *forced* duplication no shared compilation target can remove. The reflex worth keeping: a hard mechanism buried in a platform `.cpp` that "can only be tested on hardware" is a smell — extract its pure core, and "rock solid proven" becomes a unit test instead of a bench session.
+
+**A periodic re-broadcast to let late joiners "catch up" is a hack wearing a keepalive costume.** The 3D preview sends a coordinate table (positions) once, then per-frame colour. The original implementation re-sent the *whole table every ~1 second* "so a client that connected after the last rebuild catches it." It looked fine — a fresh page recovered within a second — so it shipped and sat there. But it's a workaround, not the mechanism: it rebuilt the full table from the layout **every tick-second forever**, on the hot path, whether or not anyone had connected and whether or not anything changed — and it papered over a missing request/response with polling. The correct construct is event-driven: send the table **when it actually changes** (`onBuildState` — grid/layout/LUT rebuild) and **when a client asks** (a new WS connection bumps `BinaryBroadcaster::clientGeneration()`, which `PreviewDriver::loop()` watches and re-sends on change). That's strictly *less* code than the timer and zero idle cost. How it sneaked past review: the workaround *worked* in casual testing and its cost was invisible until a later change made each rebuild heavier and the per-module tick was profiled. Lessons: (1) "re-send periodically so it eventually syncs" is the polling-instead-of-events smell — ask "what's the event that should trigger this?" and trigger on *that*; (2) a recurring rebuild on the hot path must justify itself every tick, so "every second, just in case" fails *Data over objects / fastest hot path* on sight; (3) this is *Continuous refactor, no hacks* — the fix isn't a scheduled cleanup, it's "the moment you see a keepalive timer standing in for a request, replace it." The guard is a test that advances the clock several seconds with no client change and asserts the table is **not** re-sent (the old timer would have).
+
+**Don't hold a vendor library's async handle across your own event loop — it races the library's internal timers.** A UI refresh intermittently crashed the device (`assert failed: xQueueSemaphoreTake queue.c:1709 (( pxQueue ))` — a null FreeRTOS queue — inside the espressif mDNS component's `mdns_query_async_get_results`, plus an `Interrupt wdt timeout`). The mDNS *browse* (discovering peers for the "Your devices" list — distinct from mDNS *advertise*, which serves `.local` and was never the problem) used the async API: `mdns_query_async_new` returns a handle that `DevicesModule` held across ticks, polling it each `loop1s` with a 0 ms timeout. The trap: the mDNS component's **own task** owns that handle's queue and **frees it when the query's window (3 s) expires** — so a poll landing in the gap after expiry asserts on a freed queue. It was intermittent and grid-size-sensitive (a bigger grid lengthens the tick, widening the gap) and looked like "refresh crashes it" only because a refresh's activity coincided with the poll. **First fix attempt was wrong:** I assumed a *service-table mutation* (live rename re-registering `_http._tcp`) tore the handle down and added a cancel-before-mutate guard — it didn't fix it, because the freeing party is the component's expiry timer, not our code. **Real fix:** stop using the async-handle API entirely — replace the start/poll/stop trio with one synchronous `mdnsBrowse()` (`mdns_query_ptr`) that queries, delivers results, and frees everything in a single call, holding **no handle across ticks**, so the race window can't exist. The catch that synchronous introduced: `mdns_query_ptr` blocks the *full* timeout (it waits the whole window for late responders, no early return) and `loop1s` is charged to the tick — an 80 ms query tanked the tick. So **throttle**: browse one service type every ~8th tick with a ~60 ms timeout — one brief hiccup every ~8 s, invisible for discovery, FPS untouched in between. Lessons: (1) a library's async/iterator handle is only valid between *its* lifecycle events — if you can't see/where those fire (here, an internal expiry timer on another task), don't hold the handle across your loop; prefer a self-contained synchronous call that owns the whole lifecycle. (2) An *intermittent, load-dependent* crash whose backtrace sits in a vendor component is a **lifecycle race**, not a component bug — but find the *actual* concurrent actor before "fixing" (my first guess at the actor was wrong and the fix did nothing). (3) Trading async for synchronous trades a race for a blocking cost — budget it (throttle + bound the timeout) so the cure isn't a tick-killer. (4) Desktop stubs these mDNS calls to no-ops, so it's a hardware-only fix the unit suite can't reach; the reproduction (concurrent WS churn at a large grid → crash before, stable after, uptime climbing) is the proof, in the commit, not a desktop test.
diff --git a/docs/history/plans/Plan-20260622 - Non-blocking preview send.md b/docs/history/plans/Plan-20260622 - Non-blocking preview send.md
new file mode 100644
index 0000000..f77fbcb
--- /dev/null
+++ b/docs/history/plans/Plan-20260622 - Non-blocking preview send.md
@@ -0,0 +1,76 @@
+# Plan — Non-blocking preview send: high-resolution preview without stalling the render task
+
+## Context
+
+**The problem.** On a large grid (128² = 16K LEDs) the WebSocket preview shows nothing or kills the connection. The goal: the preview runs fluently at large sizes, reaching **16K on a no-PSRAM classic ESP32** and higher on PSRAM boards.
+
+**What the measurements proved (P4, 2026-06-22) — our own diagnosis.** The wall is **not** RAM, **not** bandwidth (72 KB/s at 128²), **not** the render tick (343 µs, 8× headroom). The wall is the *send mechanism*: a preview frame is one **synchronous `writev` on the shared HTTP/render task**.
+- A frame over ~5 KB (≈1700 points) can't go out in one `lwip_writev`; it partial-writes → `broadcastBinary` closes the connection (preview vanishes above ~48²). Bisected live: 32² streams, 48² drops.
+- Raising the drain budget made it **worse** — a 60 ms drain at 128² blocked the shared task long enough to starve HTTP accept; the WS handshake itself failed. Confirmed live.
+
+**Root cause, named.** We're doing a **blocking, all-or-nothing send on the render thread**. The textbook fix is the standard one for any producer that must hand bulk data to a slower consumer over a socket: **a non-blocking bounded queue with backpressure** — the producer copies the frame and returns; a separate drain step writes it out in slices as the socket accepts them; if the producer outruns the consumer, the newest frame is **dropped, not blocked** (backpressure). This is the same producer/consumer discipline the render pipeline already uses (effects produce, drivers consume); here the consumer is the socket.
+
+**Why this reaches 16K without downsampling (the analysis).** Once the send is enqueue-and-drain, the per-frame cost is no longer "what fits one `writev`." The remaining limits are concrete and addressable:
+- **16-bit WS frame length** (`broadcastBinary` max 65535 B = 21843 pts) → extend to the RFC 6455 **64-bit length form** (~10 lines, localized). Ceiling gone.
+- **`count` field is `u16`** in the frame header (max 65535 pts) → widen to `u32` (the system already supports >65K lights: `nrOfLightsType` is `u32` on PSRAM boards).
+- **Staging-buffer RAM** (the queued frame, `pts*3`): 16K = **48 KB**, 64K = 192 KB. `platform::alloc` prefers PSRAM with internal-RAM fallback, so a classic board fits ~48 KB in internal RAM and PSRAM boards fit far larger. This *is* the "16K easily, above that needs PSRAM" boundary — derived, not guessed.
+- **Drain latency** (not blocking): a 48 KB frame drains over ~10 `loop20ms` ticks ≈ 200 ms, so the preview frame-rate **adapts down** (~5 fps at 16K) — fine for a preview, the tick never stalls.
+
+**Conclusion: raise the cap empirically, keep downsampling as the tested fallback — do NOT remove it.** The enqueue model lets the cap rise well past 1800, but we don't declare a number from a spreadsheet: per the test-first principle, **raise it and measure where it actually breaks on each board** (classic internal-RAM limit, PSRAM headroom, drain-latency floor). The spatial-lattice downsample (already built) **stays** as the deliberate fallback beyond the tested cap — bigger panels will hit limits, so the graceful-degrade path must remain. The cap is **RAM-derived with a measured safety margin**.
+
+**>65K lights is a real target (big ArtNet HUB75 walls).** The system already supports it (`nrOfLightsType` u32 on PSRAM; GridLayout anticipates `512×512 > 65535`), but the preview wire `count` is `u16` — a contradiction. Widening to `u32` is in scope so a >65K panel can be previewed (downsampled to whatever staging RAM allows, but the *count* is no longer capped at 65535).
+
+**Forward-compatible with the producer/consumer two-task split (architecture.md §145).** The two-core design lands soon. This enqueue/drain model **is that shape**: `broadcastBinary` enqueue = producer handoff; `drainWsSends()` = consumer transmit. Today both run on the one Scheduler thread; when §145 lands, **`drainWsSends()` moves to the consumer/network task unchanged** — the queue *is* the handoff boundary. A down-payment on §145, not a single-task hack.
+
+## Approach
+
+Three seams, all in core transport + the driver. No new task yet (it arrives with §145), no new module.
+
+### 1. Non-blocking send queue with backpressure (`HttpServerModule`)
+
+- **One staging buffer for the live-preview client**, sized to the RAM-derived point cap, allocated once via `platform::alloc` (PSRAM-preferred; classic falls back to internal RAM). Single live client (§2) → one buffer.
+- `broadcastBinary` → **non-blocking enqueue**: **backpressure gate first** — if the live client still has unsent bytes from the previous frame, **drop this frame** (newest-wins). Else copy WS header + payload into the staging buffer, set `len`, `sent=0`, return. Never blocks.
+- New **`HttpServerModule::drainWsSends()`** called from `loop20ms()`: flush the staging buffer with the **non-blocking** `writeSome` — send what the socket takes now, advance `sent`, leave the rest for the next tick. Mid-frame partial is expected (we own the offset); only a real socket error closes. The exact function §145 later hosts on the consumer task. (As implemented, the drain runs before the accept so a connection burst can't strand it.)
+- **Extend `broadcastBinary`'s WS header to the 64-bit length form** so a >65535-byte frame is legal (replaces the current `else { return; }`).
+
+### 2. Single live-preview client (bound the memory)
+
+The preview is a *live view* — one viewer at a time is the real use case, and it bounds the staging buffer to one instance instead of `MAX_WS_CLIENTS`×48 KB. Target the **most-recently-connected** WS client (`wsClientGeneration_` already tracks new connections; PreviewDriver re-sends its coord table on a generation bump). State-JSON pushes still go to all clients — only the binary preview is single-target.
+
+### 3. PreviewDriver: raise the cap empirically, widen count to u32, keep downsample fallback
+
+- Replace fixed `MAX_PREVIEW_POINTS = 1800` with a **RAM-derived cap** (`platform::hasPsram`/`freeInternalHeap()`/`maxAllocBlock()` with a margin for stack/HTTP/WiFi), **tuned by measurement**. The spatial-lattice downsample **stays** and engages beyond the cap.
+- **Widen the frame `count` field `u16 → u32`** (both 0x02 colour and 0x03 coord headers) on device and browser.
+- **Does NOT touch the `rgb_`/`coords_` build buffers** — only how the built frame is *sent* and the count width. Zero-copy producer-buffer reuse + channelsPerLight/offset wire model are a separate deferred step.
+
+## Files
+
+**Core transport (the enqueue + drain — the §145-ready seam):**
+1. **Edit** `src/core/HttpServerModule.h` — staging buffer (`wsPreviewBuf_`, `wsPreviewCap_/Len_/Sent_`, target client index + generation), `drainWsSends()` decl, free in `teardown()`.
+2. **Edit** `src/core/HttpServerModule.cpp` — rewrite `broadcastBinary` as non-blocking enqueue with the backpressure gate; add the 64-bit WS length branch; add `drainWsSends()`; call it from `loop20ms()` after the accept early-return. Lazy-alloc staging via `platform::alloc`.
+
+**Driver + wire format (RAM-derived cap, u32 count):**
+3. **Edit** `src/light/drivers/PreviewDriver.h` — `MAX_PREVIEW_POINTS` → RAM-derived cap, tuned by measurement; keep the lattice fallback. Widen the 0x02/0x03 header `count` to `u32`.
+4. **Edit** `src/ui/preview3d.js` — read `count` as `u32` (`getUint32`) in `renderPreviewFrame` (0x02) and `parsePreviewCoords` (0x03); adjust header offsets.
+
+**Tests + docs:**
+5. **Edit** `test/unit/light/unit_PreviewDriver.cpp` — count fits the RAM-derived cap + lattice-regularity; a grid past the cap still downsamples (fallback intact); a `u32`-count round-trip for a >65535-point grid. Add a `unit_HttpServerModule` case: a second `broadcastBinary` while the first is undrained is **dropped** (backpressure); `drainWsSends()` makes partial progress; the 64-bit WS length header is emitted for a >65535 B frame.
+6. **Edit** `docs/moonmodules/light/drivers/PreviewDriver.md` + `docs/moonmodules/core/HttpServerModule.md` — non-blocking enqueue + backpressure-drop + RAM-derived cap + u32 count + 64-bit frames; update the wire-contract layout. Update `docs/architecture.md`: preview send is enqueue-on-produce + drain-on-transport-poll, never synchronous on the render tick; the drain is the consumer-side step §145 will host.
+
+## Verification
+
+- **Host:** `cmake --build build` (-Werror), `ctest`, `uv run scripts/scenario/run_scenario.py`, `check_specs.py`, `check_platform_boundary.py`.
+- **ESP32 build** (`esp32p4-eth` + `esp32s3-n16r8` + classic `esp32`).
+- **Live — find where it breaks (test-first):** websockets probe — sweep Grid 48²→64²→128²→195²→256²→512², recording at each size: WS open?, frame point-count (full vs downsampled), `/api/system` tick, preview fps. Locate the real break point **per board** and set the cap from that. Assert the WS never closes and the tick never stalls.
+- **Classic board (the key test):** sweep toward 16K+, find where internal RAM / drain-latency forces downsampling, confirm the device degrades-never-crashes. Sets the classic-tier cap.
+- **>65K (u32 count):** a grid above 65535 lights previews (downsampled) with a correct count — the HUB75-wall path.
+
+## Risks / notes
+
+- **Memory:** one staging buffer, RAM-derived; single live client keeps it ×1. Classic internal-RAM headroom is the binding constraint.
+- **Drain latency, not blocking:** preview fps adapts down at big sizes; the tick never stalls. §145 consumer task can later drain continuously for smoother large previews.
+- **`broadcastBinary` is preview-only** (only PreviewDriver calls it), so the contract change is safe.
+- **Two-task forward-compat:** `drainWsSends()` is a standalone entry point §145 moves to the consumer task without a rewrite.
+- **Downsampling stays** — raised, not removed; the lattice fallback is the tested graceful-degrade path.
+- **Deferred (next commit):** zero-copy producer-buffer reuse + channelsPerLight/offset wire model.
+- The diagnostic `writeChunks`/`maxDrainMs` machinery was removed entirely; the transport primitive is the non-blocking `writeSome`.
diff --git a/docs/history/plans/Plan-20260622 - Responsive split-pane preview with draggable PiP.md b/docs/history/plans/Plan-20260622 - Responsive split-pane preview with draggable PiP.md
new file mode 100644
index 0000000..9ac6695
--- /dev/null
+++ b/docs/history/plans/Plan-20260622 - Responsive split-pane preview with draggable PiP.md
@@ -0,0 +1,35 @@
+# Plan — Responsive preview: docked split-pane (wide) ↔ draggable PiP (narrow)
+
+## Problem
+
+The 3D preview and the module cards stack **vertically** inside `.main-area`: a sticky `.preview-wrap` (aspect 1/1, `max-height: 50vh`, scroll-shrinks to ~25vh) sits above `#main` (cards, capped 500px, centered). On short or small screens the preview eats most of the viewport height even when configuring a module unrelated to the 3D view (e.g. Network/SSID), leaving the cards crammed into a narrow column far down the page; on wide screens there's large empty space *beside* the 500px card column while the preview hogs vertical space *above* it. The vertical stack is the worst fit for short/wide screens.
+
+## Model (product-owner decisions)
+
+One canvas, two modes, switched by width with a manual override. "Always visible, sometimes as a small popup."
+
+- **Mode A — docked split-pane** (wide, ≥ ~960px): `.content` is a 3-column row — nav (200px) · preview (flex:1, sticky, fills its pane height) · cards (**fixed ~480px**, own `overflow-y:auto`, full height). The scroll-shrink hack is removed: the preview is stable, only the cards column scrolls. Industry standard: editor+canvas (Blender / Figma / VS Code).
+- **Mode B — floating PiP** (narrow < ~960px, OR docked-preview manually dismissed): the **same** canvas moves into a fixed-position, **draggable, corner-snapping** card (~160px), with a drag handle + expand + close (×). Cards take the full content width. Industry standard: YouTube-mobile PiP.
+- **Switching**: a `ResizeObserver` / matchMedia listener toggles a class on `.content` (`mode-docked` ↔ `mode-pip`); CSS does the layout, and the preview's existing `resize` handler (preview3d.js:180, renders at `clientWidth/clientHeight`) re-fits the canvas — so a dynamic window resize pops in/out smoothly with no reload or state loss.
+- **PiP trigger**: auto on narrow + a manual toggle on wide (pop the preview out to reclaim card space).
+- **PiP dismiss**: × fully hides it; a small "show preview" affordance (status-bar icon or floating pill) brings it back.
+
+## Files
+
+- **`src/ui/index.html`** — restructure `.content`: keep `#nav`; wrap preview + cards so they're siblings in a row (`.workspace` flex: `.preview-pane` + `#main`). Add the PiP chrome (drag handle, expand/close buttons, the re-show pill). The `
@@ -1312,8 +1312,8 @@
Serial monitor
function handleSuccess({ url, mdns, board, applyDefaults = true, defaultsApplied = false, viaHttp, alreadyOnline }) {
disarmUnloadGuard();
// Device-model defaults are applied DURING the install over serial (Improv =
- // REST over serial — SET_DEVICE_MODEL + APPLY_OP). So there's no "open this
- // link to finish" step any more; the success screen just confirms + links.
+ // REST over serial — APPLY_OP). The success screen just confirms + links;
+ // the device is already fully configured by the time it shows.
if (!url) {
// No device URL (user skipped the IP prompt, or an eth-only/no-Improv device).
// On that path no serial config push happened. If a model was picked, say so —
@@ -1650,7 +1650,7 @@
Serial monitor
port: pickedPort,
manifestUrl: localUrl,
board, // names the install title + identifies the catalog entry
- applyDefaults, // gates the SET_DEVICE_MODEL + controls inject (not txPower)
+ applyDefaults, // gates the APPLY_OP config push (not txPower, sent earlier)
txPower,
eraseBefore,
onProgress: handleProgress,
diff --git a/docs/install/install-orchestrator.js b/docs/install/install-orchestrator.js
index 3a3a150..e3b7f18 100644
--- a/docs/install/install-orchestrator.js
+++ b/docs/install/install-orchestrator.js
@@ -16,8 +16,8 @@
// 4. show a WiFi creds form, await user input
// 5. provision via Improv standard SEND_WIFI_CREDENTIALS
// 6. push the device-model config over serial — "Improv = REST over serial":
-// SET_DEVICE_MODEL (0xFE, the identity name) then APPLY_OP (0xFC) frames for
-// the deviceModels.json entry's modules + controls. No HTTP, no browser pull,
+// APPLY_OP (0xFC) frames for the deviceModels.json entry's modules + controls
+// (the deviceModel name is just one of those controls). No HTTP, no browser pull,
// so it works identically on the HTTPS deployed installer and local preview
// (the old HTTP /api/control fan-out couldn't run HTTPS→http — mixed-content).
// 7. callback with { url, board } so the host page populates
@@ -40,7 +40,6 @@ import { ImprovSerial } from "https://unpkg.com/improv-wifi-serial-sdk@2.5.0/dis
// vector so the device C++, Python, and JS implementations can't drift). The
// command IDs + frame layout are documented there.
import {
- IMPROV_CMD_SET_DEVICE_MODEL,
IMPROV_CMD_SET_TX_POWER,
IMPROV_FRAME_TYPE_RPC,
buildImprovFrame,
@@ -109,63 +108,12 @@ function bufferToBinaryString(buffer) {
// Improv RPC payload encoders (frame building is in improv-frame.js)
// ---------------------------------------------------------------------------
-// Encodes the SET_DEVICE_MODEL RPC payload that the device parser at
-// platform_esp32_improv.cpp::improvHandleSetDeviceModel expects.
-//
-// RPC payload (inside the Improv frame, before the checksum):
-// [0xFE] command
-// [data_len] 1 + str_len
-// [str_len] 1..31, length of board name in bytes
-// [str_bytes] ASCII-printable 0x20..0x7E only
-//
-// The 31-char cap mirrors SystemModule::deviceModel_'s 32-byte buffer
-// (sizeof - 1 for NUL); the device-side handler validates against
-// g_improv.deviceModelOutLen dynamically, so the wire spec follows the buffer.
-function encodeSetDeviceModelPayload(board) {
- const nameBytes = new TextEncoder().encode(board);
- // Reject non-printable bytes here, before the device does — the ESP32 handler
- // (SystemModule::setDeviceModel) accepts only 0x20..0x7E, so a name with a
- // control byte / non-ASCII char would fail on-device after we'd already sent it.
- for (const b of nameBytes) {
- if (b < 0x20 || b > 0x7E) {
- throw new Error(`deviceModel name has a non-printable-ASCII byte (0x${b.toString(16)})`);
- }
- }
- if (nameBytes.length === 0 || nameBytes.length > 31) {
- throw new Error(`deviceModel name length ${nameBytes.length}: must be 1..31`);
- }
- const out = new Uint8Array(3 + nameBytes.length);
- out[0] = IMPROV_CMD_SET_DEVICE_MODEL;
- out[1] = 1 + nameBytes.length;
- out[2] = nameBytes.length;
- out.set(nameBytes, 3);
- return out;
-}
-
-// Sends the SET_DEVICE_MODEL frame on a port we own. ImprovSerial's
-// writePacketToStream is private (verified in improv-wifi-serial-sdk@2.5.0's
-// serial.d.ts), so we encode the frame ourselves and write raw bytes.
-// ImprovSerial holds the writable stream's lock during its lifetime — to
-// get our own writer we temporarily release ImprovSerial's hold by
-// disconnecting then reconnecting. Easier alternative: write while
-// ImprovSerial is still active is blocked, so we close ImprovSerial first
-// (we're done with it — the WiFi provision succeeded) and write directly.
-async function sendSetBoardFrame(port, board) {
- const payload = encodeSetDeviceModelPayload(board);
- const frame = buildImprovFrame(IMPROV_FRAME_TYPE_RPC, payload);
- const writer = port.writable.getWriter();
- try {
- await writer.write(frame);
- } finally {
- writer.releaseLock();
- }
-}
-
// 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 SET_DEVICE_MODEL: the device acks with RpcResponse
-// we don't read; the HTTP fan-out later re-applies the same deviceModels.json
-// value as the late fallback.
+// needed. Fire-and-forget like APPLY_OP: 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 Network.txPowerSetting, but
+// that arrives too late for the first association on a brown-out-prone board.
async function sendSetTxPowerFrame(port, dBm) {
const frame = buildImprovFrame(IMPROV_FRAME_TYPE_RPC,
new Uint8Array([IMPROV_CMD_SET_TX_POWER, 1, dBm & 0xFF]));
@@ -205,14 +153,15 @@ async function sendApplyOpFrame(port, op) {
await new Promise(r => setTimeout(r, 120));
}
-// Apply a device-model's catalog defaults over serial: SET_DEVICE_MODEL (the identity
-// name) then the full config as APPLY_OP ops. The caller must OWN the serial port (no
-// ImprovSerial holding the writable lock). Works on any reachable device — fresh-
-// provisioned (WiFi) OR already-online at boot (Ethernet) — because the serial RPCs
-// need no provisioning state, only the open port. Gated by applyDefaults: when the
-// "Apply device defaults" checkbox is unticked, push nothing (keep the device's
-// config). Returns true iff the catalog push actually ran (so the success note can
-// report honestly rather than always claiming "Applied").
+// Apply a device-model's catalog defaults over serial, as APPLY_OP ops. The caller must
+// OWN the serial port (no ImprovSerial holding the writable lock). Works on any reachable
+// device — fresh-provisioned (WiFi) OR already-online at boot (Ethernet) — because the
+// serial RPCs need no provisioning state, only the open port. The deviceModel name is just
+// one of the catalog controls (System.deviceModel), so it rides the same APPLY_OP `set`
+// pass as every other default.
+// Gated by applyDefaults: when the "Apply device defaults" checkbox is unticked, push
+// nothing (keep the device's config). Returns true iff the catalog push actually ran (so
+// the success note can report honestly rather than always claiming "Applied").
async function pushDefaultsOverSerial(port, board, applyDefaults, trackProgress, onLog) {
if (!(board && applyDefaults)) {
if (onLog) onLog(board
@@ -221,9 +170,7 @@ async function pushDefaultsOverSerial(port, board, applyDefaults, trackProgress,
return false;
}
trackProgress("apply-defaults", { board });
- if (onLog) onLog(`[orchestrator] applying ${board} defaults over serial (SET_DEVICE_MODEL + APPLY_OP)`);
- await sendSetBoardFrame(port, board);
- await new Promise(r => setTimeout(r, 100)); // let the UART task settle
+ if (onLog) onLog(`[orchestrator] applying ${board} defaults over serial (APPLY_OP)`);
return await sendConfigOverSerial(port, board, onLog);
}
@@ -450,13 +397,14 @@ async function releaseDetected() {
export const installer = {
/**
* Drive the full install flow: request port, flash via esptool-js,
- * provision WiFi via Improv, push SET_DEVICE_MODEL if a board was picked,
- * report success with the device URL.
+ * provision WiFi via Improv, push the device-model config over serial (APPLY_OP)
+ * if a board was picked, report success with the device URL.
*
* @param {object} opts
* @param {string} opts.manifestUrl - URL to an ESP Web Tools manifest
- * @param {string} [opts.board] - board name from deviceModels.json to push
- * via SET_DEVICE_MODEL after provisioning. Omit / empty for "(any board)".
+ * @param {string} [opts.board] - device-model name from deviceModels.json whose
+ * defaults (incl. the deviceModel control) are pushed via APPLY_OP after
+ * provisioning. Omit / empty for "(any board)".
* @param {number|null} [opts.txPower] - deviceModels.json
* controls.Network.txPowerSetting for the picked board (whole dBm).
* When set, the SET_TX_POWER vendor RPC is pushed BEFORE provisioning
diff --git a/docs/landing/index.html b/docs/landing/index.html
index b292b1b..2e0060e 100644
--- a/docs/landing/index.html
+++ b/docs/landing/index.html
@@ -61,8 +61,11 @@
diff --git a/docs/moonmodules/core/HttpServerModule.md b/docs/moonmodules/core/HttpServerModule.md
index 38503cb..60b5aba 100644
--- a/docs/moonmodules/core/HttpServerModule.md
+++ b/docs/moonmodules/core/HttpServerModule.md
@@ -53,14 +53,14 @@ All JSON responses stream through a `JsonSink` — no fixed-buffer ceiling, so a
`GET /ws` with `Upgrade: websocket` → RFC 6455 handshake (SHA-1 + base64). Up to 4 concurrent clients.
- **Server → client text frames:** full state JSON, pushed by `loop1s()`.
-- **Server → client binary frames:** `broadcastBinary(chunks)` sends one binary WS message (FIN+binary opcode) to every connected client — it prepends the WS frame header and writes; the payload bytes are the caller's. Domain-neutral: the server doesn't know what the bytes mean. Today the only caller is the light domain's [PreviewDriver](../light/drivers/PreviewDriver.md), whose frame format (leading byte `0x02`, 13-byte header, RGB triples) lives in the driver, not here.
+- **Server → client binary frames:** **streamed** with no frame-sized buffer, via `beginBinaryFrame(totalLen)` / `pushBinaryFrame(data,len)` / `endBinaryFrame()`. `begin` sends the WS header (16-bit length, or the 64-bit form above 64 KB) to every client; each `push` fans a payload slice to every client; `end` returns whether every client received the whole frame. The producer ([PreviewDriver](../light/drivers/PreviewDriver.md)) pushes straight from its source data, so neither side holds a copy of the frame. Domain-neutral: the server doesn't interpret the bytes.
- **Client → server:** none. Mutations go through the REST API.
-`broadcastBinary` uses a single non-blocking scatter-gather write (`TcpConnection::writeChunks` — one `writev`/`sendmsg`) so the render task never blocks on a slow browser. `Complete` and `WouldBlock` both keep the connection open; `Partial` or socket error drops the connection and the browser auto-reconnects.
+Each push writes to every client via the non-blocking `TcpConnection::writeSome`, spinning a bounded number of times for the lwIP send buffer to drain (no sleep) before giving up on a client that can't keep up and closing it (it reconnects). `endBinaryFrame()` returning `false` is the producer's "the link couldn't take this frame" signal, driving PreviewDriver's adaptive downscale. **The send is synchronous on the caller's loop** (PreviewDriver's rate-limited preview loop, not the LED render tick): a large frame on a slow link briefly occupies that loop. Moving to a resumable cross-tick send (push what fits now, resume next loop) is the follow-up that removes that pause; see PreviewDriver.
## Cross-domain wiring
-HttpServerModule is core infrastructure with **no** light-domain dependencies — no `PreviewFrame`, no light types, no light includes. It exposes the `BinaryBroadcaster` interface (`broadcastBinary`); the light-domain `PreviewDriver` holds a `BinaryBroadcaster*` and pushes each downsampled frame's bytes to it. `main.cpp` wires `PreviewDriver`'s broadcaster to the HttpServerModule instance — the only file that knows both. The preview's voxel budget (≤1849, fitting lwIP's TCP send buffer) and wire format are PreviewDriver's concern, documented there.
+HttpServerModule is core infrastructure with **no** light-domain dependencies — no `PreviewFrame`, no light types, no light includes. It exposes the `BinaryBroadcaster` interface (`beginBinaryFrame` / `pushBinaryFrame` / `endBinaryFrame` + `clientGeneration`); the light-domain `PreviewDriver` holds a `BinaryBroadcaster*` and streams each frame's bytes through it. `main.cpp` wires `PreviewDriver`'s broadcaster to the HttpServerModule instance — the only file that knows both. The preview's point budget and wire format are PreviewDriver's concern, documented there.
## Prior art
diff --git a/docs/moonmodules/core/ImprovProvisioningModule.md b/docs/moonmodules/core/ImprovProvisioningModule.md
index bf126b9..ad08b2f 100644
--- a/docs/moonmodules/core/ImprovProvisioningModule.md
+++ b/docs/moonmodules/core/ImprovProvisioningModule.md
@@ -1,6 +1,6 @@
# ImprovProvisioningModule
-**What Improv is.** [Improv-Wifi](https://www.improv-wifi.com/) (from Nabu Casa, the Home Assistant / ESPHome company) is an open standard for handing a device its WiFi credentials over a *local* link — USB serial here (it also has a BLE variant) — at the moment it has no network yet. That's the bootstrap chicken-and-egg it solves: a freshly-flashed ESP32 isn't on your WiFi, so you can't reach it over the network to tell it the WiFi password; Improv carries that first handoff over the cable the browser is already connected to from flashing. The name is short for *improvise* — the device has no pre-configured network, so it improvises its first connection from whatever local link is already there. projectMM extends this past credentials with vendor RPCs (`SET_DEVICE_MODEL`, `APPLY_OP`) — "Improv = REST over serial", see below — reusing the same already-there-before-the-network link to push the whole device config.
+**What Improv is.** [Improv-Wifi](https://www.improv-wifi.com/) (from Nabu Casa, the Home Assistant / ESPHome company) is an open standard for handing a device its WiFi credentials over a *local* link — USB serial here (it also has a BLE variant) — at the moment it has no network yet. That's the bootstrap chicken-and-egg it solves: a freshly-flashed ESP32 isn't on your WiFi, so you can't reach it over the network to tell it the WiFi password; Improv carries that first handoff over the cable the browser is already connected to from flashing. The name is short for *improvise* — the device has no pre-configured network, so it improvises its first connection from whatever local link is already there. projectMM extends this past credentials with the `APPLY_OP` vendor RPC — "Improv = REST over serial", see below — reusing the same already-there-before-the-network link to push the whole device config (including the deviceModel identity, which is just one of the config controls).
Browser-driven WiFi provisioning over USB-serial, using the [Improv-WiFi](https://www.improv-wifi.com/) protocol. Bridges credentials from a Chrome / Edge / Opera tab — or from `scripts/build/improv_provision.py` for rack/CI use — into `NetworkModule::setWifiCredentials`, which writes the same buffers the AP-fallback UI flow uses. The protocol parser + UART task live in the platform layer; this module is the status surface that polls a ready-flag and bridges credentials to NetworkModule on the scheduler thread.
@@ -20,17 +20,16 @@ The listener serves **both** serial transports: UART0 (external USB-to-UART brid
## Wire contract
-Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV` + version byte + type + length + payload + checksum. Full protocol details: . The on-device implementation supports four standard RPC commands plus three vendor extensions:
+Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV` + version byte + type + length + payload + checksum. Full protocol details: . The on-device implementation supports four standard RPC commands plus two vendor extensions:
- `GET_CURRENT_STATE` — returns "authorized" or "provisioned" depending on whether WiFi STA is connected.
- `GET_DEVICE_INFO` — returns `[firmware, version, chipFamily, deviceName]` (where `firmware` = `"projectMM"`, `version` from `kVersion` in `build_info.h`, `chipFamily` from `platform::chipModel()`, `deviceName` from `SystemModule`).
- `GET_WIFI_NETWORKS` — runs a synchronous WiFi scan, returns up to 10 SSIDs with RSSI + auth flag. **Rejected while STA is connected** (see below).
- `WIFI_SETTINGS` — writes SSID + password to NetworkModule via `setWifiCredentials`, polls `wifiStaConnected()` for up to 30 s, replies with success (carrying `http:///`) or `ERROR_UNABLE_TO_CONNECT`.
-- `SET_DEVICE_MODEL` (vendor, `0xFE`) — payload `[str_len][deviceModel name]`; persists the deviceModel name into SystemModule's `deviceModel` control (via `SystemModule::setDeviceModel`, which validates it). Sent by the web installer after provisioning, ahead of the `APPLY_OP` config push.
- `SET_TX_POWER` (vendor, `0xFD`) — payload `[1][dBm]` (0–21; 0 lifts the cap); persists + applies `Network.txPowerSetting` **before** any association attempt. This is the provisioning escape hatch for boards whose LDO browns out at full TX power (a weak LDO / marginal supply): the cap MUST land before the first association or the board fails WiFi auth at 20 dBm before it is ever online. `improv_provision.py --tx-power 8` (and the MoonDeck flow) sends this ahead of the credentials; error `0x81` on an out-of-range value.
-- `APPLY_OP` (vendor, `0xFC`) — **"Improv = REST over serial".** Carries ONE REST operation as JSON, the same shape an HTTP `POST /api/modules` / `/api/control` body has: `{"op":"add","type":…,"id":…,"parent":…}` / `{"op":"set","module":…,"control":…,"value":…}` / `{"op":"clearChildren","parent":…}`. On the device the op is routed to `HttpServerModule`'s apply-core — the *exact same code* the HTTP handlers call — so a REST call over the network and an `APPLY_OP` over serial execute identically. (One schema caveat: the serial `add` op names the parent `parent`, while the HTTP `POST /api/modules` body names it `parent_id`. Both feed the one `applyAddModule()` core but the two transports parse different keys, so an HTTP payload is **not** a drop-in `APPLY_OP` — rename `parent_id` → `parent`. The serial key stays terse because every byte counts against the 128-byte frame.) The web installer pushes a device-model's whole catalog config this way during provisioning (a `clearChildren` pre-pass for any `replaceChildren` container, then an `add` per module + a `set` per control), so the defaults apply over serial with **no HTTP and no browser handoff** — sidestepping the mixed-content block that stops an HTTPS installer page from POSTing to an `http://` device. Frame payload: `[0xFC][seq][last][chunk]` — most ops are one frame; a long value (e.g. a big `pins` list) chunks across frames into a reassembly buffer, applied on the device's main loop when `last=1`. Single-buffered: the device errors a new op while the previous is unconsumed, and the installer awaits each ack. (The device-side catalog fetch + the old `?deviceModel=` handoff are removed — to re-apply a model to an already-running device, use MoonDeck on the LAN, which talks plain HTTP REST with no mixed-content barrier.)
+- `APPLY_OP` (vendor, `0xFC`) — **"Improv = REST over serial".** Carries ONE REST operation as JSON, the same shape an HTTP `POST /api/modules` / `/api/control` body has: `{"op":"add","type":…,"id":…,"parent":…}` / `{"op":"set","module":…,"control":…,"value":…}` / `{"op":"clearChildren","parent":…}`. On the device the op is routed to `HttpServerModule`'s apply-core — the *exact same code* the HTTP handlers call — so a REST call over the network and an `APPLY_OP` over serial execute identically. (One schema caveat: the serial `add` op names the parent `parent`, while the HTTP `POST /api/modules` body names it `parent_id`. Both feed the one `applyAddModule()` core but the two transports parse different keys, so an HTTP payload is **not** a drop-in `APPLY_OP` — rename `parent_id` → `parent`. The serial key stays terse because every byte counts against the 128-byte frame.) The web installer pushes a device-model's whole catalog config this way during provisioning (a `clearChildren` pre-pass for any `replaceChildren` container, then an `add` per module + a `set` per control — **the deviceModel identity is just one of those `set` ops** on `System.deviceModel`, validated by that control's per-control validator like any other write), so the defaults apply **over the serial port the installer already owns during the flash** — which is what lets the HTTPS installer page configure an `http://` device that a browser fetch can't reach (mixed-content). Frame payload: `[0xFC][seq][last][chunk]` — most ops are one frame; a long value (e.g. a big `pins` list) chunks across frames into a reassembly buffer, applied on the device's main loop when `last=1`. Single-buffered: the device errors a new op (`0x82`) while the previous is unconsumed. The installer paces ops open-loop (a fixed delay between frames sized to the worst-case consume window) rather than reading the ack back, so a lost op is improbable rather than impossible; each op is idempotent, so a re-flash re-applies cleanly. (To re-apply a model to an already-running device, use MoonDeck on the LAN, which talks plain HTTP REST with no mixed-content barrier.)
-**The serial listener runs on every ESP32 target, including Ethernet-only builds** (`--firmware esp32-eth*`). On eth-only the WiFi-provisioning RPCs (`WIFI_SETTINGS`, `GET_WIFI_NETWORKS`) are compiled out — there's no STA to provision and the `esp_wifi_*` calls aren't linked — but the vendor RPCs (`SET_DEVICE_MODEL`, `SET_TX_POWER`, `APPLY_OP`) and `GET_CURRENT_STATE` / `GET_DEVICE_INFO` still work, so the web installer pushes a device-model's config over serial to an eth device exactly as it does to a WiFi one. On eth, `GET_CURRENT_STATE` reports "provisioned" + the device URL from the Ethernet link (`platform::ethConnected()` / `ethGetIPv4`) instead of the WiFi STA.
+**The serial listener runs on every ESP32 target, including Ethernet-only builds** (`--firmware esp32-eth*`). An eth-only build compiles in the vendor RPCs (`SET_TX_POWER`, `APPLY_OP`) plus `GET_CURRENT_STATE` / `GET_DEVICE_INFO`, so the web installer pushes a device-model's config over serial to an eth device exactly as it does to a WiFi one; the WiFi-provisioning RPCs (`WIFI_SETTINGS`, `GET_WIFI_NETWORKS`) build only on WiFi targets, where there's an STA to provision and `esp_wifi_*` is available. On eth, `GET_CURRENT_STATE` reports "provisioned" + the device URL from the Ethernet link (`platform::ethConnected()` / `ethGetIPv4`) instead of the WiFi STA.
`WIFI_SETTINGS` and `GET_WIFI_NETWORKS` are both **rejected with `ERROR_UNABLE_TO_CONNECT` while `platform::wifiStaConnected() == true`**. The scan gate protects large installs: `esp_wifi_scan_start` puts the radio into scan mode for 2-5 s, during which inbound ArtNet packets are dropped. On a 16K-LED rig that's a visible glitch. To re-provision a running device, wipe `ssid` via the UI and reboot, then run Improv before STA reconnects. `GET_CURRENT_STATE` and `GET_DEVICE_INFO` stay available regardless — they're read-only and don't touch the radio.
diff --git a/docs/moonmodules/core/SystemModule.md b/docs/moonmodules/core/SystemModule.md
index ea68e33..5c8bef3 100644
--- a/docs/moonmodules/core/SystemModule.md
+++ b/docs/moonmodules/core/SystemModule.md
@@ -16,7 +16,7 @@ System-level diagnostics and device identity. Always loaded, always visible in t
**Configurable:**
- `deviceName` (text, default `MM-XXXX` where XXXX = last 4 hex of MAC) — the device's network identity (*which unit this is*). Used as hostname for mDNS, AP SSID, and UI display. Persisted.
-- `deviceModel` (text, read-only in the UI) — the physical-hardware identity (*which product this is*, e.g. `Olimex ESP32-Gateway Rev G`), the entry name from the device-model catalog ([deviceModels.json](../../install/deviceModels.json)). The device can't self-identify its hardware, so this is *pushed* by tooling: the web installer over the Improv `SET_DEVICE_MODEL` RPC during provisioning (alongside the rest of the catalog config via `APPLY_OP` — see [ImprovProvisioningModule.md](ImprovProvisioningModule.md)), or MoonDeck over HTTP `/api/control` on the LAN. Both go through `SystemModule::setDeviceModel`, which validates it. Display-only in the UI (pushed, never user-typed at the device); persisted. (Was its own `BoardModule` child until folded into System.)
+- `deviceModel` (text, read-only in the UI) — the physical-hardware identity (*which product this is*, e.g. `Olimex ESP32-Gateway Rev G`), the entry name from the device-model catalog ([deviceModels.json](../../install/deviceModels.json)). The device can't self-identify its hardware, so this is *pushed* by tooling — just like any other catalog default: the web installer sends it as one of the `APPLY_OP` `set` ops during provisioning (see [ImprovProvisioningModule.md](ImprovProvisioningModule.md)), or MoonDeck over HTTP `/api/control` on the LAN. The printable-ASCII rule (1..31 chars, 0x20–0x7E, no NUL) is a per-control validator on the descriptor (`ControlDescriptor::validate`), so *every* write path — HTTP, serial APPLY_OP, persistence load — runs it in the backend. Display-only in the UI (pushed, never user-typed at the device); persisted.
**Static (set at boot):**
- `version` (read-only) — semver from library.json (`MM_VERSION`), plus the release channel in parentheses when the build was published under one: `1.0.0-rc2 (latest)`, `1.0.0 (v1.0.0)`. The channel (`MM_RELEASE`) is burned in by `release.yml` via `build_esp32.py --release `; a local / dev build has no channel and shows the bare semver. Semver answers *what code*; the channel answers *which release this device was flashed from* — a moving `latest` build and a tagged release can share a semver but differ in channel. Desktop builds show the bare semver today (the desktop packager doesn't set the channel).
diff --git a/docs/moonmodules/light/drivers/NetworkSendDriver.md b/docs/moonmodules/light/drivers/NetworkSendDriver.md
index 36f1c49..ca7acf8 100644
--- a/docs/moonmodules/light/drivers/NetworkSendDriver.md
+++ b/docs/moonmodules/light/drivers/NetworkSendDriver.md
@@ -9,6 +9,7 @@ Streams the light buffer over UDP in one of three industry protocols, selected b
- `protocol` (select: ArtNet / E1.31 / DDP, default ArtNet) — the wire protocol; the destination port follows it automatically (6454 / 5568 / 4048). Changing it re-targets the socket **live, no reboot** ([§ Live reconfiguration](../../../architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)) — switch output protocol on a running device mid-show.
- `ip` (IPv4, default 255.255.255.255) — destination address. The default is the limited-broadcast address, so a fresh sender reaches every receiver on the LAN with no IP to type; set a unicast address to target one device. Changing it re-binds live. E1.31 multicast is deliberately not implemented (see Interop below).
- `universe_start` (uint16_t, default 0) — first universe for ArtNet and E1.31; DDP is byte-addressed and ignores it.
+- `light_count` (uint16_t, default 0 = the whole buffer) — how many lights this sink sends, from the start of the buffer. >0 sends only the first N, so one sink can cover just its slice — e.g. drive some lights over LEDs and the rest over ArtNet, or run two senders for different ranges — and a frame isn't packed/sent for lights it doesn't own. (A start *offset* for arbitrary, non-prefix slices is a planned follow-up across all drivers; today the slice begins at light 0.)
- `fps` (uint8_t, default 50, range 1-120) — frame rate limit. Without it the loop would re-send on every render tick; receivers expect a steady frame cadence.
## Chunking per protocol
diff --git a/docs/moonmodules/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md
index ff72837..9e99b8b 100644
--- a/docs/moonmodules/light/drivers/PreviewDriver.md
+++ b/docs/moonmodules/light/drivers/PreviewDriver.md
@@ -10,31 +10,41 @@ Streams a true-shape 3D preview to the web UI over WebSocket. The preview is a *
## Protocol
-PreviewDriver owns both wire formats end to end and pushes the bytes to a `BinaryBroadcaster` (the core [HttpServerModule](../../core/HttpServerModule.md) implements it via `broadcastBinary`). The HTTP server only writes the bytes to its WebSocket clients — it has no knowledge of the preview, the light domain, or the formats below. `main.cpp` wires the driver's broadcaster to the HTTP server instance. This mirrors MoonLight's model: positions sent once at mapping time, channels per frame.
+PreviewDriver owns both wire formats end to end and **streams** the bytes to a `BinaryBroadcaster` (the core [HttpServerModule](../../core/HttpServerModule.md)) via `beginBinaryFrame`/`pushBinaryFrame`/`endBinaryFrame` — it never builds a copy of a frame, pushing straight from the producer buffer and the layout's coordinate iterator. The HTTP server only writes the bytes to its WebSocket clients — no knowledge of the preview, the light domain, or the formats below. `main.cpp` wires the driver's broadcaster to the HTTP server instance. This mirrors MoonLight's model: positions sent once at mapping time, channels per frame.
Two binary message types (first byte selects):
-- **`0x03` coordinate table** — sent on every LUT rebuild (layout add/replace/remove, resize, modifier change) and re-broadcast ~once per second so a newly-connected client catches up. Layout:
+- **`0x03` coordinate table** — sent on every LUT rebuild (layout add/replace/remove, resize, modifier change), when a new client connects (a generation bump), and when the adaptive downscale factor changes; re-sent on the next tick if a send is dropped under backpressure. Layout:
- `[0x03][count:u16][bx:u8][by:u8][bz:u8][stride:u16][ (x:u8, y:u8, z:u8) × count ]`
+ `[0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][ (x:u8, y:u8, z:u8) × count ]` (10-byte header)
- `count` = points actually sent; `bx/by/bz` = bounding-box extent (the browser centres the cloud on it); positions are **1 byte per axis** (a layout's bounding box is ≤255/axis in practice; clamped on build). `stride` is the index-downsample factor (see Large layouts).
+ `count` = points actually sent (**u32** — a HUB75 wall can exceed 65535 lights; matches `nrOfLightsType`); `bx/by/bz` = bounding-box extent (the browser centres the cloud on it); positions are **1 byte per axis** (a layout's bounding box is ≤255/axis in practice; scaled on build if larger). `stride` carries the **downscale factor** (1 = full resolution; >1 = the per-axis lattice step — see Large layouts), which the browser shows as `preview 1/N · link limited`.
-- **`0x02` per-frame channels** — RGB by driver-light index, in the same order as the coordinate table:
+- **`0x02` per-frame channels** — RGB, one triple per sent point, in coordinate-table order:
- `[0x02][count:u16][stride:u16][ (r, g, b) × count ]`
+ `[0x02][count:u32][stride:u16][ (r, g, b) × count ]` (7-byte header)
- The browser colours coordinate-table entry `i` with RGB triple `i`. It holds `0x02` frames until a `0x03` table has arrived.
+ The browser colours coordinate-table entry `i` with RGB triple `i`. It **skips a `0x02` frame whose `count` ≠ the current `0x03` count** (a rebuild is mid-flight — the colours would map to the wrong positions); they realign within ~1 frame. The device likewise withholds colour frames until the matching `0x03` has been accepted by the transport, so the two never desync.
## Sparse layouts & where the data comes from
The driver reads the **sparse driver buffer** — the `Layer`'s `MappingLUT` extracts the real lights from the dense render grid into a buffer of exactly `Layouts::totalLightCount()` entries (a radius-4 sphere → 210, not its 9×9×9 = 729 box). That same buffer is what ArtNet sends. PreviewDriver reads it flat by light index and builds the coordinate table from `Layouts::forEachCoord` (same driver order), so RGB index `i` and coordinate `i` always refer to the same light. See [Layer](../Layer.md) / [MappingLUT](../MappingLUT.md) for the box→driver mapping.
-## Large layouts (index downsample)
+**No preview-side buffers.** Both messages STREAM — neither holds a copy of a frame:
-A preview message is one non-blocking `writev`; it must fit lwIP's TCP send buffer (`CONFIG_LWIP_TCP_SND_BUF_DEFAULT` = 11520 B), or the connection is dropped. Sparse layouts (sphere ≈ 634 B) send every light exactly (`stride` = 1). A large dense grid (128² = 16384 lights × 3 ≈ 48 KB) is **index-downsampled**: `stride` = smallest factor whose sent-point count (≤ 1800, ≈ 5.4 KB — well under half the send buffer, since the render task shares it and a payload near the ceiling would partial-write and drop the connection) fits the cap. Both `0x03` and `0x02` carry `stride`, and the browser plots every `stride`-th light **at its real position** — far better than the old dense-box block-replicate (which this replaces; there is no `decompress` / `detail` control anymore).
+- **Colour frame (`0x02`)** at full resolution (`stride`=1) is the **producer buffer streamed 1:1**: if it's 3-channel RGB (`cpl`=3, the logical buffer's native layout) the buffer bytes ARE the payload, pushed straight through `beginBinaryFrame`/`pushBinaryFrame`. A downsampled frame walks `forEachCoord` applying the same lattice skip the coordinate table used (same subset, same order — so colour `k` lines up with coord `k` with no stored index map), pushing 3 bytes per kept light from the buffer. A non-RGB source (`cpl`≠3) pushes its 3 colour bytes per light. Either way: no `rgb_`/gather buffer.
+- **Coordinate table (`0x03`)** streams the kept lights' scaled positions from `forEachCoord` — no `coords_` buffer. Sent only on a geometry change / new client / downscale change (rare).
-Positions are 1 byte per axis. A layout whose bounding box exceeds 255 on any axis (e.g. a 512-wide grid) is **scaled** so the largest box edge maps to 255, preserving aspect ratio (the `0x03` header carries the scaled box extents, which the browser normalises against). Boxes ≤255/axis — every sparse layout and any grid up to 255 — are sent at exact integer positions (scale factor 1). So large grids preview at their true proportions, not flattened onto the 255 plane.
+The send is synchronous on the preview's rate-limited loop (not the LED render tick): a large frame on a slow link briefly occupies that loop — a resumable cross-tick send (push what fits, resume next loop) is the follow-up.
+
+## Large layouts (spatial downsample + adaptive)
+
+The point count is bounded two ways:
+
+- **Static cap** — `MAX_PREVIEW_POINTS` is RAM-derived: `131072` on PSRAM boards, `16384` on no-PSRAM. Above the cap the driver downsamples on a **spatial lattice** — keep a light only when its grid position lands on a per-axis step (`x%s==0 && y%s==0 && z%s==0`), a regular sub-grid that generalises to 2D and 3D, with no diagonal moiré (the lattice samples *positions*, not flat indices). Sparse layouts (a sphere shell) and any grid under the cap send every light (`stride` = 1, exact).
+- **Adaptive downscale** — when a streamed frame doesn't reach every client (`endBinaryFrame()` false — the link couldn't take it), the driver coarsens the lattice (`stride`++) after a short run so frames shrink; a sustained run of fully-sent frames refines back toward full resolution (hysteresis stops oscillation). The factor rides the `0x03` `stride` field to the browser's status line.
+
+Positions are 1 byte per axis. A layout whose bounding box exceeds 255 on any axis (e.g. a 512-wide grid) is **scaled** so the largest box edge maps to 255, preserving aspect ratio (the `0x03` header carries the scaled box extents, which the browser normalises against). Boxes ≤255/axis are sent at exact integer positions (scale factor 1), so large grids preview at their true proportions, not flattened onto the 255 plane.
## Tests
diff --git a/docs/moonmodules/light/layouts/GridLayout.md b/docs/moonmodules/light/layouts/GridLayout.md
index ea35add..66a75e3 100644
--- a/docs/moonmodules/light/layouts/GridLayout.md
+++ b/docs/moonmodules/light/layouts/GridLayout.md
@@ -2,11 +2,11 @@

-Arranges lights in a 3D grid, row-major (x fastest, then y, then z). Full-density — every position maps to a light. Controls: `width`, `height`, `depth`.
+Arranges lights in a 3D grid, row-major (x fastest, then y, then z). Full-density — every position maps to a light. Controls: `width`, `height`, `depth`, `serpentine`.
## Mapping
-Default settings (no serpentine, X-then-Y) are **1:1 unshuffled** — the `oneToOneMapping` flag is set and the mapping table skipped entirely. The Layer buffer and driver buffer are separate when memory allows (for parallelism), shared when memory is tight. `defaultGridSize` (16) is owned here and also read by the composition roots to size the boot grid.
+A plain grid (`serpentine` off) emits driver index `i` at box cell `i`, so the Layer takes the **1:1 unshuffled memcpy fast path** — the mapping isn't *declared* identity, it's *measured*: the Layer walks the coords once and only skips the mapping table when the order is natural. `serpentine` wires odd rows in reverse (boustrophedon — the strip snakes back and forth), so driver index `i` no longer equals box cell `i`: the grid is dense but **shuffled**, which routes it through the box→driver mapping LUT exactly as a sparse layout does. A handy lever for exercising both the identity and non-identity mapping paths from one layout. The Layer buffer and driver buffer are separate when memory allows (for parallelism), shared when memory is tight. `defaultGridSize` (16) is owned here and also read by the composition roots to size the boot grid.
## Tests
diff --git a/docs/testing.md b/docs/testing.md
index cd32e46..c6079cf 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -401,7 +401,7 @@ One live-tier test lives outside the scenario JSON schema because it spans **mul
All live scenarios pass on both desktop and ESP32 with `min_pct: 80` relative bounds. Per-module timing, memory allocation, and sizeof measurements for each platform are in [performance.md](performance.md).
-### ESP32 — Olimex ESP32-Gateway Rev G (no PSRAM)
+### ESP32 — classic, no PSRAM (measured on an Olimex ESP32-Gateway Rev G)
- 128×128 grid (16,384 lights) — all live scenarios pass.
- Memory tracking verified: mirror toggle shows heap changes, returns to baseline (no leaks).
diff --git a/scripts/MoonDeck.md b/scripts/MoonDeck.md
index f2a0eb9..f3a002f 100644
--- a/scripts/MoonDeck.md
+++ b/scripts/MoonDeck.md
@@ -246,7 +246,7 @@ Build one of the shipping ESP32 firmware variants. The MoonDeck **Build** button
| Firmware key | Chip | What's in the image |
|---|---|---|
-| `esp32` | `esp32` | WiFi **and** RMII Ethernet in one binary. Ethernet comes up only when a PHY responds; PHY type + pins are runtime config from `deviceModels.json` (Olimex defaults). The default classic build. |
+| `esp32` | `esp32` | WiFi **and** RMII Ethernet in one binary. Ethernet comes up only when a PHY responds; PHY type + pins are runtime config from `deviceModels.json` (default LAN8720 RMII pins). The default classic build. |
| `esp32-eth` | `esp32` | Ethernet only (WiFi compiled out → smaller image, more free RAM). Same runtime PHY/pin config. |
| `esp32-16mb` | `esp32` | Same as `esp32` but for 16 MB-flash classic boards (bigger OTA slots + filesystem). |
| `esp32s3-n16r8` | `esp32s3` | ESP32-S3 DevKitC-1 (N16R8: 16 MB flash, 8 MB octal PSRAM). WiFi + W5500 SPI Ethernet (external module, pins per board in `deviceModels.json`). |
@@ -262,7 +262,7 @@ uv run scripts/build/build_esp32.py --firmware esp32s3-n16r8
Auto-detects ESP-IDF installation, sets target if needed, builds, and shows flash/RAM usage summary. Each firmware writes into `build/esp32-/`, so switching firmwares (or building several in one session) keeps every variant on disk — no clean rebuild on switch.
-The Ethernet PHY type and pin map are runtime config, not baked in: each firmware carries the driver(s) its chip can host (RMII EMAC for classic, W5500 SPI for S3), and `deviceModels.json` supplies the per-board PHY/pins. The `esp32` / `esp32-eth` builds default to the [Olimex ESP32-Gateway](https://www.olimex.com/Products/IoT/ESP32/ESP32-GATEWAY/open-source-hardware) pins (LAN8720 PHY, reset on GPIO 5, MDIO addr 0); a board with different pins (e.g. WT32-ETH01: reset on GPIO 16) just needs a different `deviceModels.json` entry — no rebuild.
+The Ethernet PHY type and pin map are runtime config, not baked in: each firmware carries the driver(s) its chip can host (RMII EMAC for classic, W5500 SPI for S3), and `deviceModels.json` supplies the per-board PHY/pins. The `esp32` / `esp32-eth` builds default to the common LAN8720 RMII pins (PHY reset on GPIO 5, MDIO addr 0, clock GPIO 17 — e.g. the [Olimex ESP32-Gateway](https://www.olimex.com/Products/IoT/ESP32/ESP32-GATEWAY/open-source-hardware)); a board with different pins (e.g. WT32-ETH01: reset on GPIO 16) just needs a different `deviceModels.json` entry — no rebuild.
Each ESP32-S3 SKU has its own firmware key because the sdkconfig fragment encodes flash size, partition layout, and PSRAM mode — flashing an `n16r8` binary onto a different module (e.g. N8R2) misaligns the partition table or fails PSRAM init. New SKUs become new keys (e.g. `esp32s3-n8r8`); we don't ship a generic `esp32s3` shortcut.
diff --git a/scripts/build/build_esp32.py b/scripts/build/build_esp32.py
index 5c8f14f..5aead96 100644
--- a/scripts/build/build_esp32.py
+++ b/scripts/build/build_esp32.py
@@ -78,7 +78,7 @@
"fragments": ["sdkconfig.defaults", "sdkconfig.defaults.eth"],
"eth_only": False,
"description": "ESP32 classic — WiFi + Ethernet (RMII; per-board pins/PHY "
- "from deviceModels.json, Olimex defaults).",
+ "from deviceModels.json, default LAN8720 pins).",
"ships": True,
},
"esp32-16mb": {
diff --git a/scripts/moondeck_ui/app.js b/scripts/moondeck_ui/app.js
index 69e3cad..8964e25 100644
--- a/scripts/moondeck_ui/app.js
+++ b/scripts/moondeck_ui/app.js
@@ -886,6 +886,23 @@ function renderDevices() {
if (deviceBoard === val) opt.selected = true;
boardPicker.appendChild(opt);
}
+ // Push a board's full deviceModels.json defaults to the device (POST
+ // /api/push-board → _push_board_to_device fans out controls..).
+ // `onDone(ok)` lets the explicit button below show success/failure; the picker
+ // change path passes nothing (fire-and-forget, recovered on next refresh).
+ const pushBoard = (board, onDone) => {
+ // Success is the device-side result in the JSON body ({"ok": bool} from
+ // _push_board_to_device) — HTTP 200 alone can wrap a failed push (a device
+ // timeout / non-2xx mid-fan-out), so r.ok would falsely report success.
+ // 10s AbortSignal timeout so a stalled request can't wedge the button forever.
+ fetch("/api/push-board", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({ip: device.ip, board}),
+ signal: AbortSignal.timeout(10000),
+ }).then(r => r.json()).then(j => onDone && onDone(!!j.ok))
+ .catch(() => onDone && onDone(false));
+ };
boardPicker.addEventListener("change", () => {
device.board = boardPicker.value;
saveState();
@@ -895,11 +912,27 @@ function renderDevices() {
// UI to update right after they pick. Fire-and-forget; failure
// (timeout / device offline) is recovered on the next refresh
// when discover/refresh's bulk push catches up.
- fetch("/api/push-board", {
- method: "POST",
- headers: {"Content-Type": "application/json"},
- body: JSON.stringify({ip: device.ip, board: boardPicker.value}),
- }).catch(() => { /* best-effort */ });
+ pushBoard(boardPicker.value);
+ });
+
+ // Explicit "inject defaults" — re-push the SELECTED board's full config on demand,
+ // without having to change the picker. Distinct intent from the implicit on-change
+ // push: re-apply after a reflash wiped config, or re-assert defaults a user edited
+ // away. Brief inline feedback so a no-op (timeout / offline) isn't silent.
+ const injectBtn = document.createElement("button");
+ injectBtn.className = "device-inject";
+ injectBtn.textContent = "inject defaults";
+ injectBtn.title = "Push the selected device-model's deviceModels.json defaults to this device now";
+ injectBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ const board = boardPicker.value;
+ if (!board) { injectBtn.textContent = "pick a board first"; setTimeout(() => injectBtn.textContent = "inject defaults", 1500); return; }
+ injectBtn.disabled = true;
+ injectBtn.textContent = "injecting…";
+ pushBoard(board, (ok) => {
+ injectBtn.textContent = ok ? "injected ✓" : "failed ✗";
+ setTimeout(() => { injectBtn.textContent = "inject defaults"; injectBtn.disabled = false; }, 1800);
+ });
});
const removeBtn = document.createElement("button");
@@ -938,6 +971,7 @@ function renderDevices() {
const row3 = document.createElement("div");
row3.className = "device-row device-row-board";
row3.appendChild(boardPicker);
+ row3.appendChild(injectBtn);
// row 4 — pin-profile save/apply. A profile is the device's captured
// GPIO/peripheral config (drivers, board, network, audio); saving stores
diff --git a/scripts/moondeck_ui/style.css b/scripts/moondeck_ui/style.css
index 521a5d8..7ac76e5 100644
--- a/scripts/moondeck_ui/style.css
+++ b/scripts/moondeck_ui/style.css
@@ -302,20 +302,29 @@ select {
font-size: 11px;
}
+/* Picker + inject button share the board row; the picker flexes, the button
+ stays its natural width. */
+.device-row-board { display: flex; gap: 6px; align-items: center; }
.device-board {
font-size: 11px; background: #1c2535; color: #c0c0c0;
border: 1px solid #2a3548; border-radius: 3px; padding: 1px 4px;
- /* Picker lives alone in its own row (.device-row-board) and aligns
- left. max-width clamps long labels (e.g. "Olimex ESP32-Gateway Rev G"
- = 26ch) so the dropdown text doesn't overflow the card's right edge;
- ellipsis triple handles the truncation on the selected option's
- display text when collapsed. */
- max-width: 100%;
- display: inline-block;
+ /* Flexes to fill the row; max-width/ellipsis clamp long labels (e.g.
+ "Olimex ESP32-Gateway Rev G" = 26ch) so the dropdown text doesn't push
+ the inject button off the card's right edge. */
+ flex: 1 1 auto;
+ min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+.device-inject {
+ flex: 0 0 auto;
+ font-size: 11px; background: #1c2535; color: #8ab4f8;
+ border: 1px solid #2a3548; border-radius: 3px; padding: 1px 6px;
+ cursor: pointer; white-space: nowrap;
+}
+.device-inject:hover:not(:disabled) { border-color: #8ab4f8; }
+.device-inject:disabled { opacity: 0.6; cursor: default; }
.device-remove {
background: none; border: none; color: #666;
cursor: pointer; font-size: 11px; padding: 0 4px;
diff --git a/scripts/scenario/run_live_scenario.py b/scripts/scenario/run_live_scenario.py
index 51074fd..b360ebd 100644
--- a/scripts/scenario/run_live_scenario.py
+++ b/scripts/scenario/run_live_scenario.py
@@ -325,6 +325,12 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5,
baseline = collect_metrics(client, settle_s=settle_s)
print(f"\n Baseline: tick={baseline.get('tickTimeUs', '?')}us (FPS={baseline.get('fps', '?')}) heap={baseline.get('freeHeap', '?')}")
+ # ids whose optional add_module was skipped (a platform-gated module absent on this
+ # target — e.g. the Parlio driver on a non-P4 board). A later optional measure/remove
+ # that names a skipped id is itself skipped, so an absent driver leaves no trace rather
+ # than failing the run. (perf_full's add/measure/remove driver triples are all optional.)
+ skipped_ids = set()
+
# Live runs `steps` only — `fixture` is the in-process equivalent of what
# main.cpp already wired on the device.
for step_index, step in enumerate(scenario.get("steps", [])):
@@ -332,17 +338,46 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5,
op = step.get("op", "")
step_result = {"name": step_name, "op": op}
+ # 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"):
+ step_result["status"] = "ok"
+ print(f" {op:5} {step.get('id','?')} — skipped (optional, module not present on {target})")
+ results["steps"].append(step_result)
+ continue
+
try:
if op == "add_module":
data = {"type": step["type"], "id": step.get("id", ""),
"parent_id": step.get("parent_id", "")}
- resp = client.post("/api/modules", data)
- step_result["status"] = "ok" if resp.get("ok") else "error"
- if resp.get("note") == "already exists":
- print(f" = {step.get('id', '?')} (exists)")
- else:
- print(f" + {step.get('id', '?')} ({step['type']})")
- created_modules.append(step.get("id", ""))
+ # An `optional` add of a type this target doesn't have is a SKIP, not a
+ # fail — perf_full adds every LED driver (RMT/LCD/Parlio), but each is
+ # platform-gated (LCD/RMT on classic+S3, Parlio on P4), so the absent
+ # ones return "unknown type". The device replies either 400 (HTTPError)
+ # or 200 + ok:false depending on the path; treat both as skip when the
+ # step is optional. Mirrors the optional set_control handling below.
+ try:
+ resp = client.post("/api/modules", data)
+ if resp.get("ok"):
+ step_result["status"] = "ok"
+ if resp.get("note") == "already exists":
+ print(f" = {step.get('id', '?')} (exists)")
+ else:
+ print(f" + {step.get('id', '?')} ({step['type']})")
+ created_modules.append(step.get("id", ""))
+ elif step.get("optional"):
+ step_result["status"] = "ok"
+ skipped_ids.add(step.get("id", ""))
+ print(f" + {step.get('id','?')} ({step['type']}) — skipped (optional, type unavailable on {target})")
+ else:
+ step_result["status"] = "error"
+ except urllib.error.HTTPError:
+ if step.get("optional"):
+ step_result["status"] = "ok"
+ skipped_ids.add(step.get("id", ""))
+ print(f" + {step.get('id','?')} ({step['type']}) — skipped (optional, type unavailable on {target})")
+ else:
+ raise
elif op == "set_control":
data = {"module": step["id"], "control": step["key"],
@@ -372,7 +407,7 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5,
# for us), still give the device a moment — a set_control that
# triggers buildState briefly mutates the module tree, and the
# very next API call can hit a transient "module not found".
- # 500 ms is empirically enough on the Olimex; cheap insurance.
+ # 500 ms is empirically enough on the classic board; cheap insurance.
if not (step.get("measure") or op == "measure"):
time.sleep(0.5)
@@ -381,9 +416,24 @@ def run_scenario(client: Client, scenario_path: Path, settle_s: float = 1.5,
# reads identically on the in-process runner (which uses
# `remove_module`) and here. The two runners must never diverge
# on op names, or a scenario silently no-ops on one tier.
- resp = client.delete(_mod_path(step["id"]))
- step_result["status"] = "ok" if resp.get("ok") else "error"
- print(f" - {step.get('id', '?')}")
+ # An `optional` remove of a module that was never added (its
+ # optional add was skipped — a platform-gated driver absent on this
+ # target) is a SKIP, not a fail: the device returns 404 "module not
+ # found" or ok:false. Pairs with the optional add above.
+ try:
+ resp = client.delete(_mod_path(step["id"]))
+ if resp.get("ok") or not step.get("optional"):
+ step_result["status"] = "ok" if resp.get("ok") else "error"
+ print(f" - {step.get('id', '?')}")
+ else:
+ step_result["status"] = "ok"
+ print(f" - {step.get('id','?')} — skipped (optional, not present)")
+ except urllib.error.HTTPError:
+ if step.get("optional"):
+ step_result["status"] = "ok"
+ print(f" - {step.get('id','?')} — skipped (optional, not present)")
+ else:
+ raise
elif op == "clear_children":
# Delete every child of a container, leaving the container.
diff --git a/src/core/BinaryBroadcaster.h b/src/core/BinaryBroadcaster.h
index 3ddc626..033bdae 100644
--- a/src/core/BinaryBroadcaster.h
+++ b/src/core/BinaryBroadcaster.h
@@ -1,6 +1,8 @@
#pragma once
#include "platform/platform.h" // platform::WriteChunk
+#include
+#include // size_t
namespace mm {
@@ -10,10 +12,27 @@ namespace mm {
// producer depends only on "something I can send bytes to" — not on the HTTP
// server's full surface. Domain-neutral: the bytes' meaning is the caller's.
struct BinaryBroadcaster {
- // Send one binary WS frame whose payload is the given scatter-gather chunks
- // (the implementation prepends the WS frame header). Backpressured clients
- // skip the frame; corrupt / dead sockets are dropped.
- virtual void broadcastBinary(const platform::WriteChunk* payload, int chunkCount) = 0;
+ // Stream ONE binary WS frame whose payload is PUSHED incrementally, so the caller never
+ // holds the whole frame in a buffer. Begin/push/end trio, fitting a forward-only producer
+ // like Layouts::forEachCoord (push from inside its callback):
+ // beginBinaryFrame(totalLen) — build + send the WS header (totalLen = exact payload size)
+ // pushBinaryFrame(data, len) — send the next payload slice (call as many times as needed)
+ // endBinaryFrame() — finish; returns true if every client got the whole frame
+ // The implementation streams straight to the clients with no frame-sized staging buffer, so a
+ // large frame (e.g. PreviewDriver's coordinate table, tens of KB) goes out on a memory-tight
+ // board where a contiguous staging block won't fit. The caller MUST push exactly `totalLen`
+ // bytes between begin and end. Only one frame may be open at a time.
+ virtual void beginBinaryFrame(size_t totalLen) = 0;
+ virtual void pushBinaryFrame(const uint8_t* data, size_t len) = 0;
+ virtual bool endBinaryFrame() = 0;
+
+ // A counter that increments each time a new client connects. A producer whose
+ // first message is stateful (e.g. PreviewDriver's coordinate table, which colour
+ // frames then reference) watches this: when it changes, a fresh client just joined
+ // and needs that priming message re-sent NOW, rather than waiting for the producer's
+ // periodic re-broadcast. Cheap, broadcast-only (no per-client send / inbound routing):
+ // the producer re-broadcasts to everyone, idempotent on existing clients.
+ virtual uint32_t clientGeneration() const = 0;
protected:
~BinaryBroadcaster() = default; // not owned through this interface
diff --git a/src/core/Control.cpp b/src/core/Control.cpp
index 70ef1d8..1cbea20 100644
--- a/src/core/Control.cpp
+++ b/src/core/Control.cpp
@@ -267,6 +267,21 @@ ApplyResult applyControlValue(const ControlDescriptor& c,
// c.max is the buffer size; parseString writes up to maxLen-1 then
// NUL-terminates, so passing c.max gives "fill the buffer".
uint8_t maxLen = static_cast(c.max > 0 ? c.max : 16);
+ // A per-control validator (if set) checks the incoming value before the
+ // write, so a reject leaves the stored value untouched (no partial write).
+ // Parse into a scratch buffer first, validate, then commit — this is the
+ // one backend home every write path shares (HTTP, APPLY_OP, persistence).
+ // scratch is sized to the control's full buffer (maxLen, which is c.max, an
+ // 8-bit bufSize ≤ 255) so a long-but-valid value isn't truncated before the
+ // validator sees it. 256 bytes covers any Text/Password buffer.
+ if (c.validate) {
+ char scratch[256];
+ mm::json::parseString(json, key, scratch, maxLen);
+ if (!c.validate(scratch)) return ApplyResult::Malformed;
+ std::strncpy(static_cast(c.ptr), scratch, maxLen - 1);
+ static_cast(c.ptr)[maxLen - 1] = 0;
+ return ApplyResult::Ok;
+ }
mm::json::parseString(json, key, static_cast(c.ptr), maxLen);
return ApplyResult::Ok;
}
diff --git a/src/core/Control.h b/src/core/Control.h
index 067ddfc..d7249e0 100644
--- a/src/core/Control.h
+++ b/src/core/Control.h
@@ -178,6 +178,13 @@ struct ControlDescriptor {
// users (e.g. SystemModule.deviceModel, which MoonDeck and the web installer
// inject via POST /api/control). HTTP writes still succeed — the flag
// is a UI rendering hint, not a write gate. Set via setReadOnly().
+ // Optional per-control input validator (Text/Password only; nullptr = accept anything
+ // that fits the buffer). applyControlValue calls it on the incoming string BEFORE the
+ // write and returns ApplyResult::Malformed on reject, so the check covers EVERY write
+ // path — HTTP /api/control, APPLY_OP over serial, persistence load — in one place.
+ // A control with a wire-format constraint (e.g. deviceModel's printable-ASCII rule)
+ // declares it here, so the rule lives with the control, not with any one transport.
+ bool (*validate)(const char* value) = nullptr;
};
class ControlList {
@@ -231,9 +238,12 @@ class ControlList {
controls_[count_++] = {&var, name, 0, ControlType::Bool, 0, 1};
}
- void addText(const char* name, char* var, uint8_t bufSize = 16) {
+ // validate (optional): a per-control input check applied on every write path
+ // (see ControlDescriptor::validate). nullptr accepts anything that fits the buffer.
+ void addText(const char* name, char* var, uint8_t bufSize = 16,
+ bool (*validate)(const char*) = nullptr) {
grow();
- controls_[count_++] = {var, name, 0, ControlType::Text, 0, bufSize};
+ controls_[count_++] = {var, name, 0, ControlType::Text, 0, bufSize, false, false, validate};
}
// Like addText but the value is a secret: the API serializes it
diff --git a/src/core/DevicesModule.h b/src/core/DevicesModule.h
index 0f10bfd..5b8f600 100644
--- a/src/core/DevicesModule.h
+++ b/src/core/DevicesModule.h
@@ -369,28 +369,33 @@ class DevicesModule : public MoonModule, public ListSource {
};
static constexpr uint8_t kMdnsServiceCount =
sizeof(kMdnsServices) / sizeof(kMdnsServices[0]);
-
- uint8_t mdnsIndex_ = 0; // which service in kMdnsServices is being browsed
- bool mdnsQuerying_ = false; // a browse query is in flight (Start succeeded)
-
- // One non-blocking step of the mDNS browse cycle, called every tick. State:
- // not querying → start a query for the current service type (advance on fail).
- // querying → poll; when done, merge hits via the static callback, then stop
- // and advance to the next service type for the next tick.
- // The cycle wraps around kMdnsServices forever; new advertisers are picked up on
- // the next pass. Cheap: Poll is a 0 ms async check, never blocks the render loop.
+ // Per-tick mDNS query timeout. Small: this is a blocking call on loop1s, so it must
+ // stay well under the 1 s tick budget (and it shares loop1s with the HTTP sweep). A
+ // peer that doesn't answer within this window is caught on a later pass — discovery is
+ // continuous (every tick cycles to the next service type), so a short timeout per call
+ // mdnsBrowse is synchronous and blocks the FULL timeout (the IDF query waits the whole
+ // window for late responders — it does not return early), and loop1s shares the tick
+ // thread, so this time is charged to the tick. Keep the timeout modest AND browse only
+ // every kMdnsEveryTicks-th tick: one ~60 ms hiccup every ~8 s is invisible for a
+ // discovery feature (peers don't come and go faster than that), and FPS is untouched in
+ // between. (The old async API polled cheaply every tick but raced the mDNS task's expiry
+ // timer and crashed on a UI refresh; a bounded synchronous call holds no handle, so it
+ // can't. The throttle is how we keep that safety without the per-tick block cost.)
+ static constexpr uint32_t kMdnsBrowseMs = 60;
+ static constexpr uint8_t kMdnsEveryTicks = 8;
+
+ uint8_t mdnsIndex_ = 0; // which service in kMdnsServices is browsed
+ uint8_t mdnsTick_ = 0; // throttle counter for the browse cadence
+
+ // Browse one service type on the throttled cadence: query it (blocking, bounded), merge
+ // hits via the static callback, advance to the next type. The cycle wraps kMdnsServices
+ // forever, so new advertisers are picked up on later passes.
void stepMdns() {
- if (!mdnsQuerying_) {
- const MdnsService& s = kMdnsServices[mdnsIndex_];
- mdnsQuerying_ = platform::mdnsBrowseStart(s.service, s.proto);
- if (!mdnsQuerying_) advanceMdns(); // mDNS not up yet / busy — try next tick
- return;
- }
- if (platform::mdnsBrowsePoll(&DevicesModule::onMdnsHost, this)) {
- platform::mdnsBrowseStop();
- mdnsQuerying_ = false;
- advanceMdns();
- }
+ if (++mdnsTick_ < kMdnsEveryTicks) return;
+ mdnsTick_ = 0;
+ const MdnsService& s = kMdnsServices[mdnsIndex_];
+ platform::mdnsBrowse(s.service, s.proto, kMdnsBrowseMs, &DevicesModule::onMdnsHost, this);
+ advanceMdns();
}
void advanceMdns() { mdnsIndex_ = (mdnsIndex_ + 1) % kMdnsServiceCount; }
diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp
index 3cb267e..c787664 100644
--- a/src/core/HttpServerModule.cpp
+++ b/src/core/HttpServerModule.cpp
@@ -41,16 +41,11 @@ void HttpServerModule::teardown() {
}
void HttpServerModule::loop20ms() {
- // Accept one HTTP connection per tick
+ // Accept one HTTP connection per tick. (Preview frames are streamed synchronously by
+ // PreviewDriver via begin/push/endBinaryFrame on its own rate-limited loop — no queue to
+ // drain here.)
auto conn = server_.accept();
- if (conn.valid()) {
- handleConnection(conn);
- return; // don't broadcast in same tick as accept (WebSocket needs time to process 101)
- }
-
- // Binary frames (e.g. the 3D preview) are no longer polled here — their
- // producer (PreviewDriver) pushes them via broadcastBinary() from its own
- // loop. HttpServer owns only the transport, not the content.
+ if (conn.valid()) handleConnection(conn);
}
void HttpServerModule::loop1s() {
@@ -1072,9 +1067,12 @@ void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, con
conn.write(reinterpret_cast(response), respLen);
// Store connection as WebSocket client
- for (auto& ws : wsClients_) {
- if (!ws.valid()) {
- ws = std::move(conn);
+ for (int i = 0; i < MAX_WS_CLIENTS; i++) {
+ if (!wsClients_[i].valid()) {
+ wsClients_[i] = std::move(conn);
+ // A fresh client joined — bump the generation so stateful producers
+ // (PreviewDriver's coordinate table) re-stream their priming message now.
+ wsClientGeneration_++;
return;
}
}
@@ -1123,58 +1121,64 @@ bool HttpServerModule::sendWsTextFrame(platform::TcpConnection& conn, const char
return conn.write(reinterpret_cast(data), len);
}
-void HttpServerModule::broadcastBinary(const platform::WriteChunk* payload, int chunkCount) {
- if (!payload || chunkCount <= 0) return;
-
- // Total payload length = sum of the caller's chunks.
- size_t totalLen = 0;
- for (int i = 0; i < chunkCount; i++) totalLen += payload[i].len;
- if (totalLen == 0) return;
-
- // WebSocket frame header for a binary message of totalLen bytes.
- uint8_t wsHeader[4];
- int wsHeaderLen = 0;
- wsHeader[0] = 0x82; // FIN + binary opcode
- if (totalLen < 126) {
- wsHeader[1] = static_cast(totalLen);
- wsHeaderLen = 2;
- } else if (totalLen < 65536) {
- wsHeader[1] = 126;
- wsHeader[2] = static_cast((totalLen >> 8) & 0xFF);
- wsHeader[3] = static_cast(totalLen & 0xFF);
- wsHeaderLen = 4;
- } else {
- return; // frame too large for the 16-bit length form
+// Write the whole span via repeated non-blocking writeSome; close the client + return false if
+// it can't all go right now (WouldBlock with bytes remaining). Bounded: a tiny retry budget,
+// since DIRECT frames are small by construction (the producer downscales them to fit one shot).
+bool HttpServerModule::sendAllOrClose(platform::TcpConnection& ws, const uint8_t* data, size_t len) {
+ size_t sent = 0;
+ int stalls = 0; // TOTAL WouldBlock spins this span (NOT reset on progress) — hard-bounds
+ // how long this synchronous send can occupy the tick.
+ while (sent < len) {
+ int n = ws.writeSome(data + sent, len - sent);
+ if (n < 0) { ws.close(); return false; } // real socket error
+ if (n == 0) { // WouldBlock — lwIP send buffer momentarily full
+ // Brief no-sleep spin to let the lwIP buffer drain (TCP ACKs free it sub-ms). Capped
+ // on TOTAL spins so a slow link can't hold the tick: once the budget is spent the
+ // send gives up (close + false), and the producer's adaptive downscale shrinks the
+ // next frame so it fits. NOTE: this whole send is still synchronous on the caller's
+ // loop — a large frame on a slow link briefly pauses it. The resumable cross-tick
+ // send (carry a byte cursor, resume next loop) is the follow-up that removes that.
+ if (++stalls > kDirectSendSpins) { ws.close(); return false; }
+ continue;
+ }
+ sent += static_cast(n);
}
+ return true;
+}
- // Scatter-gather: our WS header, then the caller's payload chunks. The
- // payload buffers are caller-owned (e.g. PreviewDriver's downsample buffer);
- // no copy here. Stack array sized for WS header + a small fixed payload
- // (the preview uses 2 chunks). MAX_PAYLOAD_CHUNKS caps it so this stays a
- // stack array, not an allocation in the broadcast path.
- static constexpr int MAX_PAYLOAD_CHUNKS = 4;
- if (chunkCount > MAX_PAYLOAD_CHUNKS) return; // caller bug; don't allocate
- platform::WriteChunk chunks[1 + MAX_PAYLOAD_CHUNKS];
- chunks[0] = { wsHeader, static_cast(wsHeaderLen) };
- for (int i = 0; i < chunkCount; i++) chunks[1 + i] = payload[i];
- const int totalChunks = 1 + chunkCount;
+// Streamed frame: header now, payload pushed in slices, no frame-sized staging buffer — so a
+// large frame (PreviewDriver's coordinate table or colour frame) goes out on a memory-tight
+// board where a contiguous block won't fit. The producer (forEachCoord) pushes forward-only;
+// each slice fans to every client before the next push. A client that can't keep up is closed
+// (its WS message ends incomplete → it reconnects), so this never blocks the tick indefinitely.
+void HttpServerModule::beginBinaryFrame(size_t totalLen) {
+ wsFrameAllSent_ = true;
+ uint8_t wsHeader[10];
+ int wsHeaderLen;
+ wsHeader[0] = 0x82;
+ if (totalLen < 126) { wsHeader[1] = static_cast(totalLen); wsHeaderLen = 2; }
+ else if (totalLen < 65536) {
+ wsHeader[1] = 126; wsHeader[2] = static_cast((totalLen >> 8) & 0xFF);
+ wsHeader[3] = static_cast(totalLen & 0xFF); wsHeaderLen = 4;
+ } else {
+ wsHeader[1] = 127;
+ for (int i = 0; i < 8; i++)
+ wsHeader[2 + i] = static_cast((static_cast(totalLen) >> (56 - 8 * i)) & 0xFF);
+ wsHeaderLen = 10;
+ }
+ for (auto& ws : wsClients_) {
+ if (ws.valid() && !sendAllOrClose(ws, wsHeader, static_cast(wsHeaderLen)))
+ wsFrameAllSent_ = false;
+ }
+}
+void HttpServerModule::pushBinaryFrame(const uint8_t* data, size_t len) {
+ if (!data || len == 0) return;
for (auto& ws : wsClients_) {
- if (!ws.valid()) continue;
- switch (ws.writeChunks(chunks, totalChunks)) {
- case platform::WriteResult::Complete:
- case platform::WriteResult::WouldBlock:
- // WouldBlock: browser is backpressured — skip this frame,
- // keep the connection open (the next frame may fit).
- break;
- case platform::WriteResult::Partial:
- case platform::WriteResult::Error:
- // Partial: a truncated WS message went out — the stream is
- // corrupt, the connection must be dropped. Error: dead socket.
- ws.close();
- break;
- }
+ if (ws.valid() && !sendAllOrClose(ws, data, len)) wsFrameAllSent_ = false;
}
}
+bool HttpServerModule::endBinaryFrame() { return wsFrameAllSent_; }
+
} // namespace mm
diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h
index a3c0d60..444f615 100644
--- a/src/core/HttpServerModule.h
+++ b/src/core/HttpServerModule.h
@@ -34,10 +34,16 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
void setScheduler(Scheduler* s) { scheduler_ = s; }
void setUiPath(const char* path) { uiPath_ = path; }
- // BinaryBroadcaster — send a binary WS frame to every connected client.
- // Producers (PreviewDriver) build the payload chunks; this prepends the WS
- // header. Domain-neutral: no knowledge of what the bytes carry.
- void broadcastBinary(const platform::WriteChunk* payload, int chunkCount) override;
+ // BinaryBroadcaster — stream one binary WS frame to every connected client, pushed
+ // incrementally so no frame-sized buffer is held. Producers (PreviewDriver) push the
+ // payload bytes; this prepends the WS header. Domain-neutral: no knowledge of the content.
+ void beginBinaryFrame(size_t totalLen) override;
+ void pushBinaryFrame(const uint8_t* data, size_t len) override;
+ bool endBinaryFrame() override;
+ // Bumped on each new WS client (see handleWebSocketUpgrade). PreviewDriver watches it to
+ // re-stream its coordinate table the moment a fresh page connects, so a refresh shows the
+ // preview immediately.
+ uint32_t clientGeneration() const override { return wsClientGeneration_; }
// Keep running even when "disabled" via the UI — otherwise the user has no way
// to re-enable themselves through the same UI. The `enabled` checkbox on this
@@ -87,6 +93,20 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
static constexpr int MAX_WS_CLIENTS = 4;
platform::TcpConnection wsClients_[MAX_WS_CLIENTS];
+ uint32_t wsClientGeneration_ = 0; // ++ on each new WS client; see clientGeneration()
+
+ // begin/push/endBinaryFrame stream a binary WS frame straight to every client with NO
+ // frame-sized buffer: the header goes out on begin, each pushed slice is fanned to all
+ // clients, and end reports whether every client got the whole frame. A producer (PreviewDriver
+ // streaming the producer buffer / forEachCoord) holds no copy. wsFrameAllSent_ tracks the
+ // current frame's all-sent result across the push calls.
+ bool wsFrameAllSent_ = true;
+ // Max TOTAL WouldBlock spins for one span in sendAllOrClose before a stuck client is closed.
+ // A healthy socket WouldBlocks only a handful of times even for a 49 KB frame (the lwIP
+ // buffer drains between writes), so this is generous enough not to drop a full-res frame on a
+ // good link, yet finite so a wedged client can't spin forever. (No sleep — a slow link still
+ // briefly occupies the caller's loop; the resumable cross-tick send is the follow-up for that.)
+ static constexpr int kDirectSendSpins = 2000;
// All JSON API responses (/api/state, /api/types, /api/system) and the WS
// state push stream through a JsonSink — no shared fixed-size buffer.
@@ -153,6 +173,9 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
void handleWebSocketUpgrade(platform::TcpConnection& conn, const char* req);
void pushStateToWebSockets();
static bool sendWsTextFrame(platform::TcpConnection& conn, const char* data, int len);
+ // Write the whole span to one client via repeated non-blocking writeSome; close it + return
+ // false if it can't all go (a stuck/too-slow client). The push primitive behind begin/push/end.
+ static bool sendAllOrClose(platform::TcpConnection& ws, const uint8_t* data, size_t len);
};
} // namespace mm
diff --git a/src/core/ImprovProvisioningModule.h b/src/core/ImprovProvisioningModule.h
index 22a1f63..9d35a15 100644
--- a/src/core/ImprovProvisioningModule.h
+++ b/src/core/ImprovProvisioningModule.h
@@ -60,8 +60,6 @@ class ImprovProvisioningModule : public MoonModule {
pendingPassword_, sizeof(pendingPassword_),
&pendingCredentials_,
statusStr_, sizeof(statusStr_),
- pendingDeviceModel_, sizeof(pendingDeviceModel_),
- &pendingDeviceModelReady_,
&pendingTxPower_, &pendingTxPowerReady_,
pendingOp_, sizeof(pendingOp_), &pendingOpReady_);
} else {
@@ -95,15 +93,9 @@ class ImprovProvisioningModule : public MoonModule {
std::memset(pendingPassword_, 0, sizeof(pendingPassword_));
pendingCredentials_.store(false, std::memory_order_release);
}
- // Mirror for vendor SET_DEVICE_MODEL RPC. The Improv task validated the
- // payload on the wire (length, ASCII-printable) and wrote it here;
- // SystemModule::setDeviceModel re-validates (returns false on rejection)
- // so a malformed value never reaches the persisted buffer.
- if (pendingDeviceModelReady_.load(std::memory_order_acquire) && systemModule_) {
- systemModule_->setDeviceModel(pendingDeviceModel_);
- std::memset(pendingDeviceModel_, 0, sizeof(pendingDeviceModel_));
- pendingDeviceModelReady_.store(false, std::memory_order_release);
- }
+ // deviceModel arrives like any other catalog default: an APPLY_OP
+ // `set System.deviceModel` op, routed through the apply-core and the
+ // control's per-control validator (handled in the APPLY_OP poll below).
}
// APPLY_OP is polled per-TICK (not loop1s) because the installer pushes a burst
@@ -151,11 +143,6 @@ class ImprovProvisioningModule : public MoonModule {
char pendingPassword_[64] = {};
std::atomic pendingCredentials_{false};
- // SET_DEVICE_MODEL RPC buffer + ready flag — same producer/consumer dance as
- // pendingCredentials_, sized to SystemModule's deviceModel storage (32 bytes).
- char pendingDeviceModel_[32] = {};
- std::atomic pendingDeviceModelReady_{false};
-
// Vendor SET_TX_POWER RPC — the pre-association TX-power cap (whole dBm)
// for brown-out-prone boards; same producer/consumer shape as the above.
uint8_t pendingTxPower_ = 0;
diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h
index d0c90db..2c5589f 100644
--- a/src/core/SystemModule.h
+++ b/src/core/SystemModule.h
@@ -89,13 +89,14 @@ class SystemModule : public MoonModule {
// deviceModel — the physical-hardware identity (the catalog entry name, e.g.
// "Olimex ESP32-Gateway Rev G"). The device can't self-identify its hardware, so
- // this is INJECTED by tooling: MoonDeck / the device UI's ?deviceModel= inject via
- // HTTP /api/control, or the web installer via the Improv SET_DEVICE_MODEL RPC
- // (which routes through setDeviceModel() below). Display-only in the UI (pushed,
- // never user-typed at the device); bound as Text — not ReadOnly — because Text is
- // auto-persisted by FilesystemModule, and the readonly flag is a UI-render hint
- // that doesn't change persistence or HTTP-write semantics.
- controls_.addText("deviceModel", deviceModel_, sizeof(deviceModel_));
+ // this is INJECTED by tooling: MoonDeck / the device UI via HTTP /api/control, or
+ // the web installer via an APPLY_OP `set System.deviceModel` over serial. It's a
+ // normal Text control like any other default — the printable-ASCII rule below is a
+ // per-control validator (see ControlDescriptor::validate) so EVERY write path
+ // checks it in the backend, wherever the write comes from. Display-only in
+ // the UI (pushed, never user-typed); bound as Text — not ReadOnly — because Text is
+ // auto-persisted and the readonly flag is only a UI-render hint.
+ controls_.addText("deviceModel", deviceModel_, sizeof(deviceModel_), validateDeviceModel);
controls_.setReadOnly(controls_.count() - 1, true);
// Dynamic (updated every second)
@@ -224,39 +225,25 @@ class SystemModule : public MoonModule {
const char* deviceModel() const { return deviceModel_; }
- // External setter for transports that bypass /api/control (today: the web installer's
- // Improv vendor RPC SET_DEVICE_MODEL, routed here by ImprovProvisioningModule).
- // Validates: 1..31 chars, ASCII-printable (0x20–0x7E), no embedded NUL. The printable
- // floor rejects control bytes / NULs that would corrupt downstream consumers — JSON
- // serialization (control bytes need \u escaping at best, break naive emitters at
- // worst), the device UI (rendered verbatim; a BEL/ESC would mangle the page), and
- // C-string handling (no embedded NUL → strlen/strcpy round-trip cleanly). Printable
- // ASCII still contains `"` and `\`, which serializers must escape normally — the
- // floor isn't a license to skip escaping. Returns false on rejection so the Improv
- // handler can map to ErrorState. On accept: copies into deviceModel_ and arms
- // FilesystemModule's debounced save — same idiom as NetworkModule::setWifiCredentials.
- //
- // Known asymmetry: HTTP POST /api/control writes to `deviceModel` go through the
- // generic Text-control write in applyControlValue() (Control.cpp), which does NO
- // printable-ASCII check. A malicious LAN client could write control bytes / NUL via
- // that path. Acceptable today because the HTTP-write callers (MoonDeck, the
- // installer's HTTP inject) source the value from the device-model catalog, which the
- // project controls; there is no end-user-typed input on this field. If the threat
- // model grows, the right fix is a per-control validator hook on ControlDescriptor —
- // not a one-off HTTP dispatch exception. Until then this validation lives only on the
- // SET_DEVICE_MODEL-over-Improv path, the only path where wire-untrusted bytes arrive.
- bool setDeviceModel(const char* value) {
+ // Per-control validator for `deviceModel`, applied on EVERY write path (HTTP
+ // /api/control, APPLY_OP over serial, persistence load) via ControlDescriptor::validate.
+ // Accepts 1..31 chars, ASCII-printable (0x20–0x7E), no embedded NUL. The printable floor
+ // rejects control bytes / NULs that would corrupt downstream consumers — JSON
+ // serialization (control bytes need \u escaping at best, break naive emitters at worst),
+ // the device UI (rendered verbatim; a BEL/ESC would mangle the page), and C-string
+ // handling (no embedded NUL → strlen/strcpy round-trip cleanly). Printable ASCII still
+ // contains `"` and `\`, which serializers must escape normally — the floor isn't a
+ // license to skip escaping. (Length: the 31-char cap matches deviceModel_'s 32-byte
+ // buffer; over-long is rejected, not truncated.) Declaring the rule on the control
+ // keeps it with the data, so it holds for every transport that writes deviceModel.
+ static bool validateDeviceModel(const char* value) {
if (!value) return false;
size_t n = std::strlen(value);
- if (n == 0 || n >= sizeof(deviceModel_)) return false;
+ if (n == 0 || n >= 32) return false; // 1..31 (32-byte buffer, NUL-terminated)
for (size_t i = 0; i < n; i++) {
unsigned char b = static_cast(value[i]);
if (b < 0x20 || b > 0x7E) return false;
}
- std::strncpy(deviceModel_, value, sizeof(deviceModel_) - 1);
- deviceModel_[sizeof(deviceModel_) - 1] = 0;
- markDirty();
- FilesystemModule::noteDirty();
return true;
}
diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h
index 71b7d6d..502e49c 100644
--- a/src/light/drivers/NetworkSendDriver.h
+++ b/src/light/drivers/NetworkSendDriver.h
@@ -37,12 +37,16 @@ class NetworkSendDriver : public DriverBase {
uint8_t ip[4] = {255, 255, 255, 255};
uint8_t protocol = 0; // index into kProtocolOptions
uint16_t universeStart = 0; // first universe (ArtNet/E1.31; DDP is byte-addressed)
+ uint16_t lightCount = 0; // lights to send (0 = the whole buffer); >0 sends the FIRST N,
+ // so a sink can cover just its slice (e.g. some lights to LEDs,
+ // the rest to ArtNet) instead of every light.
uint8_t fps = 50;
void onBuildControls() override {
controls_.addSelect("protocol", protocol, kProtocolOptions, kProtocolCount);
controls_.addIPv4("ip", ip);
controls_.addUint16("universe_start", universeStart);
+ controls_.addUint16("light_count", lightCount);
controls_.addUint8("fps", fps, 1, 120);
}
@@ -113,7 +117,11 @@ class NetworkSendDriver : public DriverBase {
// earlier in-loop allocate had if the allocation itself failed.
const uint8_t* data;
size_t totalBytes;
- const nrOfLightsType nLights = sourceBuffer_->count();
+ // Send the first light_count lights (0 = the whole buffer), so this sink covers only its
+ // slice instead of every light — and so a frame isn't packed/sent for lights it doesn't own.
+ const nrOfLightsType bufLights = sourceBuffer_->count();
+ const nrOfLightsType nLights =
+ (lightCount > 0 && lightCount < bufLights) ? lightCount : bufLights;
// Three guards before applying correction: (a) correction wired,
// (b) corrected_ has the row count we need, (c) corrected_'s
// per-light stride is at least outChannels — otherwise dst + i *
@@ -137,8 +145,10 @@ class NetworkSendDriver : public DriverBase {
data = dst;
totalBytes = static_cast(nLights) * outCh;
} else {
+ // Passthrough (no correction): honour the same light_count cap as the corrected path,
+ // so a sliced sink doesn't fall back to sending the whole buffer.
data = sourceBuffer_->data();
- totalBytes = sourceBuffer_->bytes();
+ totalBytes = static_cast(nLights) * sourceBuffer_->channelsPerLight();
}
// Send the whole frame in one burst — receivers expect a complete
diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h
index 2e4be08..8e44961 100644
--- a/src/light/drivers/PreviewDriver.h
+++ b/src/light/drivers/PreviewDriver.h
@@ -15,13 +15,16 @@ namespace mm {
// per frame). Two message types — PreviewDriver owns both wire formats; the
// HTTP server is a domain-neutral BinaryBroadcaster that just writes the bytes:
//
-// 0x03 coordinate table (one-time, on every LUT rebuild + periodic re-send so
-// new clients catch it):
-// [0x03][count:u16][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count]
+// 0x03 coordinate table (sent when the geometry changes — every LUT/layout rebuild
+// via onBuildState — and when a new client connects, so a refresh gets it; never
+// per-frame):
+// [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count]
// bx/by/bz = bounding-box extent (for client centring); positions are
-// 1 byte/axis (a layout box ≤255/axis is the realistic case).
+// 1 byte/axis (a layout box ≤255/axis is the realistic case). count is u32 so a
+// >65535-light panel (big ArtNet/HUB75 walls) isn't capped by the wire format —
+// it matches nrOfLightsType (u32 on PSRAM boards).
//
-// 0x02 per-frame channels: [0x02][count:u16][stride:u16][(r,g,b) × count]
+// 0x02 per-frame channels: [0x02][count:u32][stride:u16][(r,g,b) × count]
// RGB by driver index, every `stride`-th light. The browser positions
// triple i at coord-table entry i*stride.
//
@@ -66,17 +69,48 @@ class PreviewDriver : public DriverBase {
if (now - lastSendTime_ < interval) return; // rate-limit gate
lastSendTime_ = now;
- // Rebuild + re-broadcast the coordinate table ~once per second (and
- // immediately while it's still empty). The ~1 Hz cadence lets a client
- // that connected after the last onBuildState catch the positions, and
- // rebuilding (not just re-sending a cache) self-heals the case where the
- // layout/source wasn't wired yet at onBuildState time — cheap on the
- // cold path and idempotent on the client.
- if (coordCount_ == 0 || now - lastCoordTime_ >= 1000) {
- buildAndSendCoordTable();
+ // The coordinate table is (re)streamed only when the geometry changes (onBuildState — a
+ // resize / LUT rebuild), when a new client connects (clientGeneration bump, so a page
+ // refresh gets positions immediately), when the adaptive factor changes, or while a
+ // previous stream didn't reach every client (coordPending_ retry). NOT per frame: the
+ // colour frames below reference the last-streamed positions. coordCount_==0 = cold start.
+ uint32_t gen = broadcaster_ ? broadcaster_->clientGeneration() : 0;
+ if (coordCount_ == 0 || gen != lastClientGen_ || coordPending_) {
+ lastClientGen_ = gen;
+ buildAndSendCoordTable(); // streams positions; sets coordPending_ if not all clients got it
}
- sendFrame();
+ // Hold colour frames until the browser has the matching coordinate table — a 0x02 whose
+ // count/stride don't match the last 0x03 is skipped by the browser, and streaming it
+ // anyway wastes the link. sendFrame() returns false if a client couldn't take the whole
+ // frame (it gets closed); treat that as "link can't keep up" for the adaptive step.
+ bool frameOk = true;
+ if (!coordPending_) frameOk = sendFrame();
+
+ // Adaptive resolution. The streamed send is all-or-nothing per client (a client that
+ // can't take the whole frame is closed), so a NOT-all-sent result — for the colour frame
+ // (frameOk false) OR the coord table (coordPending_) — means the link can't sustain this
+ // resolution: coarsen (downscale_++) after a short run so the rebuilt lattice sends fewer
+ // points. A sustained all-sent run refines back toward full resolution (downscale_--).
+ // Hysteresis via the streaks stops oscillation; the factor rides the wire stride field to
+ // the browser's status line. (On a memory-tight board the coord table is simply too big
+ // to push until downscaled — coordPending_ is that "too big" signal.)
+ const bool linkStruggling = coordPending_ || !frameOk;
+ if (linkStruggling) {
+ cleanStreak_ = 0;
+ if (++slowStreak_ >= kDownscaleAfterSlow && downscale_ < 64) {
+ slowStreak_ = 0;
+ downscale_++;
+ buildAndSendCoordTable();
+ }
+ } else {
+ slowStreak_ = 0;
+ if (downscale_ > 1 && ++cleanStreak_ >= kUpscaleAfterFast) {
+ cleanStreak_ = 0;
+ downscale_--;
+ buildAndSendCoordTable();
+ }
+ }
}
// Build (or rebuild) the cached coordinate table from the layout's real
@@ -87,8 +121,6 @@ class PreviewDriver : public DriverBase {
Layouts* layouts = layer_->layouts();
nrOfLightsType n = layouts->totalLightCount();
if (n == 0) return;
- stride_ = computeStride(n);
- coordCount_ = (n + stride_ - 1) / stride_; // points actually sent
// Positions are 1 byte/axis. To support layouts whose bounding box
// exceeds 255 on an axis (a 512-wide grid, say), scale every axis by the
@@ -105,89 +137,144 @@ class PreviewDriver : public DriverBase {
by_ = scaleAxis(layer_->physicalHeight());
bz_ = scaleAxis(layer_->physicalDepth());
- // Header: [0x03][count:u16][bx][by][bz][stride:u16]
- uint8_t* h = coordHeader_;
+ // Per-axis downsample step s (lattice skip x%s && y%s && z%s). The cell count of the
+ // bounding box is the upper bound on kept lights, so grow s until it fits the cap — but
+ // ONLY when the layout has more lights than the cap (a sparse layout — big box, few
+ // lights — fits at s==1 and must not be downsampled for its box size alone). The wire
+ // "stride" field carries s to the browser (1 = full res; >1 = "1/s shown, link limited").
+ const lengthType ax = layer_->physicalWidth() > 0 ? layer_->physicalWidth() : 1;
+ const lengthType ay = layer_->physicalHeight() > 0 ? layer_->physicalHeight() : 1;
+ const lengthType az = layer_->physicalDepth() > 0 ? layer_->physicalDepth() : 1;
+ nrOfLightsType s = 1;
+ if (n > MAX_PREVIEW_POINTS) {
+ auto latticeCount = [&](nrOfLightsType step) {
+ nrOfLightsType cx = (ax + step - 1) / step, cy = (ay + step - 1) / step,
+ cz = (az + step - 1) / step;
+ return static_cast(cx) * cy * cz;
+ };
+ while (latticeCount(s) > MAX_PREVIEW_POINTS) s++;
+ }
+ if (s < downscale_) s = downscale_; // adaptive: never finer than the link sustains
+ previewStride_ = s;
+
+ // Count the lights the lattice keeps (one cheap forEachCoord pass — no allocation). This
+ // is the 0x03 count, and the per-frame colour pass re-applies the SAME predicate over the
+ // SAME forEachCoord order, so colour[k] lines up with coord[k] with no stored index map.
+ struct CountCtx { nrOfLightsType s, out; };
+ CountCtx cc{s, 0};
+ layouts->forEachCoord([](void* c, nrOfLightsType, lengthType x, lengthType y, lengthType z) {
+ auto* p = static_cast(c);
+ if (x % p->s == 0 && y % p->s == 0 && z % p->s == 0) p->out++;
+ }, &cc);
+ coordCount_ = cc.out;
+ if (coordCount_ == 0) { coordPending_ = false; return; }
+
+ // STREAM the coordinate table: WS header (count + box + stride), then push each kept
+ // light's scaled (x,y,z) straight from forEachCoord — no coords_ buffer ever exists.
+ // (positions are sent rarely: on geometry change / new client / downscale change.)
+ uint8_t h[10];
h[0] = 0x03;
h[1] = static_cast(coordCount_ & 0xFF);
- h[2] = static_cast(coordCount_ >> 8);
- h[3] = bx_; h[4] = by_; h[5] = bz_;
- h[6] = static_cast(stride_ & 0xFF);
- h[7] = static_cast(stride_ >> 8);
-
- // Pack (x,y,z) for every stride-th light, in forEachCoord (driver) order.
- if (!coords_.data() || coords_.count() < coordCount_) {
- coords_.allocate(MAX_PREVIEW_POINTS, 3); // owned u8×3 position buffer
- }
- if (!coords_.data()) { coordCount_ = 0; return; }
- struct PackCtx { PreviewDriver* self; uint8_t* dst; nrOfLightsType stride; nrOfLightsType out; nrOfLightsType cap; };
- PackCtx pc{this, coords_.data(), stride_, 0, coordCount_};
- layouts->forEachCoord([](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) {
- auto* p = static_cast(c);
- if (idx % p->stride != 0) return;
- if (p->out >= p->cap) return;
- uint8_t* d = p->dst + static_cast(p->out) * 3;
- d[0] = p->self->scaleAxis(x); d[1] = p->self->scaleAxis(y); d[2] = p->self->scaleAxis(z);
- p->out++;
- }, &pc);
-
- lastCoordTime_ = platform::millis();
- sendCoordTable();
+ h[2] = static_cast((coordCount_ >> 8) & 0xFF);
+ h[3] = static_cast((coordCount_ >> 16) & 0xFF);
+ h[4] = static_cast((coordCount_ >> 24) & 0xFF);
+ h[5] = bx_; h[6] = by_; h[7] = bz_;
+ h[8] = static_cast(s & 0xFF);
+ h[9] = static_cast(s >> 8);
+
+ if (!broadcaster_) { coordPending_ = true; return; }
+ broadcaster_->beginBinaryFrame(sizeof(h) + static_cast(coordCount_) * 3);
+ broadcaster_->pushBinaryFrame(h, sizeof(h));
+ // Push positions in small slices: forEachCoord fills a stack scratch, flushed when full.
+ struct StreamCtx {
+ PreviewDriver* self; mm::BinaryBroadcaster* bc; nrOfLightsType s;
+ uint8_t buf[1536]; uint16_t fill;
+ };
+ StreamCtx sc{this, broadcaster_, s, {}, 0};
+ layouts->forEachCoord([](void* c, nrOfLightsType, lengthType x, lengthType y, lengthType z) {
+ auto* p = static_cast(c);
+ if (x % p->s != 0 || y % p->s != 0 || z % p->s != 0) return;
+ p->buf[p->fill++] = p->self->scaleAxis(x);
+ p->buf[p->fill++] = p->self->scaleAxis(y);
+ p->buf[p->fill++] = p->self->scaleAxis(z);
+ if (p->fill > sizeof(p->buf) - 3) { p->bc->pushBinaryFrame(p->buf, p->fill); p->fill = 0; }
+ }, &sc);
+ if (sc.fill) broadcaster_->pushBinaryFrame(sc.buf, sc.fill);
+ // The coord table must reach the browser before colour frames carrying the new count
+ // (else the browser's count-mismatch guard skips them). endBinaryFrame() reports whether
+ // every client got it; loop() retries while pending and withholds colour frames.
+ coordPending_ = !broadcaster_->endBinaryFrame();
}
- // Produce + push one per-frame 0x02 RGB message. Public so tests can drive
- // it without the loop() rate-limit.
- void sendFrame() {
- if (!broadcaster_ || !sourceBuffer_ || !sourceBuffer_->data() || coordCount_ == 0) return;
+ // STREAM one per-frame 0x02 RGB message from the producer buffer — no intermediate buffer.
+ // Returns whether every client got it (false → loop() drives adaptive downscaling). Public
+ // so tests can drive it without the loop() rate-limit.
+ bool sendFrame() {
+ if (!broadcaster_ || !sourceBuffer_ || !sourceBuffer_->data() || coordCount_ == 0) return false;
const uint8_t* src = sourceBuffer_->data();
- uint8_t cpl = sourceBuffer_->channelsPerLight();
- nrOfLightsType n = sourceBuffer_->count();
-
- // RGB scratch sized to the sent-point count; pick every stride-th light.
- if (!rgb_.data() || rgb_.count() < coordCount_) rgb_.allocate(MAX_PREVIEW_POINTS, 3);
- if (!rgb_.data()) return;
- uint8_t* dst = rgb_.data();
- nrOfLightsType out = 0;
- for (nrOfLightsType i = 0; i < n && out < coordCount_; i += stride_) {
- const uint8_t* s = src + static_cast(i) * cpl;
- dst[out * 3 + 0] = s[0];
- dst[out * 3 + 1] = cpl >= 2 ? s[1] : 0;
- dst[out * 3 + 2] = cpl >= 3 ? s[2] : 0;
- out++;
- }
+ const uint8_t cpl = sourceBuffer_->channelsPerLight();
+ const nrOfLightsType n = sourceBuffer_->count();
+ const nrOfLightsType s = previewStride_;
- uint8_t header[5];
+ // Header: [0x02][count:u32 LE][stride:u16 LE] (7 bytes). count = the kept lights.
+ uint8_t header[7];
header[0] = 0x02;
- header[1] = static_cast(out & 0xFF);
- header[2] = static_cast(out >> 8);
- header[3] = static_cast(stride_ & 0xFF);
- header[4] = static_cast(stride_ >> 8);
-
- const platform::WriteChunk payload[] = {
- { header, sizeof(header) },
- { dst, static_cast(out) * 3 },
- };
- broadcaster_->broadcastBinary(payload, 2);
+ header[1] = static_cast(coordCount_ & 0xFF);
+ header[2] = static_cast((coordCount_ >> 8) & 0xFF);
+ header[3] = static_cast((coordCount_ >> 16) & 0xFF);
+ header[4] = static_cast((coordCount_ >> 24) & 0xFF);
+ header[5] = static_cast(s & 0xFF);
+ header[6] = static_cast(s >> 8);
+
+ broadcaster_->beginBinaryFrame(sizeof(header) + static_cast(coordCount_) * 3);
+ broadcaster_->pushBinaryFrame(header, sizeof(header));
+
+ if (s == 1 && cpl == 3 && coordCount_ <= n) {
+ // FULL RES, RGB: the producer buffer IS the payload — push it 1:1, no copy, no walk.
+ // The common case (any grid ≤ cap, incl. 16K on a no-PSRAM classic): zero buffers.
+ broadcaster_->pushBinaryFrame(src, static_cast(coordCount_) * 3);
+ } else {
+ // Downsampled (s>1) or non-RGB (cpl≠3): walk forEachCoord with the SAME lattice skip
+ // the coord table used — same subset, same order, so colour[k] ↔ coord[k] line up
+ // with no stored index map. Push 3 bytes/light through a small stack scratch (the RGB
+ // is read straight from the producer buffer at the light's driver index).
+ struct ColCtx {
+ mm::BinaryBroadcaster* bc; const uint8_t* src; nrOfLightsType n, s; uint8_t cpl;
+ uint8_t buf[1536]; uint16_t fill;
+ };
+ // s is the FULL lattice stride (not clamped): the colour pass must use the SAME
+ // predicate as buildAndSendCoordTable's, else above stride 255 the two disagree and
+ // colour[k] no longer lines up with coord[k] (browser drops the mismatched frame).
+ ColCtx col{broadcaster_, src, n, s, cpl, {}, 0};
+ layer_->layouts()->forEachCoord([](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) {
+ auto* p = static_cast(c);
+ if (x % p->s != 0 || y % p->s != 0 || z % p->s != 0) return;
+ const uint8_t* px = (idx < p->n) ? p->src + static_cast(idx) * p->cpl : nullptr;
+ p->buf[p->fill++] = px ? px[0] : 0;
+ p->buf[p->fill++] = (px && p->cpl >= 2) ? px[1] : 0;
+ p->buf[p->fill++] = (px && p->cpl >= 3) ? px[2] : 0;
+ if (p->fill > sizeof(p->buf) - 3) { p->bc->pushBinaryFrame(p->buf, p->fill); p->fill = 0; }
+ }, &col);
+ if (col.fill) broadcaster_->pushBinaryFrame(col.buf, col.fill);
+ }
+ return broadcaster_->endBinaryFrame();
}
private:
- // Send-buffer cap: a preview message is one non-blocking writev. lwIP's TCP
- // send buffer is 11520 B (CONFIG_LWIP_TCP_SND_BUF_DEFAULT), but it is NOT
- // reliably all-free at send time (the render task shares it, frames go out at
- // the render rate), so a payload near the ceiling partial-writes — and a
- // partial write drops the WS connection (broadcastBinary closes on Partial),
- // making the preview flicker/blank. Cap at 1800 points → 1800×3 = 5400 B
- // payload + headers ≈ 5.4 KB, well under half the buffer — the same safe
- // headroom the pre-point-list code used (1849 voxels). Larger layouts stride
- // down to fit.
- static constexpr nrOfLightsType MAX_PREVIEW_POINTS = 1800;
-
- // Smallest stride whose sent-point count fits the cap. stride 1 (exact) for
- // anything ≤ MAX_PREVIEW_POINTS — every sparse layout and small grid.
- static nrOfLightsType computeStride(nrOfLightsType n) {
- nrOfLightsType s = 1;
- while ((n + s - 1) / s > MAX_PREVIEW_POINTS) s++;
- return s;
- }
+ // Frame cap: the most points one preview frame carries before the spatial-lattice
+ // downsample engages. There is no per-frame buffer — the colour frame streams straight from
+ // the producer buffer (and the coord table from forEachCoord) through the broadcaster's
+ // beginBinaryFrame/pushBinaryFrame/endBinaryFrame, so the cap isn't a buffer size but the
+ // point count the device can stream comfortably without the per-frame work and wire bytes
+ // dominating its loop. PSRAM boards stream far more points than a no-PSRAM classic; two
+ // compile-time tiers off platform::hasPsram, with the spatial-lattice downsample as the
+ // graceful fallback above the cap. Tune against the per-board live sweep (the break point
+ // each board actually hits). The literals are split so each fits its board's nrOfLightsType
+ // (u16 on classic, u32 on PSRAM) — a single ternary would force both constants through the
+ // u16 type on a classic build and overflow.
+ static constexpr nrOfLightsType MAX_PREVIEW_POINTS =
+ platform::hasPsram ? static_cast(131072u) // PSRAM: 128K pts (384 KB) into PSRAM
+ : static_cast(16384u); // classic: ~16K pts (48 KB) internal RAM
// Map an axis coordinate into the 0..255 byte range. posScale_ == 0 means
// the box already fits (1:1, exact integer positions); otherwise scale by
@@ -199,26 +286,29 @@ class PreviewDriver : public DriverBase {
return s > 255 ? 255 : static_cast(s);
}
- void sendCoordTable() {
- if (!broadcaster_ || coordCount_ == 0 || !coords_.data()) return;
- const platform::WriteChunk payload[] = {
- { coordHeader_, sizeof(coordHeader_) },
- { coords_.data(), static_cast(coordCount_) * 3 },
- };
- broadcaster_->broadcastBinary(payload, 2);
- }
-
Buffer* sourceBuffer_ = nullptr;
BinaryBroadcaster* broadcaster_ = nullptr;
- Buffer coords_; // owned; u8×3 positions, one per sent point
- Buffer rgb_; // owned; u8×3 colours, one per sent point
- uint8_t coordHeader_[8] = {};
- nrOfLightsType coordCount_ = 0; // points actually sent (after stride)
- nrOfLightsType stride_ = 1;
+ nrOfLightsType coordCount_ = 0; // lights the lattice keeps = the streamed 0x03/0x02 count
+ nrOfLightsType previewStride_ = 1; // wire field: the lattice/downscale factor (1 = full res)
+ bool coordPending_ = false; // coord table not yet delivered; loop() retries it
uint8_t bx_ = 0, by_ = 0, bz_ = 0;
int32_t posScale_ = 0; // 0 = positions 1:1; else largest box edge (>255) to scale by
uint32_t lastSendTime_ = 0;
- uint32_t lastCoordTime_ = 0;
+ uint32_t lastClientGen_ = 0; // last seen broadcaster_->clientGeneration() — re-send coords on change
+
+ // Adaptive downscaling. The preview streams at the finest resolution the link sustains.
+ // The streamed send is all-or-nothing per client, so a frame (colour or coord table) that
+ // doesn't reach every client means the link can't keep up at this resolution: coarsen
+ // (downscale_++) after a short run of such frames so the rebuilt lattice sends fewer points.
+ // A sustained run of fully-sent frames refines back toward full resolution (downscale_--).
+ // downscale_ is an extra floor on the per-axis lattice stride, composing with the cap
+ // downsample; it rides the wire stride field to the browser's "preview 1/N · link limited"
+ // status. (≥1; 1 = full resolution.) Hysteresis via the streak thresholds stops oscillation.
+ nrOfLightsType downscale_ = 1;
+ uint8_t slowStreak_ = 0; // consecutive frames the link couldn't fully send
+ uint8_t cleanStreak_ = 0; // consecutive fully-sent frames
+ static constexpr uint8_t kDownscaleAfterSlow = 4; // coarsen after this many struggling frames
+ static constexpr uint8_t kUpscaleAfterFast = 20; // refine after this many clean frames
};
} // namespace mm
diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h
index 008f5dc..d21eb9f 100644
--- a/src/light/drivers/RmtLedDriver.h
+++ b/src/light/drivers/RmtLedDriver.h
@@ -197,7 +197,12 @@ class RmtLedDriver : public DriverBase {
if constexpr (platform::rmtTxChannels == 0) return; // inert off RMT chips
if (!inited_ || !sourceBuffer_ || !sourceBuffer_->data() || !correction_) return;
- const nrOfLightsType n = sourceBuffer_->count();
+ // Encode only the lights the pins actually transmit (Σ pinCounts_), NOT the whole source
+ // buffer: a strand config of e.g. 64 leds/pin on a 16K-light grid drives 64, so encoding
+ // all 16384 would burn ~100× the work the output needs (the rest is never clocked out).
+ // Bounded by the buffer too, in case config outruns the current frame.
+ const nrOfLightsType bufN = sourceBuffer_->count();
+ const nrOfLightsType n = txLightCount_ < bufN ? txLightCount_ : bufN;
const uint8_t outCh = correction_->outChannels;
// Same defensive guard ArtNet uses: skip rather than overrun if the
// symbol buffer is stale (e.g. correction swapped without a resize).
@@ -226,11 +231,21 @@ class RmtLedDriver : public DriverBase {
// no done-callback, so waiting on it would block the full 1000 ms timeout
// and a single bad pin would stall the tick (the same guard the LCD /
// Parlio loops use, here per channel).
+ // Transmit only up to the n lights actually encoded this frame: pins are laid out
+ // contiguously from light 0, so pin i covers lights [pinStart, pinStart+pinCounts_[i]).
+ // Normally Σ pinCounts_ == n, but if the buffer shrank since the last parseConfig (a grid
+ // resize lands a tick before the config re-parse) n can be below Σ pinCounts_ — cap each
+ // pin at the encoded boundary so it never clocks out stale symbols past what we wrote.
+ const size_t wordsPerLight = static_cast(outCh) * 8;
bool started[kMaxPins] = {};
for (uint8_t i = 0; i < pinCount_; i++) {
- if (pinCounts_[i] == 0) continue;
+ const nrOfLightsType pinStart = static_cast(pinOffsets_[i] / wordsPerLight);
+ if (pinStart >= n) break; // contiguous: this pin and all later ones are past the encoded lights
+ const nrOfLightsType pinLights =
+ (pinStart + pinCounts_[i] > n) ? static_cast(n - pinStart) : pinCounts_[i];
+ if (pinLights == 0) continue;
started[i] = platform::rmtWs2812Transmit(rmt_[i], symbols_ + pinOffsets_[i],
- static_cast(pinCounts_[i]) * outCh * 8);
+ static_cast(pinLights) * wordsPerLight);
}
for (uint8_t i = 0; i < pinCount_; i++) {
if (started[i]) platform::rmtWs2812Wait(rmt_[i], 1000 /* ms */);
@@ -261,6 +276,7 @@ class RmtLedDriver : public DriverBase {
uint16_t pinList_[kMaxPins] = {}; // parsed pins, list order
nrOfLightsType pinCounts_[kMaxPins] = {}; // lights per pin (slice lengths)
size_t pinOffsets_[kMaxPins] = {}; // slice start in symbols_, words
+ nrOfLightsType txLightCount_ = 0; // Σ pinCounts_ — lights actually transmitted/encoded
uint8_t pinCount_ = 0; // 0 = idle (parse error / no pins)
bool inited_ = false; // all-or-nothing across the pins
uint32_t* symbols_ = nullptr; // owned; one word per WS2812 data bit
@@ -315,9 +331,11 @@ class RmtLedDriver : public DriverBase {
pinCount_ = n;
const uint8_t outCh = correction_ ? correction_->outChannels : 0;
size_t off = 0;
+ txLightCount_ = 0;
for (uint8_t i = 0; i < pinCount_; i++) {
pinOffsets_[i] = off;
off += static_cast(pinCounts_[i]) * outCh * 8;
+ txLightCount_ = static_cast(txLightCount_ + pinCounts_[i]);
}
clearConfigErr();
return true;
diff --git a/src/light/layers/Layer.h b/src/light/layers/Layer.h
index bfb48d6..9ae29b3 100644
--- a/src/light/layers/Layer.h
+++ b/src/light/layers/Layer.h
@@ -250,14 +250,15 @@ class Layer : public MoonModule {
width_ = physicalWidth_;
height_ = physicalHeight_;
depth_ = physicalDepth_;
- if (!sparse) {
- // Dense grid: box cell i IS light i. Identity (memcpy) — unchanged.
+ if (!sparse && isNaturalOrder(boxCount)) {
+ // Dense grid in natural order: box cell i IS driver light i. Identity (memcpy).
lut_.setIdentity(boxCount);
allocateBuffer(boxCount);
return;
}
- // Sparse: build box→driver LUT (each box cell → its driver index, or
- // nothing). logicalCount = boxCount, ≤1 destination per cell.
+ // Sparse (some box cells have no light) OR dense-but-shuffled (a serpentine grid: same
+ // count, but driver index i ≠ box cell i) → build the box→driver LUT so the driver
+ // buffer is the real lights in driver order. logicalCount = boxCount, ≤1 dest per cell.
buildSparseIdentityLUT(boxCount, driverCount);
allocateBuffer(boxCount); // layer (render) buffer stays the dense box
return;
@@ -373,6 +374,24 @@ class Layer : public MoonModule {
// Sentinel: a box cell that is not a real light (no driver index).
static constexpr nrOfLightsType kNoDriver = static_cast(-1);
+ // Does the layout emit lights in natural box order — driver index i == box cell i (x fastest,
+ // then y, then z)? Measured, not declared: one allocation-free forEachCoord pass over the same
+ // coords the LUT build would walk, so there's a single source of truth (the coords) and no
+ // per-layout hint to keep in sync. True → the dense memcpy fast path is valid; false → a
+ // reordered grid (serpentine) needs the box→driver LUT. Only meaningful for a dense layout
+ // (boxCount == driverCount); a sparse layout already routes to the LUT via the count check.
+ bool isNaturalOrder(nrOfLightsType boxCount) const {
+ struct Ctx { lengthType w, h; nrOfLightsType box; bool ok; };
+ Ctx ctx{physicalWidth_, physicalHeight_, boxCount, true};
+ layouts_->forEachCoord([](void* c, nrOfLightsType driverIdx, lengthType x, lengthType y, lengthType z) {
+ auto* k = static_cast(c);
+ nrOfLightsType box = static_cast(z) * k->w * k->h
+ + static_cast(y) * k->w + x;
+ if (driverIdx != box) k->ok = false;
+ }, &ctx);
+ return ctx.ok;
+ }
+
// Allocate + fill a box-cell → driver-index map from the layout's real
// lights (Layouts::forEachCoord emits (driverIdx, x, y, z) in driver order).
// Cells with no light hold kNoDriver. Caller owns the returned block
diff --git a/src/light/layouts/GridLayout.h b/src/light/layouts/GridLayout.h
index ebf7420..c742758 100644
--- a/src/light/layouts/GridLayout.h
+++ b/src/light/layouts/GridLayout.h
@@ -18,11 +18,14 @@ class GridLayout : public LayoutBase {
lengthType width = defaultGridSize;
lengthType height = defaultGridSize;
lengthType depth = 1;
+ bool serpentine = false; // odd rows wired in reverse (boustrophedon) — the standard matrix
+ // strip layout where the strip snakes back and forth row to row.
void onBuildControls() override {
controls_.addInt16("width", width, 1, 512);
controls_.addInt16("height", height, 1, 512);
controls_.addInt16("depth", depth, 1, 512);
+ controls_.addBool("serpentine", serpentine);
}
nrOfLightsType lightCount() const override {
@@ -40,7 +43,13 @@ class GridLayout : public LayoutBase {
uint32_t idx = 0;
for (lengthType z = 0; z < depth && idx < limit; z++) {
for (lengthType y = 0; y < height && idx < limit; y++) {
- for (lengthType x = 0; x < width && idx < limit; x++) {
+ // Serpentine: the strip enters odd rows from the high-x end, so the driver index
+ // walks x in reverse there. The emitted COORDINATE is still the true (x,y,z) — only
+ // the index→position order changes, which is exactly what makes the mapping
+ // non-identity (driver index i ≠ box cell i).
+ const bool reverse = serpentine && (y & 1);
+ for (lengthType i = 0; i < width && idx < limit; i++) {
+ const lengthType x = reverse ? static_cast(width - 1 - i) : i;
cb(ctx, static_cast(idx++), x, y, z);
}
}
diff --git a/src/main.cpp b/src/main.cpp
index d783429..1a1ccc6 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -179,8 +179,9 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) {
// The deviceModel identity (e.g. "Olimex ESP32-Gateway Rev G") is now SystemModule's
// `deviceModel` control — no separate module. SystemModule owns the device identity
- // (deviceName + deviceModel) directly; tooling injects deviceModel via /api/control or
- // the Improv SET_DEVICE_MODEL RPC (routed to SystemModule::setDeviceModel by Improv).
+ // (deviceName + deviceModel) directly; tooling injects deviceModel like any catalog
+ // default — via /api/control (MoonDeck) or an APPLY_OP `set System.deviceModel` over
+ // serial (the installer) — both routed through the apply-core + the control's validator.
// AudioModule is NOT auto-wired. It is a mic peripheral, useful only on a board
// that actually has an I2S microphone, so the user adds it through the UI when
@@ -226,11 +227,9 @@ void mm_main(volatile bool& keepRunning, uint16_t httpPort) {
mm::ModuleFactory::create("ImprovProvisioningModule"));
improvModule->setSystemModule(systemModule);
improvModule->setNetworkModule(networkModule);
- // SET_DEVICE_MODEL vendor RPC (command 0xFE, see platform_esp32_improv.cpp).
- // ImprovProvisioningModule's loop1s() picks up the validated payload and forwards
- // to systemModule->setDeviceModel() (the deviceModel identity lives on SystemModule
- // now), arming the standard FilesystemModule debounced save — same idiom as
- // MoonDeck's HTTP push. (systemModule is already wired above.)
+ // systemModule is wired for GET_DEVICE_INFO (the device name) and networkModule
+ // for WIFI_SETTINGS credentials; deviceModel arrives as an APPLY_OP
+ // `set System.deviceModel`, like any catalog default.
// Mark wired-by-code so applyNode's trim loop preserves it on devices
// whose saved Network.json predates the Improv child (the upgrade case).
improvModule->markWiredByCode();
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index bf39d60..11c606a 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -20,7 +20,6 @@
#include // _fileno, _commit (POSIX fileno/fsync equivalents)
#else
#include
-#include
#include
#include
#include
@@ -406,11 +405,10 @@ bool wifiSetTxPower(int8_t quarterDbm) { return quarterDbm == 0; }
bool mdnsInit(const char* /*deviceName*/) { return false; }
void mdnsStop() {}
void mdnsShutdown() {}
-// No mDNS on desktop — browse is a no-op (Start fails, Poll reports "done" with no hits).
-// A PC instance discovers peers via the HTTP sweep instead (see DevicesModule).
-bool mdnsBrowseStart(const char* /*service*/, const char* /*proto*/) { return false; }
-bool mdnsBrowsePoll(MdnsHostCb /*cb*/, void* /*user*/) { return true; }
-void mdnsBrowseStop() {}
+// No mDNS on desktop — browse is a no-op (no hosts found). A PC instance discovers peers
+// via the HTTP sweep instead (see DevicesModule).
+bool mdnsBrowse(const char* /*service*/, const char* /*proto*/, uint32_t /*timeoutMs*/,
+ MdnsHostCb /*cb*/, void* /*user*/) { return false; }
// OTA — no-op on desktop (no OTA partition). The /api/firmware/url route
// guards with `if constexpr (mm::platform::hasOta)` and returns 501 here,
@@ -564,8 +562,6 @@ bool improvProvisioningInit(const ImprovDeviceInfo& /*info*/,
char* /*passwordOut*/, size_t /*passwordOutLen*/,
std::atomic* /*ready*/,
char* statusBuf, size_t statusBufLen,
- char* /*deviceModelOut*/, size_t /*deviceModelOutLen*/,
- std::atomic* /*deviceModelReady*/,
uint8_t* /*txPowerOut*/,
std::atomic* /*txPowerReady*/,
char* /*opOut*/, size_t /*opOutLen*/,
@@ -702,59 +698,31 @@ bool TcpConnection::write(const uint8_t* data, size_t len) {
return true;
}
-WriteResult TcpConnection::writeChunks(const WriteChunk* chunks, int count) {
- if (fd_ < 0) return WriteResult::Error;
- if (count < 1 || count > MAX_WRITE_CHUNKS) return WriteResult::Error;
-#ifdef _WIN32
- // WSASend takes a WSABUF[] — same scatter-gather shape as iovec[]. Windows
- // has no MSG_DONTWAIT flag, so flip the socket to non-blocking just for the
- // duration of this call (and back). The socket is blocking by default for
- // recv()'s SO_RCVTIMEO behaviour (see TcpServer::accept).
- WSABUF bufs[MAX_WRITE_CHUNKS];
- size_t total = 0;
- for (int i = 0; i < count; i++) {
- bufs[i].buf = reinterpret_cast(const_cast(chunks[i].data));
- bufs[i].len = static_cast(chunks[i].len);
- total += chunks[i].len;
- }
- u_long nonblocking = 1;
- ::ioctlsocket(sock(fd_), FIONBIO, &nonblocking);
- DWORD sentBytes = 0;
- int rc = ::WSASend(sock(fd_), bufs, static_cast(count),
- &sentBytes, 0, nullptr, nullptr);
- int err = (rc == SOCKET_ERROR) ? ::WSAGetLastError() : 0;
- u_long blocking = 0;
- ::ioctlsocket(sock(fd_), FIONBIO, &blocking);
- if (rc == SOCKET_ERROR) {
- return (err == WSAEWOULDBLOCK) ? WriteResult::WouldBlock : WriteResult::Error;
- }
- if (sentBytes == 0) return WriteResult::WouldBlock;
- if (static_cast(sentBytes) == total) return WriteResult::Complete;
- return WriteResult::Partial;
-#else
- struct iovec iov[MAX_WRITE_CHUNKS];
- size_t total = 0;
- for (int i = 0; i < count; i++) {
- iov[i].iov_base = const_cast(chunks[i].data);
- iov[i].iov_len = chunks[i].len;
- total += chunks[i].len;
- }
- // sendmsg + MSG_DONTWAIT makes this single scatter-gather write non-blocking
- // regardless of the socket's blocking mode (the desktop client socket uses a
- // read timeout, not O_NONBLOCK, so writev alone would block).
- struct msghdr msg{};
- msg.msg_iov = iov;
- msg.msg_iovlen = static_cast(count);
- ssize_t n = ::sendmsg(fd_, &msg, MSG_DONTWAIT);
- if (n < 0) {
- return sockWouldBlock() ? WriteResult::WouldBlock : WriteResult::Error;
- }
- if (n == 0) return WriteResult::WouldBlock;
- if (static_cast(n) == total) return WriteResult::Complete;
- return WriteResult::Partial;
+int TcpConnection::writeSome(const uint8_t* data, size_t len) {
+ if (fd_ < 0) return -1;
+ if (len == 0) return 0;
+ // The client socket is blocking (SO_RCVTIMEO drives recv's read timeout), so a plain
+ // ::send() would block when the kernel send buffer is full. Toggle non-blocking around the
+ // send to keep this truly non-blocking, then restore so recv's timeout semantics hold.
+ // Evaluate the would-block / EINTR status BEFORE make_blocking, since that ioctl/fcntl
+ // can clobber the error state.
+ make_nonblocking(fd_);
+ auto n = ::send(sock(fd_), reinterpret_cast(data), static_cast(len), 0);
+ bool wouldBlock = (n < 0) && sockWouldBlock();
+#ifndef _WIN32
+ bool interrupted = (n < 0) && (errno == EINTR);
#endif
+ make_blocking(fd_);
+ if (n > 0) return static_cast(n);
+ if (n == 0) return 0;
+ if (wouldBlock) return 0; // buffer full — try later
+#ifndef _WIN32
+ if (interrupted) return 0; // interrupted — try later
+#endif
+ return -1; // real socket error
}
+
void TcpConnection::close() {
if (fd_ >= 0) {
close_sock(fd_);
@@ -807,7 +775,7 @@ TcpConnection TcpServer::accept() {
int clientFd = static_cast(client);
// Match POSIX: socket stays blocking, SO_RCVTIMEO gives recv a 2-second
// timeout. Windows SO_RCVTIMEO takes a DWORD millisecond count (not a
- // timeval). writeChunks toggles non-blocking around its WSASend call to
+ // timeval). writeSome() toggles non-blocking around its send call to
// emulate POSIX's MSG_DONTWAIT.
DWORD timeoutMs = 2000;
::setsockopt(client, SOL_SOCKET, SO_RCVTIMEO,
diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h
index bd87048..ede34f0 100644
--- a/src/platform/esp32/platform_config.h
+++ b/src/platform/esp32/platform_config.h
@@ -109,7 +109,7 @@ constexpr bool hasWiFi = true;
constexpr bool hasWifiCoprocessor = isEsp32P4 && hasWiFi;
// Ethernet is only available on firmware variants whose sdkconfig fragment
-// enables the ESP32 EMAC (sdkconfig.defaults.eth — Olimex pin map). Other
+// enables the ESP32 EMAC (sdkconfig.defaults.eth — the default LAN8720 RMII pin map). Other
// firmwares (plain ESP32 WiFi-only, ESP32-S3 with no EMAC) define MM_NO_ETH
// and get stubbed-out platform::eth* functions, mirroring the desktop layer.
#ifdef MM_NO_ETH
@@ -172,8 +172,8 @@ struct EthPinConfig {
// deviceModels.json still comes up on the historically-wired pins:
// - P4 → Waveshare P4-NANO: IP101, addr 1, MDC/MDIO 31/52, reset 51, ext 50 MHz
// clock IN on GPIO50 (Waveshare wiki + schematic + ESPHome page agree).
-// - classic ESP32 → Olimex ESP32-Gateway Rev G: LAN8720, addr 0, reset 5, chip
-// drives RMII clock OUT on GPIO17, MDC/MDIO at IDF defaults.
+// - classic ESP32 → the common LAN8720 RMII wiring: addr 0, reset 5, chip drives
+// RMII clock OUT on GPIO17, MDC/MDIO at IDF defaults (e.g. Olimex ESP32-Gateway).
// - S3 → no built-in EMAC, so the default is W5500 SPI but with no pins set
// (phyType ethW5500, pins -1): a W5500 S3 board MUST provide its SPI pins via
// deviceModels.json — there's no universal S3 default to guess.
diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index 2c0c3b4..175f00f 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -72,7 +72,6 @@
#include
#include
#include
-#include
#include
namespace mm::platform {
@@ -400,7 +399,7 @@ static bool ethInitRmii() {
esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH();
ethNetif_ = esp_netif_new(&netif_cfg);
- // RMII / PHY pins from the runtime ethConfig_ (the Olimex map by default, the
+ // RMII / PHY pins from the runtime ethConfig_ (the default LAN8720 map by default, the
// P4-NANO's IP101 map on the P4, or a board override pushed from deviceModels.json).
eth_mac_config_t mac_config = ETH_MAC_DEFAULT_CONFIG();
eth_esp32_emac_config_t emac_config = ETH_ESP32_EMAC_DEFAULT_CONFIG();
@@ -436,7 +435,7 @@ static bool ethInitRmii() {
if (!mac) return fail("MAC create failed", nullptr, nullptr);
// IP101 (P4-NANO) is a managed-component PHY ctor (espressif/ip101 in
// idf_component.yml; removed from esp_eth core in IDF v6); the generic ctor
- // (Olimex LAN8720) stays in core. The IP101 symbol is only declared on the
+ // (LAN8720) stays in core. The IP101 symbol is only declared on the
// P4 build (its header include is #ifdef'd), so the runtime phyType branch
// below must be wrapped in `#ifdef CONFIG_IDF_TARGET_ESP32P4` — otherwise the
// non-P4 build would fail to compile the undeclared esp_eth_phy_new_ip101 call.
@@ -999,31 +998,25 @@ void mdnsShutdown() {
if (mdnsStackUp_) { mdns_free(); mdnsStackUp_ = false; }
}
-// --- mDNS service browse (async, non-blocking) ---
-// One in-flight async query at a time (DevicesModule serialises service types). The
-// synchronous mdns_query_ptr would block the full timeout on the render task; the async
-// handle lets us poll a few ms each tick instead.
-static mdns_search_once_t* mdnsSearch_ = nullptr;
-
-bool mdnsBrowseStart(const char* service, const char* proto) {
- if (mdnsSearch_) return false; // one query at a time
- // Browse needs only the mDNS stack, not advertising — so bring the stack up here
- // regardless of the advertise toggle (mdnsStop clears the hostname but keeps the
- // stack). A device that chooses not to advertise can still discover others.
+// --- mDNS service browse (synchronous, bounded) ---
+
+// One synchronous PTR browse for `service`/`proto`, blocking up to `timeoutMs`, then it
+// frees everything it allocated before returning. Self-contained ON PURPOSE: the earlier
+// async API (mdns_query_async_new + poll-the-handle-across-ticks) raced the mDNS task's
+// own search-expiry timer — when a query's window lapsed, the component freed the search's
+// internal queue, and our next-tick poll asserted on it (xQueueSemaphoreTake on a null
+// queue, crashing on a UI refresh). Holding no handle across ticks closes that window by
+// construction. The cost is a bounded blocking call: DevicesModule calls this on loop1s
+// (not the render hot path) for ONE service type per tick with a small timeout, the
+// standard mDNS-query pattern (WLED/ESPHome do the same), so the tick budget is fine.
+bool mdnsBrowse(const char* service, const char* proto, uint32_t timeoutMs,
+ MdnsHostCb cb, void* user) {
+ // Browse needs only the mDNS stack, not advertising — bring it up regardless of the
+ // advertise toggle (mdnsStop clears the hostname but keeps the stack), so a device
+ // that doesn't advertise can still discover others.
if (!ensureMdnsStack()) return false;
- // PTR query for the service type; 3 s window, up to 16 results. Returns immediately
- // with a handle — results are gathered on the mDNS task, read via Poll below.
- mdnsSearch_ = mdns_query_async_new(nullptr, service, proto, MDNS_TYPE_PTR,
- 3000, 16, nullptr);
- return mdnsSearch_ != nullptr;
-}
-
-bool mdnsBrowsePoll(MdnsHostCb cb, void* user) {
- if (!mdnsSearch_) return true; // nothing running == "done"
mdns_result_t* results = nullptr;
- uint8_t num = 0;
- // 0 ms timeout: pure poll, never blocks the tick. true == the query finished.
- if (!mdns_query_async_get_results(mdnsSearch_, 0, &results, &num)) return false;
+ if (mdns_query_ptr(service, proto, timeoutMs, 16, &results) != ESP_OK) return false;
for (mdns_result_t* r = results; r && cb; r = r->next) {
MdnsHost h{};
// A PTR/service browse gives the friendly service *instance* name in
@@ -1056,11 +1049,7 @@ bool mdnsBrowsePoll(MdnsHostCb cb, void* user) {
cb(h, user);
}
if (results) mdns_query_results_free(results);
- return true; // done — caller calls mdnsBrowseStop()
-}
-
-void mdnsBrowseStop() {
- if (mdnsSearch_) { mdns_query_async_delete(mdnsSearch_); mdnsSearch_ = nullptr; }
+ return true;
}
// UdpSocket
@@ -1171,68 +1160,17 @@ bool TcpConnection::write(const uint8_t* data, size_t len) {
return true;
}
-WriteResult TcpConnection::writeChunks(const WriteChunk* chunks, int count) {
- if (fd_ < 0) return WriteResult::Error;
- if (count < 1 || count > MAX_WRITE_CHUNKS) return WriteResult::Error;
- struct iovec iov[MAX_WRITE_CHUNKS];
- size_t total = 0;
- for (int i = 0; i < count; i++) {
- iov[i].iov_base = const_cast(chunks[i].data);
- iov[i].iov_len = chunks[i].len;
- total += chunks[i].len;
- }
- // Single non-blocking writev — the socket is already O_NONBLOCK.
- ssize_t n = lwip_writev(fd_, iov, count);
- if (n < 0) {
- return (errno == EAGAIN || errno == EWOULDBLOCK)
- ? WriteResult::WouldBlock : WriteResult::Error;
- }
- if (n == 0) return WriteResult::WouldBlock;
- if (static_cast(n) == total) return WriteResult::Complete;
-
- // Partial: some bytes of this WS frame went out, so we MUST finish the rest
- // — a half-sent frame corrupts the stream and the caller would otherwise
- // drop the connection. This happens under backpressure when the link is
- // saturated (e.g. ArtNet + a large preview frame on a slow 128×128 tick):
- // the lwIP send buffer can't take the whole frame at once but drains in
- // microseconds. Drain the unsent tail with a bounded retry loop; only if it
- // still can't complete (a genuinely stuck socket) do we report Partial so
- // the caller closes. lwIP exposes no free-TX-space query (SO_SNDBUF is
- // unimplemented), so a pre-check isn't possible — finishing the write is the
- // way to keep the stream intact without dropping.
- size_t sent = static_cast(n);
- // Small cap: a partial tail (a few KB) drains in 1-2 ms as TCP ACKs arrive;
- // 8 ms is plenty for a transient. A genuinely saturated link blows past it —
- // then we give up (report Partial → caller closes, browser reconnects),
- // rather than stall the render tick. Bounds the worst-case tick hit to ~8 ms.
- constexpr int kMaxDrainTries = 8; // each yields up to 1ms
- for (int tries = 0; sent < total && tries < kMaxDrainTries; ) {
- // Locate the chunk + offset where `sent` lands, write its remaining tail.
- size_t acc = 0;
- const uint8_t* p = nullptr; size_t remain = 0;
- for (int i = 0; i < count; i++) {
- if (sent < acc + chunks[i].len) {
- size_t off = sent - acc;
- p = chunks[i].data + off;
- remain = chunks[i].len - off;
- break;
- }
- acc += chunks[i].len;
- }
- if (!p) break; // shouldn't happen (sent < total)
- ssize_t w = lwip_write(fd_, p, remain);
- if (w > 0) {
- sent += static_cast(w);
- } else if (w < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
- vTaskDelay(pdMS_TO_TICKS(1)); // buffer full — let it drain
- tries++;
- } else {
- return WriteResult::Error; // real socket error
- }
- }
- return (sent == total) ? WriteResult::Complete : WriteResult::Partial;
+int TcpConnection::writeSome(const uint8_t* data, size_t len) {
+ if (fd_ < 0) return -1;
+ if (len == 0) return 0;
+ ssize_t n = lwip_write(fd_, data, len);
+ if (n > 0) return static_cast(n);
+ if (n == 0) return 0;
+ if (errno == EAGAIN || errno == EWOULDBLOCK) return 0; // buffer full — try later
+ return -1; // real socket error
}
+
void TcpConnection::close() {
if (fd_ >= 0) {
lwip_close(fd_);
diff --git a/src/platform/esp32/platform_esp32_improv.cpp b/src/platform/esp32/platform_esp32_improv.cpp
index 8640aff..9789064 100644
--- a/src/platform/esp32/platform_esp32_improv.cpp
+++ b/src/platform/esp32/platform_esp32_improv.cpp
@@ -5,7 +5,7 @@
// the platform layer only through public accessors declared in platform.h.
//
// Runs on EVERY ESP32 target, including Ethernet-only builds (MM_NO_WIFI). The serial
-// transport + the vendor RPCs (SET_DEVICE_MODEL, SET_TX_POWER, APPLY_OP — "Improv =
+// transport + the vendor RPCs (SET_TX_POWER, APPLY_OP — "Improv =
// REST over serial") need no WiFi, so the installer can push a device-model's config
// over serial to an eth device too. Only the WiFi-PROVISIONING RPCs (WIFI_SETTINGS,
// GET_WIFI_NETWORKS) and their `esp_wifi_*` calls are `#ifndef MM_NO_WIFI`-guarded —
@@ -67,16 +67,6 @@ struct ImprovTaskState {
char* statusBuf = nullptr; // module shows as `provision_status`
size_t statusBufLen = 0;
- // Vendor SET_DEVICE_MODEL RPC (command 0xFE): module-owned deviceModel buffer
- // sized by SystemModule::deviceModel_ at the caller (see deviceModelOutLen). The
- // Improv handler caps str_len dynamically against deviceModelOutLen so the
- // wire spec adapts when the buffer resizes. Same producer/consumer
- // dance as ssid/password.
- // Nullable: opt out by leaving null (desktop stub doesn't pass any).
- char* deviceModelOut = nullptr;
- size_t deviceModelOutLen = 0;
- std::atomic* deviceModelReady = nullptr;
-
// Vendor SET_TX_POWER RPC (command 0xFD): pre-association TX-power cap in
// whole dBm for brown-out-prone boards. Same producer/consumer dance.
uint8_t* txPowerOut = nullptr;
@@ -86,7 +76,7 @@ struct ImprovTaskState {
// serial during provisioning ("Improv = REST over serial"). The frame carries
// [0xFC][seq][last][chunk bytes…]; chunks are appended to opOut until last=1,
// then opReady is set and the module's loop applies the op on the MAIN loop
- // (never the Improv task). Same producer/consumer dance as deviceModel; the
+ // (never the Improv task). Same producer/consumer dance as the credentials; the
// buffer is module-owned and sized to hold the largest op (a long pins list).
// Chunk reassembly + the sequence guard live in mm::ImprovOpReassembler, bound
// to opOut in the handler — only the buffer + the ready flag are shared state here.
@@ -285,79 +275,6 @@ static void improvHandleProvision(const improv::ImprovCommand& cmd) {
#endif // MM_NO_WIFI — end WiFi-provisioning RPCs
-// SET_DEVICE_MODEL vendor RPC (command 0xFE) — Step 3 of the deviceModel-injection plan.
-// The web installer's orchestrator sends this after WiFi provisioning so the
-// device persists its physical-board name (e.g. "LOLIN D32") without needing
-// MoonDeck or an HTTP fetch (which is blocked by mixed-content on Pages).
-//
-// Frame payload layout (after the standard Improv frame header):
-// [0xFE] command
-// [data_len] number of bytes that follow (= 1 + str_len)
-// [str_len] 1..(deviceModel buffer - 1), length of deviceModel name in bytes
-// [str_bytes...] ASCII-printable 0x20..0x7E only
-//
-// We parse the raw payload directly instead of going through
-// improv::parse_improv_data — that helper is WIFI_SETTINGS-shaped (n
-// length-prefixed strings into cmd.ssid/cmd.password) and may default-empty
-// the fields for unknown command IDs. Single-bytestring vendor commands are
-// cleaner to handle inline.
-//
-// On valid: write into g_improv.deviceModelOut, set deviceModelReady. The module's
-// loop1s() picks it up and calls SystemModule::setDeviceModel which arms
-// FilesystemModule's debounced save. RpcResponse fires immediately —
-// validation already passed.
-//
-// On invalid: ErrorState 0x80 (ERROR_INVALID_DEVICE_MODEL, vendor error code).
-static constexpr uint8_t IMPROV_CMD_SET_DEVICE_MODEL = 0xFE;
-static constexpr uint8_t IMPROV_ERROR_INVALID_DEVICE_MODEL = 0x80;
-
-static void improvHandleSetDeviceModel(const uint8_t* payload, uint8_t len) {
- if (!g_improv.deviceModelOut || !g_improv.deviceModelReady) {
- // Module didn't opt in (no SystemModule wired). Mostly defensive —
- // production wires it in main.cpp; failing-safe here keeps the dispatch
- // path well-defined.
- improvSendError(improv::ERROR_UNKNOWN_RPC);
- return;
- }
- if (len < 3) {
- improvSendError(static_cast(IMPROV_ERROR_INVALID_DEVICE_MODEL));
- return;
- }
- // payload[0] is the command byte (0xFE) — we already dispatched on it.
- // payload[1] is data_len (RPC framing); payload[2] is str_len.
- // Cross-check the three lengths so a malformed frame (e.g. data_len
- // disagreeing with str_len, or extra trailing bytes inside the
- // framing-level payload) is rejected rather than silently accepted.
- // The outer framing parser already validated `len` against the
- // wire-level length byte; these checks enforce internal consistency.
- uint8_t dataLen = payload[1];
- uint8_t strLen = payload[2];
- if (strLen == 0 || strLen >= g_improv.deviceModelOutLen
- || dataLen != static_cast(1u + strLen)
- || len != static_cast(3u + strLen)) {
- improvSendError(static_cast(IMPROV_ERROR_INVALID_DEVICE_MODEL));
- return;
- }
- for (uint8_t i = 0; i < strLen; i++) {
- uint8_t b = payload[3 + i];
- if (b < 0x20 || b > 0x7E) {
- improvSendError(static_cast(IMPROV_ERROR_INVALID_DEVICE_MODEL));
- return;
- }
- }
- std::memcpy(g_improv.deviceModelOut, payload + 3, strLen);
- g_improv.deviceModelOut[strLen] = 0;
- // release-store: pairs with the module's acquire-load in loop1s() so the
- // buffer write is visible before the consumer sees ready=true.
- g_improv.deviceModelReady->store(true, std::memory_order_release);
- // Empty-payload RpcResponse for command 0xFE — signals success. The
- // browser orchestrator can treat any RpcResponse with cmd=0xFE as ack.
- auto rpc = improv::build_rpc_response(
- static_cast(IMPROV_CMD_SET_DEVICE_MODEL),
- std::vector{}, false);
- improvSend(ImprovFrameType::RpcResponse, rpc);
-}
-
// SET_TX_POWER vendor RPC (command 0xFD) — the pre-association escape hatch
// for boards whose LDO browns out at full TX power (weak-powered boards). Their
// deviceModels.json cap (Network.txPowerSetting) normally arrives over HTTP after
@@ -474,15 +391,11 @@ static void improvHandleApplyOp(const uint8_t* payload, uint8_t len) {
// we care about; the spec lets the other types through silently.
static void improvDispatchFrame(const ImprovFrameParser& parser) {
if (parser.lastType() != improv::TYPE_RPC) return;
- // SET_DEVICE_MODEL short-circuits the standard improv::parse_improv_data path
- // because that helper is WIFI_SETTINGS-shaped (n length-prefixed strings).
+ // Vendor RPCs short-circuit the standard improv::parse_improv_data path because
+ // that helper is WIFI_SETTINGS-shaped (n length-prefixed strings into ssid/password).
// Peek at the command byte first; vendor-RPC parsing handles its own payload.
const uint8_t* raw = parser.lastPayload();
uint8_t rawLen = parser.lastPayloadLen();
- if (rawLen >= 1 && raw[0] == IMPROV_CMD_SET_DEVICE_MODEL) {
- improvHandleSetDeviceModel(raw, rawLen);
- return;
- }
if (rawLen >= 1 && raw[0] == IMPROV_CMD_SET_TX_POWER) {
improvHandleSetTxPower(raw, rawLen);
return;
@@ -682,8 +595,6 @@ bool improvProvisioningInit(const ImprovDeviceInfo& info,
char* passwordOut, size_t passwordOutLen,
std::atomic* ready,
char* statusBuf, size_t statusBufLen,
- char* deviceModelOut, size_t deviceModelOutLen,
- std::atomic* deviceModelReady,
uint8_t* txPowerOut,
std::atomic* txPowerReady,
char* opOut, size_t opOutLen,
@@ -704,10 +615,6 @@ bool improvProvisioningInit(const ImprovDeviceInfo& info,
g_improv.ready = ready;
g_improv.statusBuf = statusBuf;
g_improv.statusBufLen = statusBufLen;
- // SET_DEVICE_MODEL opt-in: caller may pass null/0/null to skip vendor-RPC support.
- g_improv.deviceModelOut = deviceModelOut;
- g_improv.deviceModelOutLen = deviceModelOutLen;
- g_improv.deviceModelReady = deviceModelReady;
// SET_TX_POWER opt-in, same shape.
g_improv.txPowerOut = txPowerOut;
g_improv.txPowerReady = txPowerReady;
diff --git a/src/platform/platform.h b/src/platform/platform.h
index 40cc6d9..2246783 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -151,18 +151,15 @@ void mdnsShutdown();
// mDNS service browse (discovery) — the standard, push-style way to find devices that
// advertise a service (projectMM, WLED `_wled._tcp`, Home Assistant, ESPHome, …),
-// without an HTTP subnet sweep. Three calls form a NON-BLOCKING poll cycle so it never
-// stalls the render task (the synchronous IDF mdns_query_ptr blocks the full timeout —
-// not usable on the tick):
-// mdnsBrowseStart(service, proto) — kick off one async PTR query (e.g. "_http","_tcp").
-// Returns false if mDNS isn't up / already querying.
-// mdnsBrowsePoll(cb, user) — call each tick; when results are ready, invokes
-// cb once per found host then returns true (done).
-// Returns false while still pending (cheap, no block).
-// mdnsBrowseStop() — release the query (call after a done poll, or to
-// abort). Idempotent.
-// One query in flight at a time (DevicesModule serialises service types). A found host is
-// reported as a small POD — no IDF types leak across the seam. Desktop: stubs (no mDNS).
+// without an HTTP subnet sweep. ONE synchronous call per invocation — it queries, invokes
+// `cb` for each found host, frees everything, and returns. It blocks up to `timeoutMs`, so
+// the caller runs it on a slow cadence (DevicesModule on loop1s, one service type per tick
+// with a small timeout — NOT the render hot path), the standard mDNS-query pattern.
+// Deliberately not the async poll-a-handle API: holding the IDF search handle across ticks
+// raced the mDNS task's own expiry timer (it freed the search's queue mid-poll → a
+// null-queue assert that crashed on a UI refresh); a self-contained call holds no handle,
+// so that window can't exist. A found host is reported as a small POD — no IDF types leak
+// across the seam. Desktop: stub (no mDNS).
struct MdnsHost {
uint8_t ip[4] = {}; // resolved IPv4 (0.0.0.0 if unresolved)
char hostname[32] = {}; // instance/host name (e.g. "wled-desk"), "" if none
@@ -173,9 +170,8 @@ struct MdnsHost {
// Lets a browse classify a peer without an HTTP probe.
};
using MdnsHostCb = void(*)(const MdnsHost& host, void* user);
-bool mdnsBrowseStart(const char* service, const char* proto);
-bool mdnsBrowsePoll(MdnsHostCb cb, void* user);
-void mdnsBrowseStop();
+bool mdnsBrowse(const char* service, const char* proto, uint32_t timeoutMs,
+ MdnsHostCb cb, void* user);
// Store the DHCP hostname (DHCP option 12) the next eth/wifi bring-up advertises.
// Routers populate their client list from the DHCP request, not mDNS, so without
@@ -245,31 +241,26 @@ struct ImprovDeviceInfo {
const char* chipFamily; // "ESP32" / "ESP32-S3" / ...
const char* firmwareVersion; // e.g. "1.0.0-rc2"
};
-// deviceModel-extension args (deviceModelOut/deviceModelOutLen/deviceModelReady)
-// are for the vendor SET_DEVICE_MODEL RPC (command 0xFE) — when set, the Improv
-// task validates the RPC payload, writes the deviceModel name into deviceModelOut,
-// and publishes via deviceModelReady's release-store. Pass nullptr/0/nullptr to opt
-// out (desktop stub). Mirrors the ssid/password triple: validate + buffer-write +
-// flag-signal, scheduler thread reads.
// SET_TX_POWER RPC (command 0xFD) — when set, the Improv task validates the
// 1-byte dBm payload (0..21), writes it to txPowerOut, and publishes via
// txPowerReady's release-store. This is the pre-association escape hatch for
// boards whose LDO browns out at full TX power (weak-powered boards): their
// catalog cap normally arrives over HTTP *after* the device is online,
-// which such a board can never reach — proven on the bench 2026-06-10. Same
-// validate + buffer-write + flag-signal shape as SET_DEVICE_MODEL.
+// which such a board can never reach — proven on the bench 2026-06-10. It stays
+// a dedicated RPC (not an APPLY_OP) precisely because it must land BEFORE the
+// radio associates, whereas APPLY_OP ops apply once the device is up.
// opOut/opOutLen/opReady carry the APPLY_OP vendor RPC (0xFC) — one REST operation
// as JSON, pushed over serial during provisioning ("Improv = REST over serial").
// Chunks reassemble into opOut; on the last chunk opReady's release-store publishes
-// it and ImprovProvisioningModule applies the op on the main loop. Same buffer +
-// flag shape as deviceModel; opt out by leaving null (desktop stub does).
+// it and ImprovProvisioningModule applies the op on the main loop. This is how the
+// deviceModel and every other catalog default arrive: a `set`/`add` op routed through
+// the apply-core + per-control validators, the same path the HTTP API uses.
+// Opt out by leaving null (desktop stub does).
bool improvProvisioningInit(const ImprovDeviceInfo& info,
char* ssidOut, size_t ssidOutLen,
char* passwordOut, size_t passwordOutLen,
std::atomic* ready,
char* statusBuf, size_t statusBufLen,
- char* deviceModelOut = nullptr, size_t deviceModelOutLen = 0,
- std::atomic* deviceModelReady = nullptr,
uint8_t* txPowerOut = nullptr,
std::atomic* txPowerReady = nullptr,
char* opOut = nullptr, size_t opOutLen = 0,
@@ -308,17 +299,10 @@ class UdpSocket {
int fd_ = -1;
};
-// One contiguous span for a scatter-gather write.
+// One contiguous span. broadcastBinary() takes an array of these (a frame's header +
+// payload) and stages them into one buffer for the non-blocking drain (see writeSome).
struct WriteChunk { const uint8_t* data; size_t len; };
-// Outcome of a non-blocking scatter-gather write (TcpConnection::writeChunks).
-enum class WriteResult {
- Complete, // every byte across all chunks was sent
- WouldBlock, // socket buffer full, NOTHING was sent — caller may retry later
- Partial, // some bytes sent — the message is truncated, caller MUST close()
- Error // socket error — caller MUST close()
-};
-
class TcpConnection {
public:
TcpConnection() = default;
@@ -337,12 +321,12 @@ class TcpConnection {
bool valid() const { return fd_ >= 0; }
int read(uint8_t* buf, size_t maxLen); // non-blocking: >0 data, 0 closed, -1 nothing
bool write(const uint8_t* data, size_t len); // blocking — retries until all sent
-
- // Single non-blocking scatter-gather write (one writev). Never blocks.
- // Used for the preview broadcast so a backpressured browser cannot stall
- // the render task. `count` must be 1..MAX_WRITE_CHUNKS.
- static constexpr int MAX_WRITE_CHUNKS = 3;
- WriteResult writeChunks(const WriteChunk* chunks, int count);
+ // Non-blocking partial write: send as many of `len` bytes as the socket accepts right
+ // now, return the count actually written (0..len). -1 = socket error (caller closes);
+ // 0 = WouldBlock (buffer full, try later) or len==0. The caller advances its own offset
+ // and re-calls — used by the preview drain to stream a frame across ticks without ever
+ // blocking the render task. Never spins, never yields. (int mirrors read()'s contract.)
+ int writeSome(const uint8_t* data, size_t len);
void close();
diff --git a/src/ui/app.js b/src/ui/app.js
index de6ffea..2c0373d 100644
--- a/src/ui/app.js
+++ b/src/ui/app.js
@@ -165,7 +165,7 @@ async function init() {
}
connectWs();
preview.init();
- preview.setupShrink();
+ preview.setupLayout();
}
async function sendControl(moduleName, controlName, value) {
diff --git a/src/ui/index.html b/src/ui/index.html
index 69d8c92..d837c43 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -23,11 +23,37 @@
-
-
-
+
+
+
+
+
+ ⠿
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js
index 47292ff..35d537b 100644
--- a/src/ui/preview3d.js
+++ b/src/ui/preview3d.js
@@ -2,7 +2,7 @@
// cloud. Extracted from app.js as a self-contained module (same pattern as
// install-picker.js): app.js wires it at three points only —
// preview.init() once, after the canvas exists
-// preview.setupShrink() once, for the scroll-shrink behaviour
+// preview.setupLayout() once, for docked-split ↔ floating-PiP responsiveness
// preview.onBinaryMessage(buf) per WebSocket binary frame
// It owns its own GL context, camera, and geometry; it talks to the rest of the
// app only through the DOM (#preview canvas, --bg-0 theme colour) and
@@ -12,6 +12,7 @@ let gl = null;
let glProgram = null;
let glBuffer = null;
let glLocs = null; // cached attrib/uniform locations
+let glMaxPointSize = 64; // driver's gl_PointSize cap (ALIASED_POINT_SIZE_RANGE max)
let glLoopRunning = false; // continuous rAF render loop active
// Parse the persisted camera, tolerating a malformed/corrupt value so a bad
// localStorage entry can't throw during module init. Falls back to defaults.
@@ -22,11 +23,23 @@ const _cam = (() => {
} catch { /* corrupt value — ignore, use defaults */ }
return null;
})();
+// Camera-distance clamp. The scene is normalised to ~[-0.5, 0.5] (box-centred), so CAM_MIN
+// well below the scene radius lets you zoom DEEP into a dense grid — close enough that a
+// single 128²-grid cell fills the view and its sequence number fits the bulb (the projection
+// near plane is lowered to match, so the scene doesn't clip as you approach). CAM_MAX frames
+// the whole volume with headroom.
+const CAM_MIN = 0.03, CAM_MAX = 10;
let camTheta = _cam ? _cam.t : Math.PI;
let camPhi = _cam ? _cam.p : 0.4;
let camDist = _cam ? _cam.d : 2.5;
+// The point the camera orbits + looks at. Origin by default (the scene is box-centred);
+// cursor-anchored zoom pans it so the world point under the pointer stays put (Google-Maps
+// style). Persisted with the angles/distance so a reload keeps the framing.
+let camTgtX = _cam ? (_cam.tx || 0) : 0;
+let camTgtY = _cam ? (_cam.ty || 0) : 0;
+let camTgtZ = _cam ? (_cam.tz || 0) : 0;
let camAutoFit = !_cam; // fit on first frame when no saved position
-function saveCam() { localStorage.setItem("mm_cam", JSON.stringify({t: camTheta, p: camPhi, d: camDist})); }
+function saveCam() { localStorage.setItem("mm_cam", JSON.stringify({t: camTheta, p: camPhi, d: camDist, tx: camTgtX, ty: camTgtY, tz: camTgtZ})); }
let lastVerts = null; // cached vertex array for orbit-without-server-frame
let lastVertCount = 0;
let lastMaxDim = 1;
@@ -36,7 +49,22 @@ let vertsBuf = null; // reused worst-case Float32Array; grows but never
let previewCoords_ = null; // Float32Array[count*3], normalised + box-centred positions
let previewCoordCount_ = 0;
let previewMaxDim_ = 1;
+let previewStride_ = 1; // device's adaptive downscale factor (1 = full res); for the status line
+let showSeqNumbers_ = false; // sequence-number overlay toggle (preview-numbers button)
+// Dot-size multiplier on the auto-computed "filled-panel" base (1 = ¾-fill). A user knob
+// because the ideal fill is subjective and layout-dependent — a 2D panel reads best solid,
+// a 3D cube reads best with smaller dots so the back layers show through. Persisted.
+const DOT_MIN = 0.25, DOT_MAX = 1.5; // matches the slider range; clamp so a bad
+const clampDot = (v) => Math.min(DOT_MAX, Math.max(DOT_MIN, Number.isFinite(v) ? v : 1));
+// localStorage value (or a manual edit) can't push the dot size to a performance-killing extreme.
+let dotScale_ = clampDot(parseFloat(localStorage.getItem("mm_preview_dot")));
+let resetLayout_ = null; // set by setupLayout(): restores docked/PiP state to defaults
let previewBox_ = null; // {x,y,z} bounding-box extent for camera auto-fit
+let lineProgram = null; // separate program for the wireframe bounding box
+let lineLocs = null;
+let lineBuffer = null;
+let boxVerts = null; // 12-edge wireframe (24 line endpoints) for the current box
+let boxKey = ""; // cache key so the box buffer rebuilds only when extents change
function initWebGL() {
const canvas = document.getElementById("preview");
@@ -48,6 +76,7 @@ function initWebGL() {
attribute vec3 aPos;
attribute vec3 aCol;
varying vec3 vCol;
+ varying float vSize;
uniform mat4 uMVP;
uniform float uPointSize;
void main() {
@@ -55,18 +84,47 @@ function initWebGL() {
gl_Position = uMVP * vec4(aPos, 1.0);
// Depth-corrected point size — closer LEDs render larger
gl_PointSize = uPointSize / gl_Position.w;
+ vSize = gl_PointSize; // px size, so the fragment can keep the AA edge ~1px wide
}
`;
const fsrc = `
precision mediump float;
varying vec3 vCol;
+ varying float vSize;
+ uniform float uRingFade; // 0..1: off-LED placeholder opacity. 1 at small/zoomed
+ // grids (placeholders show the layout); →0 when points get
+ // dense (they'd be noise, so the lit pattern reads cleanly).
+ uniform float uLitPass; // two-pass draw: 0 = placeholders only, 1 = lit LEDs only.
+ // Lit are drawn second with depth-test off so they always
+ // layer ABOVE the grey placeholders at the same spot,
+ // regardless of pan/tilt draw order (no z-fighting).
void main() {
- float d = length(gl_PointCoord - vec2(0.5));
- if (d > 0.5) discard;
- float a = 1.0 - smoothstep(0.25, 0.5, d);
- // Gamma 0.7 lifts mid-greys so dim effects stay readable in the preview; not sRGB-correct
+ float d = length(gl_PointCoord - vec2(0.5)); // 0 at center .. 0.5 at rim
+ // Anti-alias band ~1px wide regardless of sprite size: crisp disc at 8x8
+ // (huge sprites) AND smooth at large grids (tiny sprites).
+ float aa = clamp(1.0 / max(vSize, 1.0), 0.004, 0.12);
+ float disc = 1.0 - smoothstep(0.5 - aa, 0.5, d); // filled circle, thin soft rim
+ // Gamma 0.7 lifts mid-greys so dim effects stay readable; not sRGB-correct.
vec3 bright = pow(vCol, vec3(0.7));
- gl_FragColor = vec4(bright * a, a);
+ float lum = max(max(vCol.r, vCol.g), vCol.b);
+ // How "lit" the LED is, ramped over the bottom of the range so a near-off LED is
+ // a placeholder but a genuinely lit one is solid.
+ float lit = smoothstep(0.02, 0.10, lum);
+
+ if (uLitPass < 0.5) {
+ // Pass 1 — placeholders for OFF LEDs only. A faint filled grey CIRCLE (a disc,
+ // not a hollow ring or square) so irregular layouts (a wheel, a sphere) read
+ // cleanly; fades out as the grid gets dense (uRingFade→0).
+ if (lit > 0.5) discard; // lit LEDs belong to pass 2
+ float a = disc * 0.22 * uRingFade;
+ if (a < 0.01) discard;
+ gl_FragColor = vec4(vec3(0.32), a);
+ } else {
+ // Pass 2 — lit LEDs only, solid disc in the real colour, on top.
+ if (lit < 0.5) discard; // off LEDs were pass 1
+ if (disc < 0.01) discard;
+ gl_FragColor = vec4(bright, disc);
+ }
}
`;
@@ -78,17 +136,41 @@ function initWebGL() {
gl.attachShader(glProgram, vs); gl.attachShader(glProgram, fs);
gl.linkProgram(glProgram); gl.useProgram(glProgram);
+ // WebGL clamps gl_PointSize to the driver's range — bulbs stop growing past this even
+ // as you zoom in, so the label fit-check must clamp to the same cap (else it thinks a
+ // bulb is big enough for a number when the drawn sprite is actually capped smaller).
+ glMaxPointSize = (gl.getParameter(gl.ALIASED_POINT_SIZE_RANGE) || [1, 64])[1] || 64;
+
glBuffer = gl.createBuffer();
glLocs = {
aPos: gl.getAttribLocation(glProgram, "aPos"),
aCol: gl.getAttribLocation(glProgram, "aCol"),
uMVP: gl.getUniformLocation(glProgram, "uMVP"),
uPointSize:gl.getUniformLocation(glProgram, "uPointSize"),
+ uRingFade: gl.getUniformLocation(glProgram, "uRingFade"),
+ uLitPass: gl.getUniformLocation(glProgram, "uLitPass"),
};
gl.enable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
+ // A second, minimal program for the wireframe bounding box (a faint cuboid around
+ // the light volume — gives the scene bounds + 3D orientation while orbiting, and a
+ // frame even when every LED is off). Flat colour, no per-vertex attributes beyond pos.
+ const lvs = `attribute vec3 aPos; uniform mat4 uMVP; void main(){ gl_Position = uMVP * vec4(aPos,1.0); }`;
+ const lfs = `precision mediump float; uniform vec4 uColor; void main(){ gl_FragColor = uColor; }`;
+ const lv = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(lv, lvs); gl.compileShader(lv);
+ const lf = gl.createShader(gl.FRAGMENT_SHADER); gl.shaderSource(lf, lfs); gl.compileShader(lf);
+ lineProgram = gl.createProgram();
+ gl.attachShader(lineProgram, lv); gl.attachShader(lineProgram, lf);
+ gl.linkProgram(lineProgram);
+ lineLocs = {
+ aPos: gl.getAttribLocation(lineProgram, "aPos"),
+ uMVP: gl.getUniformLocation(lineProgram, "uMVP"),
+ uColor: gl.getUniformLocation(lineProgram, "uColor"),
+ };
+ lineBuffer = gl.createBuffer();
+
// Orbit controls (mouse + touch)
let dragging = false, lastX = 0, lastY = 0;
canvas.addEventListener("mousedown", (e) => { dragging = true; lastX = e.clientX; lastY = e.clientY; });
@@ -102,8 +184,38 @@ function initWebGL() {
canvas.addEventListener("mouseup", () => { dragging = false; saveCam(); });
canvas.addEventListener("mouseleave", () => { dragging = false; saveCam(); });
canvas.addEventListener("wheel", (e) => {
- camDist = Math.max(0.5, Math.min(10, camDist + e.deltaY * 0.005));
e.preventDefault();
+ // Cursor-anchored zoom (Google-Maps style): keep the world point under the pointer
+ // fixed on screen while zooming. The orbit camera looks at camTgt from camDist; the
+ // view half-extent at the target plane is camDist*tan(fov/2). The cursor's offset
+ // from canvas centre, in that world scale along the camera's right/up axes, is where
+ // the pointer is in the target plane. Scaling camDist by k scales that plane's extent
+ // by k, so shifting camTgt by (1-k)*cursorOffset keeps the pointed-at point put.
+ const r = canvas.getBoundingClientRect();
+ const ndcX = ((e.clientX - r.left) / r.width) * 2 - 1;
+ const ndcY = 1 - ((e.clientY - r.top) / r.height) * 2; // y-up
+ const aspect = r.width / Math.max(1, r.height);
+ const fov = 0.8;
+ const halfH = camDist * Math.tan(fov / 2);
+ const offU = ndcY * halfH; // world units along camera up at target plane
+ const offR = ndcX * halfH * aspect; // along camera right
+
+ const oldDist = camDist;
+ camDist = Math.max(CAM_MIN, Math.min(CAM_MAX, camDist * Math.exp(e.deltaY * 0.0015)));
+ const k = camDist / oldDist; // <1 zooming in, >1 zooming out
+
+ // Camera right/up axes (same basis buildMVP derives: right = forward×worldUp, etc.).
+ const fx = -Math.cos(camPhi) * Math.sin(camTheta);
+ const fy = -Math.sin(camPhi);
+ const fz = -Math.cos(camPhi) * Math.cos(camTheta);
+ let rx = fz, rz = -fx; const rl = Math.hypot(rx, 0, rz) || 1; rx /= rl; rz /= rl; // ry=0
+ const ux = (-rz)*fy - 0, uy = rz*fx - rx*fz, uz = 0 - (-rx)*fy; // up = right×forward
+ // Shift the target so the cursor-pointed world point stays fixed as the extent scales.
+ const s = (1 - k);
+ camTgtX += s * (offR * rx + offU * ux);
+ camTgtY += s * (offR * 0 + offU * uy);
+ camTgtZ += s * (offR * rz + offU * uz);
+
redrawCached();
saveCam();
}, {passive: false});
@@ -141,7 +253,7 @@ function initWebGL() {
const d = touchDistance(e.touches[0], e.touches[1]);
if (d > 0) {
const ratio = pinchDist / d;
- camDist = Math.max(0.5, Math.min(10, camDist * ratio));
+ camDist = Math.max(CAM_MIN, Math.min(CAM_MAX, camDist * ratio));
pinchDist = d;
redrawCached();
}
@@ -158,41 +270,184 @@ function initWebGL() {
}
-// Scroll-shrink preview: 0..1 ratio over 0..300px of main scroll.
-function setupPreviewShrink() {
+// Responsive layout: docked split-pane on wide screens, a draggable floating
+// picture-in-picture on narrow screens (or when the user pops the preview out).
+// One canvas throughout — only the wrapper's class/position change, so the WebGL
+// context is never lost. Width drives the default mode; a manual toggle overrides.
+const PIP_BELOW = 960; // px: auto-PiP under this width
+const LS_KEY = "projectMM.preview.v1"; // {corner, dismissed, forcePip}
+
+// Hostile-storage guards (a 3-line idiom shared with the rest of the UI; localStorage
+// throws in private mode / when disabled, and may hold a hand-edited non-JSON value).
+function loadPrefs() {
+ try { return JSON.parse(localStorage.getItem(LS_KEY) || "{}") || {}; }
+ catch (_) { return {}; }
+}
+function savePrefs(p) {
+ try { localStorage.setItem(LS_KEY, JSON.stringify(p)); } catch (_) { /* ignore */ }
+}
+
+function setupLayout() {
+ const ws = document.querySelector(".workspace");
+ const pane = document.querySelector(".preview-pane");
+ const bar = document.querySelector(".preview-bar");
const canvas = document.getElementById("preview");
- if (!canvas) return;
- let naturalMaxH = null;
- let ticking = false;
- const SHRINK_OVER = 300;
- function apply() {
- ticking = false;
- if (!naturalMaxH) {
- naturalMaxH = canvas.getBoundingClientRect().height || (window.innerHeight * 0.5);
- }
- const r = Math.min(1, Math.max(0, window.scrollY / SHRINK_OVER));
- canvas.style.maxHeight = Math.round(naturalMaxH * (1 - r * 0.5)) + "px";
- if (lastVerts) redrawCached();
+ if (!ws || !pane || !bar || !canvas) return;
+
+ const prefs = loadPrefs();
+ let forcePip = !!prefs.forcePip; // user popped the preview out on a wide screen
+ let dismissed = !!prefs.dismissed; // user hid the PiP entirely
+ let corner = prefs.corner || "br"; // tl | tr | bl | br
+
+ const refit = () => { if (lastVerts) redrawCached(); };
+
+ // Place the PiP at its snapped corner (left/top so dragging can move it freely).
+ function placeCorner() {
+ if (!ws.classList.contains("mode-pip")) { pane.style.left = pane.style.top = ""; return; }
+ const m = 12, w = pane.offsetWidth, h = pane.offsetHeight;
+ const x = corner.includes("l") ? m : window.innerWidth - w - m;
+ const y = corner.includes("t") ? 56 : window.innerHeight - h - m;
+ pane.style.left = x + "px";
+ pane.style.top = y + "px";
+ pane.style.right = "auto";
+ pane.style.bottom = "auto";
+ }
+
+ // Pick the mode from width + the manual override, apply classes, re-fit the canvas.
+ function applyMode() {
+ const pip = forcePip || window.innerWidth < PIP_BELOW;
+ ws.classList.toggle("mode-pip", pip);
+ ws.classList.toggle("mode-docked", !pip);
+ ws.classList.toggle("preview-hidden", pip && dismissed);
+ const showBtn = document.getElementById("preview-show");
+ if (showBtn) showBtn.hidden = !(pip && dismissed);
+ // The dock button means "pop out" when docked, "re-dock" when floating.
+ const dockBtn = document.getElementById("preview-dock");
+ if (dockBtn) dockBtn.textContent = pip ? "⤡" : "⤢";
+ requestAnimationFrame(() => { placeCorner(); refit(); });
}
- window.addEventListener("scroll", () => {
- if (!ticking) { requestAnimationFrame(apply); ticking = true; }
- }, {passive: true});
+
+ // Let the preview "reset" button restore the docked/PiP layout to defaults too (back to
+ // auto docked-vs-PiP, not dismissed, default corner) — these vars are closure-local here.
+ resetLayout_ = () => {
+ forcePip = false; dismissed = false; corner = "br";
+ savePrefs({ corner, dismissed, forcePip });
+ applyMode();
+ };
+
+ // matchMedia would only catch the breakpoint crossing; a resize listener also keeps
+ // the PiP pinned to its corner as the window changes. rAF-throttled.
+ let ticking = false;
window.addEventListener("resize", () => {
- naturalMaxH = null;
- canvas.style.maxHeight = "";
- if (lastVerts) redrawCached();
+ if (ticking) return;
+ ticking = true;
+ requestAnimationFrame(() => { ticking = false; applyMode(); });
+ });
+
+ // Dock / pop-out toggle.
+ document.getElementById("preview-dock")?.addEventListener("click", () => {
+ forcePip = !ws.classList.contains("mode-pip") ? true : false;
+ savePrefs({ corner, dismissed, forcePip });
+ applyMode();
+ });
+ // Hide the preview; reveal the re-show pill. Dismissal only takes visible effect in
+ // PiP mode (the pill replaces the floating preview); closing from docked mode also
+ // pops it out (forcePip) so the result is immediate — a dismissed docked preview that
+ // only vanished later when narrow auto-PiP kicked in would be a confusing surprise.
+ document.getElementById("preview-close")?.addEventListener("click", () => {
+ dismissed = true;
+ if (!ws.classList.contains("mode-pip")) forcePip = true;
+ savePrefs({ corner, dismissed, forcePip });
+ applyMode();
});
+ document.getElementById("preview-show")?.addEventListener("click", () => {
+ dismissed = false;
+ forcePip = false; // bring it back in the width-appropriate mode (docked on wide)
+ savePrefs({ corner, dismissed, forcePip });
+ applyMode();
+ });
+ // Sequence-number overlay toggle. The active state reflects the flag; the labels
+ // themselves only appear when also legible (few enough on-screen — see drawSeqLabels).
+ const numBtn = document.getElementById("preview-numbers");
+ numBtn?.addEventListener("click", () => {
+ showSeqNumbers_ = !showSeqNumbers_;
+ numBtn.classList.toggle("active", showSeqNumbers_);
+ if (lastVerts) redrawCached(); // repaint so labels appear/clear immediately
+ });
+
+ // Dot-size knob: scales the auto "filled-panel" base. Persisted so the preference sticks.
+ const dotSlider = document.getElementById("preview-dot");
+ if (dotSlider) {
+ dotSlider.value = String(dotScale_);
+ dotSlider.addEventListener("input", () => {
+ dotScale_ = clampDot(parseFloat(dotSlider.value));
+ localStorage.setItem("mm_preview_dot", String(dotScale_));
+ if (lastVerts) redrawCached();
+ });
+ }
+
+ // Drag the PiP by its bar; snap to the nearest corner on release. Pointer events
+ // on the BAR only (the canvas keeps its own orbit handler, untouched).
+ let drag = null;
+ bar.addEventListener("pointerdown", (e) => {
+ if (!ws.classList.contains("mode-pip")) return; // bar inert when docked
+ if (e.target.closest(".preview-bar-btn")) return; // let buttons click
+ const r = pane.getBoundingClientRect();
+ drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };
+ pane.classList.add("dragging");
+ bar.setPointerCapture(e.pointerId);
+ e.preventDefault();
+ });
+ bar.addEventListener("pointermove", (e) => {
+ if (!drag) return;
+ const w = pane.offsetWidth, h = pane.offsetHeight;
+ let x = e.clientX - drag.dx, y = e.clientY - drag.dy;
+ x = Math.max(0, Math.min(window.innerWidth - w, x)); // clamp to viewport
+ y = Math.max(44, Math.min(window.innerHeight - h, y));
+ pane.style.left = x + "px";
+ pane.style.top = y + "px";
+ pane.style.right = pane.style.bottom = "auto";
+ });
+ bar.addEventListener("pointerup", (e) => {
+ if (!drag) return;
+ drag = null;
+ pane.classList.remove("dragging");
+ try { bar.releasePointerCapture(e.pointerId); } catch (_) { /* ignore */ }
+ // Snap to nearest corner by the pane's center.
+ const r = pane.getBoundingClientRect();
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
+ corner = (cy < window.innerHeight / 2 ? "t" : "b") + (cx < window.innerWidth / 2 ? "l" : "r");
+ savePrefs({ corner, dismissed, forcePip });
+ placeCorner();
+ });
+
+ applyMode();
}
// True-shape preview: two binary message types on the preview WebSocket.
// 0x03 coordinate table (once per layout/LUT rebuild + ~1 Hz keepalive):
-// [0x03][count:u16][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count]
+// [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count]
// Stores the real lights' normalised positions in previewCoords_ (the
// geometry); per-frame 0x02 messages then just recolour those points.
-// 0x02 per-frame channels: [0x02][count:u16][stride:u16][(r,g,b) × count]
+// 0x02 per-frame channels: [0x02][count:u32][stride:u16][(r,g,b) × count]
// Colour for light i sits at position previewCoords_[i].
+// count is u32 so a >65535-light panel (HUB75 walls) isn't capped by the wire format.
// Light index i in the 0x02 stream matches coordinate-table entry i (both are
// every stride-th driver light, in the same order) — no dense grid, no decompress.
+// Show the device's adaptive-downscale factor in the preview bar — only while it's active
+// (factor > 1), so the bar stays clean at full resolution. previewStride_ is the per-axis
+// downscale: factor f means ~1/f² of the lights are shown (f per axis on a 2D grid).
+function updatePreviewStatus() {
+ const el = document.getElementById("preview-status");
+ if (!el) return;
+ if (previewStride_ > 1) {
+ el.textContent = `preview 1/${previewStride_} · link limited`;
+ el.hidden = false;
+ } else {
+ el.hidden = true;
+ }
+}
+
function renderPreviewBinary(buf) {
if (buf.byteLength < 1) return;
const view = new DataView(buf);
@@ -204,11 +459,14 @@ function renderPreviewBinary(buf) {
// Parse + cache the coordinate table: normalised (x,y,z) per point, centred on
// the bounding box so the cloud sits around the origin like the old grid did.
function parsePreviewCoords(view, buf) {
- if (buf.byteLength < 8) return;
- const count = view.getUint16(1, true);
- const bx = view.getUint8(3), by = view.getUint8(4), bz = view.getUint8(5);
- if (buf.byteLength < 8 + count * 3) return;
- const pos = new Uint8Array(buf, 8);
+ // Header: [0x03][count:u32][bx][by][bz][stride:u16] = 10 bytes.
+ if (buf.byteLength < 10) return;
+ const count = view.getUint32(1, true);
+ const bx = view.getUint8(5), by = view.getUint8(6), bz = view.getUint8(7);
+ previewStride_ = view.getUint16(8, true) || 1; // = device's adaptive downscale factor
+ updatePreviewStatus();
+ if (buf.byteLength < 10 + count * 3) return;
+ const pos = new Uint8Array(buf, 10);
const maxDim = Math.max(1, bx, by, bz);
previewMaxDim_ = maxDim;
previewCoords_ = new Float32Array(count * 3);
@@ -219,37 +477,60 @@ function parsePreviewCoords(view, buf) {
}
previewCoordCount_ = count;
previewBox_ = { x: bx, y: by, z: bz };
+ // Draw the grid layout NOW, off (placeholder rings), so a fresh page / UI refresh shows the
+ // geometry the instant the table arrives — not only once the first colour frame happens to land
+ // (which never comes if the scene is paused/idle). Colour frames then light it.
+ drawLights(null);
}
function renderPreviewFrame(view, buf) {
if (!gl) initWebGL();
if (!gl) return;
- // Hold frames until positions have arrived (the table is sent on rebuild +
- // ~1 Hz, so a fresh client catches up within a second).
+ // Hold frames until positions have arrived (the device sends the table on a geometry
+ // rebuild and when a new client connects, so a fresh client gets it on connect).
if (!previewCoords_ || previewCoordCount_ === 0) return;
- if (buf.byteLength < 5) return;
- const count = view.getUint16(1, true);
- if (buf.byteLength < 5 + count * 3) return;
- const rgb = new Uint8Array(buf, 5);
- // RGB[i] colours the light at previewCoords_[i]. If the frame and table
- // counts disagree (a rebuild in flight), plot the overlap only.
- const n = Math.min(count, previewCoordCount_);
+ // Header: [0x02][count:u32][stride:u16] = 7 bytes.
+ if (buf.byteLength < 7) return;
+ const count = view.getUint32(1, true);
+ const stride = view.getUint16(5, true) || 1;
+ if (buf.byteLength < 7 + count * 3) return;
+ const rgb = new Uint8Array(buf, 7);
+ // RGB[i] colours the light at previewCoords_[i]. The colour frame and the coordinate table
+ // MUST describe the same light set — if their count OR stride (downscale factor) disagree, a
+ // geometry rebuild (a resize, or the device's adaptive downscale changing the lattice) is
+ // mid-flight: the colours would land on the wrong positions (a visibly scrambled frame).
+ // Skip such a frame; the matching coord table arrives within ~1 frame and they realign.
+ if (count !== previewCoordCount_ || stride !== previewStride_) return;
+ drawLights(rgb);
+}
+
+// Build the vertex buffer from previewCoords_ + per-light colour and (re)start the render loop.
+// rgb may be null — then every light is drawn off (the shader's placeholder ring), so the grid
+// LAYOUT shows the instant the coordinate table arrives (a fresh page / UI refresh), before any
+// colour frame. A colour frame then calls this again with its rgb to light the scene.
+function drawLights(rgb) {
+ if (!gl) initWebGL();
+ if (!gl) return;
+ if (!previewCoords_ || previewCoordCount_ === 0) return;
+ const n = previewCoordCount_;
if (!vertsBuf || vertsBuf.length < n * 6) vertsBuf = new Float32Array(n * 6);
let vi = 0;
for (let i = 0; i < n; i++) {
- const r = rgb[i * 3], g = rgb[i * 3 + 1], b = rgb[i * 3 + 2];
- if (!(r | g | b)) continue; // skip dark points
+ // Include EVERY light, dark ones too: the shader draws an off LED as a faint
+ // placeholder ring (a lit one as a solid disc), so the grid shape stays visible
+ // and an all-off scene shows the layout instead of a black screen. (The count is
+ // already bounded by the table's stride downsampling for large grids.)
vertsBuf[vi++] = previewCoords_[i * 3 + 0];
vertsBuf[vi++] = previewCoords_[i * 3 + 1];
vertsBuf[vi++] = previewCoords_[i * 3 + 2];
- vertsBuf[vi++] = r / 255;
- vertsBuf[vi++] = g / 255;
- vertsBuf[vi++] = b / 255;
+ vertsBuf[vi++] = rgb ? rgb[i * 3] / 255 : 0;
+ vertsBuf[vi++] = rgb ? rgb[i * 3 + 1] / 255 : 0;
+ vertsBuf[vi++] = rgb ? rgb[i * 3 + 2] / 255 : 0;
}
const vertCount = vi / 6;
- if (vi === 0) return; // all-dark frame — keep the last geometry, let rAF idle
+ if (vi === 0) return; // no coords at all — keep the last geometry, let rAF idle
lastVerts = vertsBuf.subarray(0, vi);
lastVertCount = vertCount;
lastMaxDim = previewMaxDim_;
@@ -273,13 +554,31 @@ function redrawCached() {
if (!glLoopRunning) startRenderLoop();
}
-// Status-bar "reset preview" button: forget the saved camera and re-fit on the
-// next frame. Owns the camera, so the reset lives here, not in app.js.
+// The preview "reset" (⌖) button: restore the WHOLE preview to defaults — camera, dot-size
+// slider, sequence-number toggle, and docked/PiP layout. One button clears every preview
+// preference (all browser-local; nothing touches the device). Lives here since it owns the
+// camera + dot/number state; the layout part defers to setupLayout's resetLayout_ hook.
function resetCamera() {
+ // Camera: forget the saved orbit + re-fit on the next frame.
localStorage.removeItem("mm_cam");
camTheta = Math.PI;
camPhi = 0.4;
+ camTgtX = camTgtY = camTgtZ = 0; // recentre the pan target (cursor-zoom resets too)
camAutoFit = true;
+
+ // Dot size: back to the auto "filled-panel" base (1×); sync the slider control.
+ dotScale_ = 1;
+ localStorage.removeItem("mm_preview_dot");
+ const dotSlider = document.getElementById("preview-dot");
+ if (dotSlider) dotSlider.value = "1";
+
+ // Sequence numbers: off; clear the № button's active state.
+ showSeqNumbers_ = false;
+ document.getElementById("preview-numbers")?.classList.remove("active");
+
+ // Layout: back to auto docked/PiP, not dismissed, default corner.
+ if (resetLayout_) resetLayout_();
+
if (lastVerts) redrawCached();
}
@@ -304,10 +603,12 @@ function drawVerts() {
}
gl.viewport(0, 0, canvas.width, canvas.height);
- const cx = camDist * Math.cos(camPhi) * Math.sin(camTheta);
- const cy = camDist * Math.sin(camPhi);
- const cz = camDist * Math.cos(camPhi) * Math.cos(camTheta);
- const mvp = buildMVP(cx, cy, cz, canvas.width / Math.max(1, canvas.height));
+ // Eye orbits the target at camDist; the view looks AT the target (not the origin), so
+ // cursor-anchored zoom can pan the target without changing the orbit angles.
+ const ex = camTgtX + camDist * Math.cos(camPhi) * Math.sin(camTheta);
+ const ey = camTgtY + camDist * Math.sin(camPhi);
+ const ez = camTgtZ + camDist * Math.cos(camPhi) * Math.cos(camTheta);
+ const mvp = buildMVP(ex, ey, ez, camTgtX, camTgtY, camTgtZ, canvas.width / Math.max(1, canvas.height));
// alpha:false context — clear to page background colour so the canvas
// blends seamlessly in both light and dark themes.
@@ -325,15 +626,167 @@ function drawVerts() {
gl.vertexAttribPointer(glLocs.aCol, 3, gl.FLOAT, false, 24, 12);
gl.uniformMatrix4fv(glLocs.uMVP, false, mvp);
- const pointSize = Math.max(2, canvas.width * 0.8 / lastMaxDim);
+ // Size each dot to the spacing between the SAMPLED points so the layout reads as a filled
+ // panel (¾ light, ¼ gap) at any size — a big grid is spatially downsampled (the device
+ // sends ~1800 lattice points), so sizing by the full dimension left each dot a fraction of
+ // its cell with big gaps. The sampled points fill the bounding box uniformly, so the pitch
+ // between neighbours (in grid units) is (boxVolume / count)^(1/activeDims): the square root
+ // for a flat grid, the CUBE root for a 3D volume (a cube's points spread over depth, so a
+ // flat √ undercounts the pitch and the dots come out too small — the 3D-gap bug). Convert
+ // that grid pitch to on-screen pixels (canvas px per grid unit) and take 75% of it. The
+ // shader depth-corrects per point, so zooming still enlarges the dots beyond this base.
+ const bX = previewBox_ ? Math.max(1, previewBox_.x) : 1;
+ const bY = previewBox_ ? Math.max(1, previewBox_.y) : 1;
+ const bZ = previewBox_ ? Math.max(1, previewBox_.z) : 1;
+ const dims = (previewBox_ ? [bX, bY, bZ].filter(d => d > 1).length : 2) || 1; // active axes
+ const volume = (bX > 1 ? bX : 1) * (bY > 1 ? bY : 1) * (bZ > 1 ? bZ : 1);
+ const gridPitch = Math.pow(volume / Math.max(1, lastVertCount), 1 / dims); // grid units
+ const pxPerGridUnit = canvas.width / lastMaxDim;
+ const pointSize = Math.max(2, gridPitch * pxPerGridUnit * 0.75 * dotScale_);
gl.uniform1f(glLocs.uPointSize, pointSize);
-
+ // LOD: the off-LED placeholder rings are useful when sprites are big enough to show a
+ // hollow rim, but become visual mud once sprites are tiny (a dense grid zoomed out).
+ // Fade them by base sprite size — full rings ≥8px, gone ≤4px — so the layout shows on
+ // small/zoomed grids and the lit pattern reads cleanly when dense. Lit dots are never
+ // faded (their alpha ignores uRingFade in the shader).
+ const ringFade = Math.max(0, Math.min(1, (pointSize - 4) / 4));
+ gl.uniform1f(glLocs.uRingFade, ringFade);
+
+ // Two passes so lit LEDs always sit ABOVE the grey placeholders (your "lights should layer
+ // above the circles"). On a flat grid all LEDs share a z-plane, so a single pass let draw
+ // order + z-fighting clip a lit dot behind a neighbour's placeholder. Pass 1 draws the
+ // off-LED placeholders and writes depth; pass 2 draws the lit LEDs with depthFunc LEQUAL
+ // and depth-WRITE off — so a lit dot beats a co-located placeholder (equal depth passes)
+ // yet lit dots still depth-sort against each other in a true 3D cube under any pan/tilt.
+ gl.uniform1f(glLocs.uLitPass, 0.0); // placeholders (write depth)
gl.drawArrays(gl.POINTS, 0, lastVertCount);
+ gl.depthFunc(gl.LEQUAL);
+ gl.depthMask(false);
+ gl.uniform1f(glLocs.uLitPass, 1.0); // lit LEDs, on top
+ gl.drawArrays(gl.POINTS, 0, lastVertCount);
+ gl.depthMask(true);
+ gl.depthFunc(gl.LESS);
+
+ drawBoundingBox(mvp);
+ drawSeqLabels(mvp, canvas, pointSize);
+}
+
+// Faint wireframe cuboid around the light volume. Rebuilt only when the box extent
+// changes (cached by boxKey). Half-extents are box/2/maxDim — matching the same
+// normalisation the point coords use (pos/maxDim - 0.5*box/maxDim), so the cuboid's
+// faces pass through the outermost LED centres.
+function drawBoundingBox(mvp) {
+ if (!lineProgram || !previewBox_ || !previewMaxDim_) return;
+ const md = previewMaxDim_;
+ const key = previewBox_.x + "x" + previewBox_.y + "x" + previewBox_.z + "@" + md;
+ if (key !== boxKey) {
+ const hx = (previewBox_.x) / 2 / md, hy = (previewBox_.y) / 2 / md, hz = (previewBox_.z) / 2 / md;
+ // 8 corners → 12 edges → 24 endpoints.
+ const c = [
+ [-hx,-hy,-hz],[ hx,-hy,-hz],[ hx, hy,-hz],[-hx, hy,-hz],
+ [-hx,-hy, hz],[ hx,-hy, hz],[ hx, hy, hz],[-hx, hy, hz],
+ ];
+ const E = [[0,1],[1,2],[2,3],[3,0],[4,5],[5,6],[6,7],[7,4],[0,4],[1,5],[2,6],[3,7]];
+ boxVerts = new Float32Array(E.length * 6);
+ let k = 0;
+ for (const [a, b] of E) { boxVerts.set(c[a], k); k += 3; boxVerts.set(c[b], k); k += 3; }
+ boxKey = key;
+ }
+ gl.useProgram(lineProgram);
+ gl.bindBuffer(gl.ARRAY_BUFFER, lineBuffer);
+ gl.bufferData(gl.ARRAY_BUFFER, boxVerts, gl.DYNAMIC_DRAW);
+ gl.enableVertexAttribArray(lineLocs.aPos);
+ gl.vertexAttribPointer(lineLocs.aPos, 3, gl.FLOAT, false, 0, 0);
+ gl.uniformMatrix4fv(lineLocs.uMVP, false, mvp);
+ // Faint, theme-neutral grey — visible on both dark and light backgrounds.
+ gl.uniform4f(lineLocs.uColor, 0.5, 0.5, 0.55, 0.25);
+ gl.drawArrays(gl.LINES, 0, boxVerts.length / 3);
+ gl.useProgram(glProgram); // restore the points program for the next frame
+}
+
+// Sequence-number overlay. WebGL point sprites can't draw text, so each light's index is
+// rendered onto a 2D canvas laid over #preview: project the light's position through the
+// SAME mvp the GL render uses (so labels track LEDs in 2D AND 3D layouts), to a screen
+// pixel, and draw its number. Legibility LOD: a number is drawn only if it FITS INSIDE its
+// light bulb (the on-screen sprite) — so it never overflows onto neighbours. The font is
+// sized to the sprite, so as you zoom in (sprites grow, depth-corrected) more numbers fit
+// and appear; zoomed out on a dense grid they don't fit and stay hidden. Behind-camera
+// points (w ≤ 0) are skipped — essential for 3D.
+function drawSeqLabels(mvp, glCanvas, pointSize) {
+ const lc = document.getElementById("preview-labels");
+ if (!lc) return;
+ // Match the overlay to the GL canvas's on-screen box (the pane also holds the bar
+ // above the canvas, so anchor to #preview's rect, not the pane's).
+ const gr = glCanvas.getBoundingClientRect();
+ const pr = lc.parentElement.getBoundingClientRect();
+ lc.style.left = (gr.left - pr.left) + "px";
+ lc.style.top = (gr.top - pr.top) + "px";
+ lc.style.width = gr.width + "px";
+ lc.style.height = gr.height + "px";
+ const W = Math.max(1, Math.round(gr.width)), H = Math.max(1, Math.round(gr.height));
+ if (lc.width !== W || lc.height !== H) { lc.width = W; lc.height = H; }
+ const ctx = lc.getContext("2d");
+ ctx.clearRect(0, 0, W, H);
+
+ if (!showSeqNumbers_ || !previewCoords_ || previewCoordCount_ === 0) return;
+
+ // Project every sent point; keep those in front of the camera + inside the viewport.
+ // mvp is column-major: clip[r] = Σ_c mvp[c*4+r] * v[c].
+ const proj = [];
+ for (let i = 0; i < previewCoordCount_; i++) {
+ const x = previewCoords_[i*3], y = previewCoords_[i*3+1], z = previewCoords_[i*3+2];
+ const cw = mvp[3] * x + mvp[7] * y + mvp[11] * z + mvp[15];
+ if (cw <= 0) continue; // behind the camera (3D)
+ const cx = mvp[0] * x + mvp[4] * y + mvp[8] * z + mvp[12];
+ const cy = mvp[1] * x + mvp[5] * y + mvp[9] * z + mvp[13];
+ const sx = (cx / cw * 0.5 + 0.5) * W;
+ const sy = (1 - (cy / cw * 0.5 + 0.5)) * H; // GL y-up → canvas y-down
+ if (sx < 0 || sx > W || sy < 0 || sy > H) continue; // off-screen
+ // The bulb's on-screen diameter, in CSS px. The shader's gl_PointSize = uPointSize/w
+ // (same depth correction) is clamped to glMaxPointSize by the driver, so the DRAWN
+ // bulb can't exceed that — clamp here too (in backing px) before converting to CSS
+ // px (sprite px are backing px; the overlay is CSS px), or the fit-check would
+ // believe a bulb is bigger than it renders and labels would never appear at the cap.
+ const cssPerBacking = W / glCanvas.width;
+ const diam = Math.min(pointSize / cw, glMaxPointSize) * cssPerBacking;
+ // Label = the sent-point index. At full resolution (previewStride_==1) that IS the
+ // driver light index. When downsampled (>1) the device sends a spatial LATTICE, not
+ // every Nth flat light, so the true driver index isn't i*stride — we show the sent
+ // index i (monotonic, still useful for orientation) rather than a wrong number.
+ proj.push({ n: i, sx, sy, depth: cw, diam });
+ }
+ if (proj.length === 0) return;
+
+ // Nearest first so a label drawn later (farther) doesn't overwrite a closer one's slot.
+ proj.sort((a, b) => a.depth - b.depth);
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+ for (const p of proj) {
+ const t = String(p.n);
+ // "Fits in the bulb or hide": size the font so the number's WIDTH fills ~85% of the
+ // bulb (monospace digit ≈ 0.6em wide, so width ≈ digits*0.6*fontPx), capped so a
+ // 1-digit number isn't comically tall. Draw only if the resulting font is readable
+ // (≥7px). A dense grid zoomed out → tiny bulbs → fontPx<7 → hidden; zoom in → bulbs
+ // grow → the same numbers cross 7px and appear. (A 3-digit number needs a ~3× bigger
+ // bulb than a 1-digit one to show — exactly right: more digits need more room.)
+ const widthLimited = (p.diam * 0.85) / (t.length * 0.6);
+ const fontPx = Math.min(widthLimited, p.diam * 0.8);
+ if (fontPx < 7) continue; // too small to read at this zoom
+ ctx.font = `${fontPx}px ui-monospace, monospace`;
+ // A dark halo so the number reads over both lit LEDs and the dark background.
+ ctx.lineWidth = Math.max(2, fontPx * 0.18);
+ ctx.strokeStyle = "rgba(0,0,0,0.85)";
+ ctx.strokeText(t, p.sx, p.sy);
+ ctx.fillStyle = "#fff";
+ ctx.fillText(t, p.sx, p.sy);
+ }
}
-function buildMVP(ex, ey, ez, aspect) {
- const fLen = Math.sqrt(ex*ex + ey*ey + ez*ez) || 1;
- const fx = -ex/fLen, fy = -ey/fLen, fz = -ez/fLen;
+function buildMVP(ex, ey, ez, tx, ty, tz, aspect) {
+ // forward = normalize(target - eye)
+ const dx = tx - ex, dy = ty - ey, dz = tz - ez;
+ const fLen = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1;
+ const fx = dx/fLen, fy = dy/fLen, fz = dz/fLen;
// Right = cross(forward, (0,1,0))
let rx = fz, ry = 0, rz = -fx;
const rLen = Math.sqrt(rx*rx + ry*ry + rz*rz) || 1;
@@ -348,7 +801,9 @@ function buildMVP(ex, ey, ez, aspect) {
-(rx*ex+ry*ey+rz*ez), -(ux*ex+uy*ey+uz*ez), (fx*ex+fy*ey+fz*ez), 1
];
- const near = 0.1, far = 50, fov = 0.8;
+ // near is small so a deep zoom-in (camDist down to CAM_MIN) doesn't clip the LEDs in
+ // front of the camera away before their sequence numbers get big enough to read.
+ const near = 0.01, far = 50, fov = 0.8;
const f = 1 / Math.tan(fov / 2);
const proj = [
f/aspect, 0, 0, 0,
@@ -369,10 +824,10 @@ function buildMVP(ex, ey, ez, aspect) {
return m;
}
-// Public surface — the only three entry points app.js touches.
+// Public surface — the only entry points app.js touches.
export const preview = {
init: initWebGL,
- setupShrink: setupPreviewShrink,
+ setupLayout: setupLayout,
onBinaryMessage: renderPreviewBinary,
resetCamera: resetCamera,
};
diff --git a/src/ui/style.css b/src/ui/style.css
index 689a7ec..47ebe5d 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -212,63 +212,155 @@ body {
font-size: 11px;
}
-/* The content area right of the nav. Full width — the preview spans all of it.
- The module card column is capped separately (#main below) so cards stay a
- readable single-column width while the preview goes edge to edge. */
+/* The content area right of the nav. */
.main-area {
flex: 1;
min-width: 0;
padding: 12px;
}
-/* Module cards: capped width, centered under the full-width preview. */
-#main {
- max-width: 500px;
- margin: 0 auto;
-}
-
/* ============================================================
- 3D preview (sticky, scroll-shrink)
+ Workspace: preview pane + module cards
+ Two modes (class on .workspace, toggled by preview3d.js::setupLayout):
+ - .mode-docked (wide ≥960px): a row — preview pane (flex) beside a fixed-width
+ card column that scrolls on its own. The preview is stable, no scroll-shrink.
+ - .mode-pip (narrow, or popped out): cards full-width; the preview detaches
+ into a fixed, draggable picture-in-picture (the same one canvas, restyled in
+ place — never duplicated, so the single WebGL context survives the switch).
============================================================ */
-.preview-wrap {
+.workspace.mode-docked {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+ gap: 16px;
+}
+
+/* Docked preview: fills the pane, sticky so it stays put while cards scroll. */
+.mode-docked .preview-pane {
position: sticky;
- top: 44px;
- z-index: 5;
- background: var(--bg-0);
- padding: 8px 0;
- margin-bottom: 12px;
+ top: 56px;
+ flex: 1;
+ min-width: 0;
+}
+.mode-docked #main {
+ flex: 0 0 480px;
+ max-width: 480px;
+ /* Cards own their scroll — the page itself doesn't scroll in docked mode, so
+ the preview beside them stays fixed. 44px status bar + 12px padding above. */
+ max-height: calc(100vh - 56px);
+ overflow-y: auto;
}
#preview {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
- max-height: 50vh;
- transition: max-height 0.05s linear;
+ max-height: calc(100vh - 72px);
touch-action: none;
}
-#preview-reset {
+/* Sequence-number overlay: a 2D canvas sized + positioned to exactly cover #preview
+ (the preview-pane is the positioned ancestor). pointer-events:none so orbit/drag pass
+ through to the WebGL canvas underneath. preview3d.js projects each light through the
+ same MVP as the GL render, so labels track LEDs in 2D AND 3D layouts. */
+#preview-labels {
position: absolute;
- top: 16px;
- right: 8px;
- background: var(--bg-1);
- border: 1px solid var(--border);
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ pointer-events: none;
+}
+
+/* The drag bar is docked-mode-hidden (only PiP needs a grab handle); the dock /
+ close buttons stay available so a wide-screen user can pop the preview out. */
+.preview-bar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 2px 4px;
+}
+.preview-grip { display: none; color: var(--fg-muted); cursor: grab; user-select: none; font-size: 14px; }
+.preview-status { color: var(--fg-muted); font-size: 11px; opacity: 0.8; white-space: nowrap; }
+/* Standard screen-reader-only pattern: present in the DOM (labels a control) but not shown. */
+.visually-hidden {
+ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
+ overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
+}
+.preview-bar-spacer { flex: 1; }
+.preview-bar-btn {
+ background: transparent;
+ border: none;
color: var(--fg-muted);
- border-radius: 4px;
- width: 26px;
- height: 26px;
- font-size: 15px;
+ cursor: pointer;
+ font-size: 13px;
line-height: 1;
- padding: 0;
+ padding: 2px 4px;
+ border-radius: 4px;
+ opacity: 0.6;
+}
+.preview-bar-btn:hover { opacity: 1; color: var(--accent); }
+.preview-bar-btn.active { opacity: 1; color: var(--accent); }
+
+/* Compact dot-size slider in the preview bar. accent-color tints the native control to
+ match the theme without a full custom track/thumb (recognisable + minimal). */
+.preview-dot {
+ width: 70px;
+ height: 14px;
+ margin: 0 2px;
cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
+ accent-color: var(--accent);
opacity: 0.6;
}
-#preview-reset:hover { opacity: 1; color: var(--accent); border-color: var(--accent); }
+.preview-dot:hover { opacity: 1; }
+
+
+/* ---- PiP mode: cards full width, preview floats ---- */
+.workspace.mode-pip { display: block; }
+.mode-pip #main {
+ max-width: 560px;
+ margin: 0 auto;
+}
+.mode-pip .preview-pane {
+ position: fixed;
+ z-index: 70;
+ width: 200px;
+ /* JS sets left/top from the snapped corner; these are the fallback (bottom-right). */
+ right: 12px;
+ bottom: 12px;
+ background: var(--bg-1);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
+ overflow: hidden;
+}
+.mode-pip .preview-pane.dragging { opacity: 0.85; box-shadow: 0 8px 28px rgba(0, 0, 0, 0.55); }
+.mode-pip .preview-grip { display: inline; }
+.mode-pip .preview-bar { cursor: grab; background: var(--bg-2); }
+.mode-pip #preview { max-height: none; }
+.mode-pip #preview-reset { top: 28px; width: 22px; height: 22px; font-size: 13px; }
+/* Expanded PiP: a larger floating view when the user taps ⤢ in PiP. */
+.mode-pip .preview-pane.expanded { width: min(80vw, 360px); }
+
+/* When the PiP is dismissed, hide the pane and show the re-show pill. */
+.workspace.preview-hidden .preview-pane { display: none; }
+.preview-show {
+ position: fixed;
+ right: 12px;
+ bottom: 12px;
+ z-index: 70;
+ background: var(--bg-1);
+ border: 1px solid var(--border);
+ color: var(--fg-muted);
+ border-radius: 16px;
+ padding: 6px 12px;
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
+}
+.preview-show:hover { color: var(--accent); border-color: var(--accent); }
/* ============================================================
Cards
diff --git a/test/scenarios/light/scenario_Audio_mutation.json b/test/scenarios/light/scenario_Audio_mutation.json
index 253fb6e..1e2ab04 100644
--- a/test/scenarios/light/scenario_Audio_mutation.json
+++ b/test/scenarios/light/scenario_Audio_mutation.json
@@ -88,7 +88,7 @@
"pc-macos": {
"tick_us": [
8,
- 10
+ 12
],
"free_heap": [
0,
@@ -100,7 +100,7 @@
],
"at": [
"2026-06-12",
- "2026-06-14"
+ "2026-06-22"
]
}
}
@@ -127,7 +127,7 @@
"pc-macos": {
"tick_us": [
8,
- 10
+ 20
],
"free_heap": [
0,
@@ -139,7 +139,7 @@
],
"at": [
"2026-06-12",
- "2026-06-16"
+ "2026-06-22"
]
}
}
@@ -221,7 +221,7 @@
"pc-macos": {
"tick_us": [
8,
- 12
+ 13
],
"free_heap": [
0,
@@ -233,7 +233,7 @@
],
"at": [
"2026-06-12",
- "2026-06-16"
+ "2026-06-22"
]
}
}
@@ -295,7 +295,7 @@
"pc-macos": {
"tick_us": [
8,
- 11
+ 12
],
"free_heap": [
0,
@@ -307,7 +307,7 @@
],
"at": [
"2026-06-12",
- "2026-06-17"
+ "2026-06-22"
]
}
}
diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json
index c1514e0..71c3f69 100644
--- a/test/scenarios/light/scenario_Driver_mutation.json
+++ b/test/scenarios/light/scenario_Driver_mutation.json
@@ -77,7 +77,7 @@
"pc-macos": {
"tick_us": [
8,
- 11
+ 12
],
"free_heap": [
0,
@@ -89,7 +89,7 @@
],
"at": [
"2026-06-13",
- "2026-06-16"
+ "2026-06-22"
]
}
}
@@ -116,7 +116,7 @@
"pc-macos": {
"tick_us": [
8,
- 10
+ 56
],
"free_heap": [
0,
@@ -128,7 +128,7 @@
],
"at": [
"2026-06-13",
- "2026-06-16"
+ "2026-06-22"
]
}
}
@@ -155,7 +155,7 @@
"pc-macos": {
"tick_us": [
8,
- 10
+ 20
],
"free_heap": [
0,
@@ -167,7 +167,7 @@
],
"at": [
"2026-06-13",
- "2026-06-16"
+ "2026-06-22"
]
}
}
diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json
index 7010fb4..d30ea14 100644
--- a/test/scenarios/light/scenario_GridLayout_resize.json
+++ b/test/scenarios/light/scenario_GridLayout_resize.json
@@ -211,7 +211,7 @@
1353
],
"free_heap": [
- 34015075,
+ 34002551,
34015087
],
"max_alloc_block": [
@@ -220,7 +220,25 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
+ ]
+ },
+ "esp32s3-n16r8": {
+ "tick_us": [
+ 4601,
+ 9422
+ ],
+ "free_heap": [
+ 8514887,
+ 8520587
+ ],
+ "max_alloc_block": [
+ 106496,
+ 110592
+ ],
+ "at": [
+ "2026-06-22",
+ "2026-06-22"
]
}
}
@@ -344,7 +362,7 @@
655
],
"free_heap": [
- 34023791,
+ 34011543,
34023819
],
"max_alloc_block": [
@@ -353,7 +371,25 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
+ ]
+ },
+ "esp32s3-n16r8": {
+ "tick_us": [
+ 1270,
+ 2411
+ ],
+ "free_heap": [
+ 8524023,
+ 8530563
+ ],
+ "max_alloc_block": [
+ 102400,
+ 114688
+ ],
+ "at": [
+ "2026-06-22",
+ "2026-06-22"
]
}
}
@@ -477,7 +513,7 @@
1312
],
"free_heap": [
- 34014799,
+ 34002551,
34014827
],
"max_alloc_block": [
@@ -486,7 +522,25 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
+ ]
+ },
+ "esp32s3-n16r8": {
+ "tick_us": [
+ 3986,
+ 7599
+ ],
+ "free_heap": [
+ 8511875,
+ 8521567
+ ],
+ "max_alloc_block": [
+ 102400,
+ 114688
+ ],
+ "at": [
+ "2026-06-22",
+ "2026-06-22"
]
}
}
diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json
index 926598c..4ef3a78 100644
--- a/test/scenarios/light/scenario_Layouts_mutation.json
+++ b/test/scenarios/light/scenario_Layouts_mutation.json
@@ -79,7 +79,7 @@
"pc-macos": {
"tick_us": [
8,
- 34
+ 35
],
"free_heap": [
0,
@@ -91,7 +91,7 @@
],
"at": [
"2026-06-05",
- "2026-06-05"
+ "2026-06-22"
]
},
"pc-windows": {
@@ -158,7 +158,7 @@
"pc-macos": {
"tick_us": [
9,
- 46
+ 82
],
"free_heap": [
0,
@@ -170,7 +170,7 @@
],
"at": [
"2026-06-05",
- "2026-06-11"
+ "2026-06-22"
]
},
"pc-windows": {
@@ -232,7 +232,7 @@
"pc-macos": {
"tick_us": [
10,
- 174
+ 225
],
"free_heap": [
0,
@@ -244,7 +244,7 @@
],
"at": [
"2026-06-05",
- "2026-06-11"
+ "2026-06-22"
]
},
"pc-windows": {
diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json
index 48467ff..87a9784 100644
--- a/test/scenarios/light/scenario_perf_full.json
+++ b/test/scenarios/light/scenario_perf_full.json
@@ -104,20 +104,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 122,
- 133
+ 111,
+ 186
],
"free_heap": [
8540003,
- 8540039
+ 8546675
],
"max_alloc_block": [
106496,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -140,11 +140,11 @@
},
"esp32p4-eth": {
"tick_us": [
- 63,
+ 58,
67
],
"free_heap": [
- 34041019,
+ 34023535,
34042067
],
"max_alloc_block": [
@@ -153,7 +153,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -193,20 +193,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 110,
- 111
+ 101,
+ 124
],
"free_heap": [
8538439,
- 8540019
+ 8546019
],
"max_alloc_block": [
106496,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -230,10 +230,10 @@
"esp32p4-eth": {
"tick_us": [
53,
- 54
+ 55
],
"free_heap": [
- 34041015,
+ 34025103,
34041643
],
"max_alloc_block": [
@@ -242,7 +242,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -282,20 +282,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 110,
- 112
+ 101,
+ 115
],
"free_heap": [
8536747,
- 8539987
+ 8545823
],
"max_alloc_block": [
102400,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -318,11 +318,11 @@
},
"esp32p4-eth": {
"tick_us": [
- 56,
+ 54,
57
],
"free_heap": [
- 34041015,
+ 34023519,
34041019
],
"max_alloc_block": [
@@ -331,7 +331,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -369,20 +369,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 252,
- 292
+ 236,
+ 293
],
"free_heap": [
8536423,
- 8538239
+ 8544003
],
"max_alloc_block": [
106496,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -405,11 +405,11 @@
},
"esp32p4-eth": {
"tick_us": [
- 96,
- 98
+ 94,
+ 106
],
"free_heap": [
- 34039211,
+ 34021595,
34039211
],
"max_alloc_block": [
@@ -418,7 +418,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -463,20 +463,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 114,
- 118
+ 106,
+ 124
],
"free_heap": [
8535099,
- 8540027
+ 8545827
],
"max_alloc_block": [
- 102400,
- 106496
+ 94208,
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -503,7 +503,7 @@
63
],
"free_heap": [
- 34041007,
+ 34024991,
34041015
],
"max_alloc_block": [
@@ -512,7 +512,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -555,15 +555,15 @@
],
"free_heap": [
8533659,
- 8537007
+ 8544519
],
"max_alloc_block": [
86016,
- 102400
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -587,10 +587,10 @@
"esp32p4-eth": {
"tick_us": [
57,
- 67
+ 69
],
"free_heap": [
- 34037987,
+ 34023679,
34038003
],
"max_alloc_block": [
@@ -599,7 +599,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -648,20 +648,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 118,
+ 108,
120
],
"free_heap": [
8506847,
- 8514887
+ 8520667
],
"max_alloc_block": [
86016,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -685,10 +685,10 @@
"esp32p4-eth": {
"tick_us": [
56,
- 58
+ 63
],
"free_heap": [
- 34015883,
+ 33996811,
34015891
],
"max_alloc_block": [
@@ -697,7 +697,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -747,20 +747,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 119,
+ 108,
142
],
"free_heap": [
8536135,
- 8537691
+ 8545247
],
"max_alloc_block": [
94208,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -783,11 +783,11 @@
},
"esp32p4-eth": {
"tick_us": [
- 57,
+ 56,
62
],
"free_heap": [
- 34040455,
+ 34022847,
34040463
],
"max_alloc_block": [
@@ -796,7 +796,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -846,20 +846,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 118,
+ 106,
130
],
"free_heap": [
8537691,
- 8537719
+ 8545823
],
"max_alloc_block": [
106496,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -882,11 +882,11 @@
},
"esp32p4-eth": {
"tick_us": [
- 57,
+ 56,
63
],
"free_heap": [
- 34040463,
+ 34022855,
34040471
],
"max_alloc_block": [
@@ -895,7 +895,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -955,20 +955,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 110,
+ 101,
119
],
"free_heap": [
8535703,
- 8536683
+ 8545827
],
"max_alloc_block": [
102400,
- 102400
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -992,10 +992,10 @@
"esp32p4-eth": {
"tick_us": [
53,
- 61
+ 65
],
"free_heap": [
- 34041007,
+ 34023427,
34041007
],
"max_alloc_block": [
@@ -1004,7 +1004,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1048,20 +1048,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 297,
+ 278,
328
],
"free_heap": [
8531251,
- 8537887
+ 8543527
],
"max_alloc_block": [
102400,
- 102400
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -1085,10 +1085,10 @@
"esp32p4-eth": {
"tick_us": [
133,
- 134
+ 138
],
"free_heap": [
- 34038703,
+ 34022787,
34038703
],
"max_alloc_block": [
@@ -1097,7 +1097,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1141,20 +1141,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 1002,
+ 989,
1090
],
"free_heap": [
8510995,
- 8530415
+ 8534311
],
"max_alloc_block": [
90112,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -1181,7 +1181,7 @@
498
],
"free_heap": [
- 34029487,
+ 34015299,
34029487
],
"max_alloc_block": [
@@ -1190,7 +1190,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1234,20 +1234,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 7787,
+ 7488,
7949
],
"free_heap": [
8490295,
- 8493559
+ 8497459
],
"max_alloc_block": [
102400,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -1270,11 +1270,11 @@
},
"esp32p4-eth": {
"tick_us": [
- 1790,
+ 1744,
1940
],
"free_heap": [
- 33992623,
+ 33978435,
33992623
],
"max_alloc_block": [
@@ -1283,7 +1283,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1335,20 +1335,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 738,
- 799
+ 735,
+ 871
],
"free_heap": [
8541939,
- 8541963
+ 8545831
],
"max_alloc_block": [
110592,
- 110592
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -1375,7 +1375,7 @@
345
],
"free_heap": [
- 34041007,
+ 34026819,
34041027
],
"max_alloc_block": [
@@ -1384,7 +1384,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1429,19 +1429,19 @@
"esp32s3-n16r8": {
"tick_us": [
2808,
- 2831
+ 3447
],
"free_heap": [
8539631,
- 8539659
+ 8543527
],
"max_alloc_block": [
110592,
- 110592
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -1468,7 +1468,7 @@
1252
],
"free_heap": [
- 34038703,
+ 34024515,
34038723
],
"max_alloc_block": [
@@ -1477,7 +1477,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1521,20 +1521,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 11136,
- 11235
+ 11073,
+ 11371
],
"free_heap": [
8530415,
- 8530443
+ 8534311
],
"max_alloc_block": [
110592,
- 110592
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -1558,10 +1558,10 @@
"esp32p4-eth": {
"tick_us": [
4358,
- 4504
+ 5101
],
"free_heap": [
- 34029487,
+ 34015299,
34029507
],
"max_alloc_block": [
@@ -1570,7 +1570,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1614,20 +1614,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 49192,
- 50555
+ 48051,
+ 51127
],
"free_heap": [
- 8493583,
- 8493855
+ 8491607,
+ 8497447
],
"max_alloc_block": [
- 110592,
- 110592
+ 106496,
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -1654,7 +1654,7 @@
18024
],
"free_heap": [
- 33992623,
+ 33978435,
33992643
],
"max_alloc_block": [
@@ -1663,7 +1663,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1697,20 +1697,20 @@
"observed": {
"esp32s3-n16r8": {
"tick_us": [
- 385,
- 385
+ 382,
+ 456
],
"free_heap": [
8540231,
- 8540231
+ 8543995
],
"max_alloc_block": [
110592,
- 110592
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"pc-macos": {
@@ -1752,10 +1752,10 @@
"esp32p4-eth": {
"tick_us": [
154,
- 163
+ 164
],
"free_heap": [
- 34039203,
+ 34021691,
34039223
],
"max_alloc_block": [
@@ -1764,7 +1764,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1790,20 +1790,20 @@
"observed": {
"esp32s3-n16r8": {
"tick_us": [
- 1573,
- 1573
+ 1409,
+ 1668
],
"free_heap": [
8528551,
- 8528551
+ 8537251
],
"max_alloc_block": [
102400,
- 102400
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"pc-macos": {
@@ -1845,10 +1845,10 @@
"esp32p4-eth": {
"tick_us": [
533,
- 549
+ 613
],
"free_heap": [
- 34032459,
+ 34014947,
34032479
],
"max_alloc_block": [
@@ -1857,7 +1857,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1883,26 +1883,26 @@
"observed": {
"esp32s3-n16r8": {
"tick_us": [
- 6552,
+ 6165,
6552
],
"free_heap": [
8506427,
- 8506427
+ 8510275
],
"max_alloc_block": [
110592,
- 110592
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"pc-macos": {
"tick_us": [
14,
- 16
+ 18
],
"free_heap": [
0,
@@ -1914,7 +1914,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -1938,10 +1938,10 @@
"esp32p4-eth": {
"tick_us": [
2058,
- 2167
+ 2285
],
"free_heap": [
- 34005483,
+ 33991055,
34005503
],
"max_alloc_block": [
@@ -1950,7 +1950,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -1976,26 +1976,26 @@
"observed": {
"esp32s3-n16r8": {
"tick_us": [
- 29647,
- 29647
+ 28061,
+ 33932
],
"free_heap": [
8398527,
- 8398527
+ 8402371
],
"max_alloc_block": [
110592,
- 110592
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"pc-macos": {
"tick_us": [
62,
- 70
+ 71
],
"free_heap": [
0,
@@ -2007,7 +2007,7 @@
],
"at": [
"2026-06-17",
- "2026-06-21"
+ "2026-06-22"
]
},
"esp32": {
@@ -2031,10 +2031,10 @@
"esp32p4-eth": {
"tick_us": [
9846,
- 10143
+ 10184
],
"free_heap": [
- 33897579,
+ 33883359,
33897599
],
"max_alloc_block": [
@@ -2043,7 +2043,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json
index cb2e3fc..83eeaad 100644
--- a/test/scenarios/light/scenario_perf_light.json
+++ b/test/scenarios/light/scenario_perf_light.json
@@ -120,19 +120,19 @@
"esp32s3-n16r8": {
"tick_us": [
113,
- 140
+ 149
],
"free_heap": [
8515895,
- 8535151
+ 8538843
],
"max_alloc_block": [
81920,
- 102400
+ 106496
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -156,10 +156,10 @@
"esp32p4-eth": {
"tick_us": [
54,
- 72
+ 73
],
"free_heap": [
- 34040943,
+ 34024987,
34041859
],
"max_alloc_block": [
@@ -168,7 +168,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -212,15 +212,15 @@
],
"free_heap": [
8530367,
- 8531367
+ 8535475
],
"max_alloc_block": [
98304,
- 98304
+ 102400
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -247,7 +247,7 @@
104
],
"free_heap": [
- 34039139,
+ 34023155,
34039419
],
"max_alloc_block": [
@@ -256,7 +256,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -287,12 +287,12 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 241,
+ 238,
243
],
"free_heap": [
8529571,
- 8532931
+ 8533923
],
"max_alloc_block": [
98304,
@@ -300,7 +300,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -324,10 +324,10 @@
"esp32p4-eth": {
"tick_us": [
93,
- 94
+ 95
],
"free_heap": [
- 34039139,
+ 34023147,
34039207
],
"max_alloc_block": [
@@ -336,7 +336,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -374,20 +374,20 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 405,
+ 399,
406
],
"free_heap": [
- 8532915,
+ 8532403,
8532939
],
"max_alloc_block": [
- 102400,
+ 90112,
102400
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -414,7 +414,7 @@
180
],
"free_heap": [
- 34039163,
+ 34021587,
34039219
],
"max_alloc_block": [
@@ -423,7 +423,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -467,7 +467,7 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 1527,
+ 1399,
1778
],
"free_heap": [
@@ -480,7 +480,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -504,10 +504,10 @@
"esp32p4-eth": {
"tick_us": [
532,
- 533
+ 550
],
"free_heap": [
- 34032431,
+ 34018071,
34032475
],
"max_alloc_block": [
@@ -516,7 +516,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
@@ -543,7 +543,7 @@
"pc-macos": {
"tick_us": [
14,
- 18
+ 26
],
"free_heap": [
0,
@@ -555,7 +555,7 @@
],
"at": [
"2026-06-17",
- "2026-06-21"
+ "2026-06-22"
]
},
"esp32s3-n16r8": {
@@ -597,10 +597,10 @@
"esp32p4-eth": {
"tick_us": [
2038,
- 2061
+ 2115
],
"free_heap": [
- 34005455,
+ 33991263,
34005499
],
"max_alloc_block": [
@@ -609,7 +609,7 @@
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
}
}
diff --git a/test/unit/core/unit_Control_apply_absent_key.cpp b/test/unit/core/unit_Control_apply_absent_key.cpp
index 187b3ff..9ee9bf0 100644
--- a/test/unit/core/unit_Control_apply_absent_key.cpp
+++ b/test/unit/core/unit_Control_apply_absent_key.cpp
@@ -99,3 +99,73 @@ TEST_CASE("applyControlValue applies an explicit zero when the key is present")
== mm::ApplyResult::Ok);
CHECK(ethType == 0); // explicit 0 IS applied
}
+
+// A per-control validator (ControlDescriptor::validate) runs on EVERY write path —
+// the backend home for input rules that used to live in a bespoke per-transport RPC
+// (e.g. deviceModel's printable-ASCII check, formerly the SET_DEVICE_MODEL Improv RPC).
+// A reject returns Malformed and leaves the stored value untouched (no partial write);
+// any transport (HTTP, APPLY_OP over serial, persistence) gets the check for free.
+static bool acceptPrintableAscii(const char* v) {
+ if (!v) return false;
+ size_t n = std::strlen(v);
+ if (n == 0 || n >= 32) return false;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char b = static_cast(v[i]);
+ if (b < 0x20 || b > 0x7E) return false;
+ }
+ return true;
+}
+
+TEST_CASE("a per-control validator accepts a valid value and rejects bad input") {
+ mm::ControlList controls;
+ char deviceModel[32] = "initial";
+ controls.addText("deviceModel", deviceModel, sizeof(deviceModel), acceptPrintableAscii);
+
+ // Valid printable-ASCII → applied.
+ CHECK(mm::applyControlValue(controls[0], "{\"deviceModel\":\"LOLIN D32\"}",
+ "deviceModel", mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok);
+ CHECK(std::strcmp(deviceModel, "LOLIN D32") == 0);
+
+ // A raw non-printable byte embedded in the value (0x01) — parseString copies bytes
+ // verbatim (it only un-escapes \" and \\), so a wire-untrusted control byte reaches
+ // the validator, which rejects it → Malformed, prior value preserved (no partial write).
+ const char bad[] = {'{','"','d','e','v','i','c','e','M','o','d','e','l','"',':','"',
+ 'b','a','d', 0x01, 'x','"','}', 0};
+ CHECK(mm::applyControlValue(controls[0], bad,
+ "deviceModel", mm::ApplyPolicy::Clamp) == mm::ApplyResult::Malformed);
+ CHECK(std::strcmp(deviceModel, "LOLIN D32") == 0); // unchanged
+
+ // Empty string → Malformed (the validator rejects 0-length), prior value preserved.
+ CHECK(mm::applyControlValue(controls[0], "{\"deviceModel\":\"\"}",
+ "deviceModel", mm::ApplyPolicy::Clamp) == mm::ApplyResult::Malformed);
+ CHECK(std::strcmp(deviceModel, "LOLIN D32") == 0);
+}
+
+// Length boundary of the deviceModel validator (accepts 1..31). Uses a buffer wider than
+// the validator's limit so the 32-char value reaches the validator intact (parseString
+// truncates to bufSize-1, so the buffer must exceed 32 for the validator's own length
+// check — not parse truncation — to be what rejects it). The scratch buffer in
+// applyControlValue is sized to bufSize, so a long value isn't truncated before validation.
+TEST_CASE("the validator enforces its length limit on the long end") {
+ mm::ControlList controls;
+ char label[64] = "init"; // wider than the validator's 31-char limit
+ controls.addText("label", label, sizeof(label), acceptPrintableAscii);
+
+ const char s31[] = "{\"label\":\"1234567890123456789012345678901\"}"; // 31 chars
+ CHECK(mm::applyControlValue(controls[0], s31, "label", mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok);
+ CHECK(std::strlen(label) == 31);
+
+ const char s32[] = "{\"label\":\"12345678901234567890123456789012\"}"; // 32 chars → rejected
+ CHECK(mm::applyControlValue(controls[0], s32, "label", mm::ApplyPolicy::Clamp) == mm::ApplyResult::Malformed);
+ CHECK(std::strlen(label) == 31); // prior 31-char value preserved, not overwritten/truncated
+}
+
+TEST_CASE("a Text control with no validator accepts anything that fits") {
+ mm::ControlList controls;
+ char label[16] = {};
+ controls.addText("label", label, sizeof(label)); // no validator
+
+ CHECK(mm::applyControlValue(controls[0], "{\"label\":\"hi\"}",
+ "label", mm::ApplyPolicy::Clamp) == mm::ApplyResult::Ok);
+ CHECK(std::strcmp(label, "hi") == 0);
+}
diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp
index 2997f43..698902d 100644
--- a/test/unit/core/unit_HttpServerModule_apply.cpp
+++ b/test/unit/core/unit_HttpServerModule_apply.cpp
@@ -30,6 +30,26 @@ struct Box : public mm::MoonModule {
// accepts any child (the HTTP role gate lives above the apply-core).
};
+// A leaf with a VALIDATED Text control — mirrors SystemModule.deviceModel: the printable-
+// ASCII rule is a per-control validator, so a bad value is rejected on EVERY write path
+// (including the APPLY_OP `set` the installer uses), not in a bespoke per-transport RPC.
+struct Tag : public mm::MoonModule {
+ char label[32] = "init";
+ static bool printableAscii(const char* v) {
+ if (!v) return false;
+ size_t n = std::strlen(v);
+ if (n == 0 || n >= 32) return false;
+ for (size_t i = 0; i < n; i++) {
+ unsigned char b = static_cast(v[i]);
+ if (b < 0x20 || b > 0x7E) return false;
+ }
+ return true;
+ }
+ void onBuildControls() override {
+ controls_.addText("label", label, sizeof(label), printableAscii);
+ }
+};
+
// Build a tree: scheduler root "Root" (a Box) with HttpServerModule wired to it.
// Returns via out-params so each case starts clean. Caller owns teardown via the
// scheduler.
@@ -38,6 +58,7 @@ void registerTestTypes() {
if (done) return;
mm::ModuleFactory::registerType("Knob");
mm::ModuleFactory::registerType("Box");
+ mm::ModuleFactory::registerType("Tag");
done = true;
}
@@ -163,3 +184,45 @@ TEST_CASE("apply-core: applyOp dispatches each op type and tolerates bad input")
s.deleteTree(root);
}
+
+// A per-control validator (like SystemModule.deviceModel's printable-ASCII rule) is
+// enforced THROUGH the apply-core — so the APPLY_OP `set` the installer pushes over
+// serial is guarded exactly like an HTTP write, with no per-transport special-casing.
+// This is the point of moving validation onto the control: one backend check, every path.
+TEST_CASE("apply-core: a control validator rejects bad input on the set/APPLY_OP path") {
+ registerTestTypes();
+ mm::Scheduler s;
+ auto* root = new Box();
+ root->setName("Root");
+ s.addModule(root);
+ mm::HttpServerModule http;
+ http.setScheduler(&s);
+ using OpResult = mm::HttpServerModule::OpResult;
+
+ REQUIRE(http.applyAddModule("Tag", "T", "Root") == OpResult::Ok);
+ auto* tag = static_cast(childNamed(root, "T"));
+ REQUIRE(tag != nullptr);
+
+ // Valid value applies — via applySetControl (HTTP path) ...
+ CHECK(http.applySetControl("T", "label", "{\"value\":\"LOLIN D32\"}") == OpResult::Ok);
+ CHECK(std::strcmp(tag->label, "LOLIN D32") == 0);
+
+ // ... and via applyOp (the APPLY_OP-over-serial path) — same shape the installer sends.
+ CHECK(http.applyOp("{\"op\":\"set\",\"module\":\"T\",\"control\":\"label\",\"value\":\"Living Room\"}")
+ == OpResult::Ok);
+ CHECK(std::strcmp(tag->label, "Living Room") == 0);
+
+ // A raw control byte in the value → Malformed on the APPLY_OP path, prior value kept.
+ const char badOp[] = {'{','"','o','p','"',':','"','s','e','t','"',',',
+ '"','m','o','d','u','l','e','"',':','"','T','"',',',
+ '"','c','o','n','t','r','o','l','"',':','"','l','a','b','e','l','"',',',
+ '"','v','a','l','u','e','"',':','"','x', 0x01, '"','}', 0};
+ CHECK(http.applyOp(badOp) == OpResult::Malformed);
+ CHECK(std::strcmp(tag->label, "Living Room") == 0); // unchanged — no partial write
+
+ // Empty string → Malformed too (the validator rejects 0-length), prior value kept.
+ CHECK(http.applySetControl("T", "label", "{\"value\":\"\"}") == OpResult::Malformed);
+ CHECK(std::strcmp(tag->label, "Living Room") == 0);
+
+ s.deleteTree(root);
+}
diff --git a/test/unit/light/unit_GridLayout.cpp b/test/unit/light/unit_GridLayout.cpp
index f4f91a5..6918095 100644
--- a/test/unit/light/unit_GridLayout.cpp
+++ b/test/unit/light/unit_GridLayout.cpp
@@ -59,6 +59,40 @@ TEST_CASE("GridLayout 4x4x1 produces 16 coords in row-major order") {
CHECK(coords[15].z == 0);
}
+// Serpentine reverses x on odd rows (boustrophedon), so the strip snakes back and forth: driver
+// index advances linearly while the emitted x zigzags. Even rows L→R, odd rows R→L. The COORDINATE
+// is always the true (x,y) — only the index→position order changes, which is what makes the
+// mapping non-identity.
+TEST_CASE("GridLayout serpentine reverses x on odd rows") {
+ mm::GridLayout grid;
+ grid.width = 4;
+ grid.height = 3;
+ grid.depth = 1;
+ grid.serpentine = true;
+
+ std::vector coords;
+ grid.forEachCoord(collectCoord, &coords);
+ REQUIRE(coords.size() == 12);
+
+ // Row 0 (even): left→right, x = 0,1,2,3 at idx 0..3
+ CHECK(coords[0].x == 0); CHECK(coords[0].y == 0);
+ CHECK(coords[3].x == 3); CHECK(coords[3].y == 0);
+ // Row 1 (odd): right→left, x = 3,2,1,0 at idx 4..7 — the serpentine turn
+ CHECK(coords[4].idx == 4); CHECK(coords[4].x == 3); CHECK(coords[4].y == 1);
+ CHECK(coords[5].x == 2); CHECK(coords[5].y == 1);
+ CHECK(coords[7].x == 0); CHECK(coords[7].y == 1);
+ // Row 2 (even again): left→right, x = 0,1,2,3 at idx 8..11
+ CHECK(coords[8].x == 0); CHECK(coords[8].y == 2);
+ CHECK(coords[11].x == 3); CHECK(coords[11].y == 2);
+
+ // Non-serpentine is unchanged: index i lands at natural box order.
+ grid.serpentine = false;
+ coords.clear();
+ grid.forEachCoord(collectCoord, &coords);
+ CHECK(coords[4].x == 0); // row 1 starts at x=0 again
+ CHECK(coords[5].x == 1);
+}
+
// A 3D 2×2×2 grid yields 8 lights with z-plane separation (indices 0-3 at z=0, 4-7 at z=1).
TEST_CASE("GridLayout 2x2x2 produces 8 coords with z") {
mm::GridLayout grid;
diff --git a/test/unit/light/unit_Layer_sparse_mapping.cpp b/test/unit/light/unit_Layer_sparse_mapping.cpp
index 5fc16d5..f2249fb 100644
--- a/test/unit/light/unit_Layer_sparse_mapping.cpp
+++ b/test/unit/light/unit_Layer_sparse_mapping.cpp
@@ -47,6 +47,39 @@ TEST_CASE("Layer: dense grid stays on the identity path (no LUT)") {
CHECK(rig.layer.buffer().count() == 64); // render buffer == box == lights
}
+// Serpentine grid: dense (every box cell is a light, so the count check alone would pick the
+// identity fast path) but SHUFFLED (driver index i != box cell i). isNaturalOrder() measures that
+// from the coords and routes it through the box->driver LUT instead. This is the lever for
+// exercising the non-identity mapping path without a sparse layout or a modifier.
+TEST_CASE("Layer: serpentine grid leaves the identity path and builds a LUT") {
+ mm::GridLayout g;
+ g.width = 4; g.height = 4; g.depth = 1; // 16 lights, dense
+ g.serpentine = true;
+ LayerRig rig(&g);
+
+ CHECK(rig.layer.lut().hasLUT()); // dense-but-shuffled → a real LUT, not memcpy
+ CHECK(rig.layer.physicalLightCount() == 16);
+ CHECK(rig.layer.buffer().count() == 16); // render buffer still the dense box
+
+ // The LUT maps box cell -> driver index. Row 0 (even) is natural: box 0 -> driver 0.
+ // Row 1 (odd) is reversed: box cell (x=0,y=1) = box 4 should map to driver 7 (the strip
+ // enters that row from the high-x end), and box (x=3,y=1) = box 7 -> driver 4.
+ const mm::MappingLUT& lut = rig.layer.lut();
+ auto driverOf = [&](mm::nrOfLightsType box) {
+ mm::nrOfLightsType d = 0xFFFF;
+ lut.forEachDestination(box, [&](mm::nrOfLightsType dst) { d = dst; });
+ return d;
+ };
+ CHECK(driverOf(0) == 0); // row 0, x=0 — natural
+ CHECK(driverOf(4) == 7); // row 1, x=0 — reversed: last of the row's driver indices
+ CHECK(driverOf(7) == 4); // row 1, x=3 — reversed: first
+
+ // Flipping serpentine off returns it to the identity fast path (no LUT).
+ g.serpentine = false;
+ rig.layer.onBuildState();
+ CHECK_FALSE(rig.layer.lut().hasLUT());
+}
+
// Sparse sphere: a LUT is built; its destinations are driver indices in
// [0, lightCount), and the render buffer stays the dense bounding box.
TEST_CASE("Layer: sparse sphere builds a box->driver LUT, no out-of-range index") {
diff --git a/test/unit/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp
index 5bfd676..8efdfff 100644
--- a/test/unit/light/unit_PreviewDriver.cpp
+++ b/test/unit/light/unit_PreviewDriver.cpp
@@ -21,23 +21,46 @@
namespace {
-// Captures the two preview message types so tests can inspect them.
+// Captures the two preview message types so tests can inspect them. PreviewDriver STREAMS each
+// frame via begin/push/end (no frame buffer); the mock reassembles the pushed slices, strips the
+// WS header (begin is given the PAYLOAD length, so what's pushed is exactly the payload), and
+// classifies by first byte at end. dropCoord/acceptNext make endBinaryFrame report a client that
+// didn't get the whole frame (false) to drive the coord-pending retry + adaptive-downscale paths.
struct CaptureBroadcaster : mm::BinaryBroadcaster {
int coordMsgs = 0, frameMsgs = 0;
std::vector lastCoord, lastFrame;
+ std::vector cur_; // payload accumulated across pushBinaryFrame between begin/end
+ uint32_t generation = 0; // bump to simulate a new client connecting
+ bool acceptNext = true; // false → endBinaryFrame reports a colour frame not fully sent
+ bool dropCoord = false; // true → endBinaryFrame reports a coord table not fully sent
- void broadcastBinary(const mm::platform::WriteChunk* payload, int chunkCount) override {
- std::vector buf;
- for (int i = 0; i < chunkCount; i++)
- buf.insert(buf.end(), payload[i].data, payload[i].data + payload[i].len);
- if (buf.empty()) return;
- if (buf[0] == 0x03) { coordMsgs++; lastCoord = buf; }
- else if (buf[0] == 0x02) { frameMsgs++; lastFrame = buf; }
+ void beginBinaryFrame(size_t /*totalLen*/) override { cur_.clear(); }
+ void pushBinaryFrame(const uint8_t* data, size_t len) override {
+ cur_.insert(cur_.end(), data, data + len);
}
+ bool endBinaryFrame() override {
+ if (cur_.empty()) return false;
+ const uint8_t type = cur_[0];
+ if (type == 0x03) {
+ if (dropCoord) return false; // simulate the table not reaching the client
+ coordMsgs++; lastCoord = cur_; return true;
+ }
+ if (type == 0x02) {
+ if (!acceptNext) return false; // simulate the colour frame not reaching the client
+ frameMsgs++; lastFrame = cur_; return true;
+ }
+ return true;
+ }
+ uint32_t clientGeneration() const override { return generation; }
- int coordCount() const { return lastCoord.size() >= 3 ? lastCoord[1] | (lastCoord[2] << 8) : -1; }
- int frameCount() const { return lastFrame.size() >= 3 ? lastFrame[1] | (lastFrame[2] << 8) : -1; }
- int coordStride() const { return lastCoord.size() >= 8 ? lastCoord[6] | (lastCoord[7] << 8) : -1; }
+ // 0x03 = [type][count:u32][bx][by][bz][stride:u16] (10-byte header)
+ // 0x02 = [type][count:u32][stride:u16] (7-byte header)
+ static uint32_t u32le(const std::vector& b, size_t o) {
+ return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (static_cast(b[o + 3]) << 24);
+ }
+ int coordCount() const { return lastCoord.size() >= 5 ? static_cast(u32le(lastCoord, 1)) : -1; }
+ int frameCount() const { return lastFrame.size() >= 5 ? static_cast(u32le(lastFrame, 1)) : -1; }
+ int coordStride() const { return lastCoord.size() >= 10 ? lastCoord[8] | (lastCoord[9] << 8) : -1; }
};
// Wire PreviewDriver under Drivers, over a Layer + single layout, with a
@@ -82,8 +105,8 @@ TEST_CASE("PreviewDriver coordinate table carries the real lights, not the box")
REQUIRE(rig.cap.coordMsgs > 0);
CHECK(rig.cap.coordCount() == 210); // the shell, not 729
CHECK(rig.cap.coordStride() == 1); // small → exact, no downsample
- // 0x03 = [0x03][count:u16][bx][by][bz][stride:u16] + count*3 position bytes
- CHECK(rig.cap.lastCoord.size() == 8u + 210u * 3u);
+ // 0x03 = [0x03][count:u32][bx][by][bz][stride:u16] (10-byte hdr) + count*3 position bytes
+ CHECK(rig.cap.lastCoord.size() == 10u + 210u * 3u);
}
// Per-frame 0x02 RGB count matches the coordinate-table count.
@@ -95,8 +118,8 @@ TEST_CASE("PreviewDriver per-frame RGB count matches the coordinate table") {
REQUIRE(rig.cap.frameMsgs > 0);
CHECK(rig.cap.frameCount() == 210);
- // 0x02 = [0x02][count:u16][stride:u16] + count*3 RGB bytes
- CHECK(rig.cap.lastFrame.size() == 5u + 210u * 3u);
+ // 0x02 = [0x02][count:u32][stride:u16] (7-byte hdr) + count*3 RGB bytes
+ CHECK(rig.cap.lastFrame.size() == 7u + 210u * 3u);
}
// A small grid sends every light at its grid position (stride 1, exact).
@@ -111,17 +134,56 @@ TEST_CASE("PreviewDriver small grid sends all lights exactly") {
CHECK(rig.cap.coordStride() == 1);
}
-// A large layout is index-downsampled (stride > 1) so the payload fits the
-// send-buffer cap — but at REAL positions, not a padded box.
-TEST_CASE("PreviewDriver downsamples a large layout via index stride") {
+// A large layout is SPATIALLY downsampled (a regular per-axis lattice, not every-Nth-flat-
+// index) so the payload fits the send-buffer cap without the diagonal moiré that linear
+// stride produced on a grid whose width didn't divide the stride. The wire "stride" field
+// carries the per-axis lattice/downscale factor (colour k still maps 1:1 to coord k).
+TEST_CASE("PreviewDriver downsamples a large layout on a regular spatial lattice") {
mm::GridLayout g;
- g.width = 128; g.height = 128; g.depth = 1; // 16384 lights > 1800-point cap
+ g.width = 512; g.height = 512; g.depth = 1; // 262144 lights > the desktop/PSRAM 131072 cap
PreviewRig rig(&g);
rig.produce();
- CHECK(rig.cap.coordStride() > 1); // strided
- CHECK(rig.cap.coordCount() <= 1800); // fits the send-buffer cap
- CHECK(rig.cap.coordCount() == rig.cap.frameCount()); // table + RGB agree
+ CHECK(rig.cap.coordStride() >= 2); // RAM cap forces a lattice step (the factor)
+ CHECK(rig.cap.coordCount() <= 131072); // downsampled to the desktop (PSRAM-tier) cap
+ CHECK(rig.cap.coordCount() > 0);
+ CHECK(rig.cap.coordCount() == rig.cap.frameCount()); // table + RGB agree (lockstep)
+
+ // Regular lattice check: every sent X coordinate is a multiple of the same step, and so
+ // is every Y — i.e. the kept points sit on a grid, with NO per-row column drift (the
+ // diagonal-streak bug). Read the packed u8 positions back from the coord message.
+ const auto& cd = rig.cap.lastCoord;
+ const int hdr = 10; // [0x03][count:u32][bx][by][bz][stride:u16]
+ REQUIRE(cd.size() >= static_cast(hdr + 3));
+ // Derive the X step from the first two distinct X values, then assert all X are multiples.
+ int stepX = 0, x0 = cd[hdr];
+ for (size_t p = hdr; p + 2 < cd.size(); p += 3) {
+ int dx = cd[p] - x0;
+ if (dx != 0) { stepX = dx > 0 ? dx : -dx; break; }
+ }
+ REQUIRE(stepX > 0);
+ bool regular = true;
+ for (size_t p = hdr; p + 2 < cd.size(); p += 3) {
+ if (((cd[p] - x0) % stepX) != 0) { regular = false; break; } // X off the lattice → drift
+ }
+ CHECK(regular); // no diagonal moiré
+}
+
+// A SPARSE layout with a huge bounding box but a light count UNDER the cap must NOT be
+// downsampled: the lattice bound is the bounding-box cell count, so naively growing the stride
+// until that fits the cap would prematurely strip a sparse layout (which already fits). A
+// radius-64 sphere has a 129³≈2.1M-cell box but only a ~51K-light shell (< the 131072 cap), so
+// it must send every light at full resolution (stride 1).
+TEST_CASE("PreviewDriver keeps a sparse large-box layout at full resolution") {
+ mm::SphereLayout s;
+ s.radius = 64; // huge box, shell light-count well under cap
+ PreviewRig rig(&s);
+ rig.produce();
+
+ CHECK(rig.cap.coordCount() > 0);
+ CHECK(rig.cap.coordCount() < 131072); // the shell fits the cap...
+ CHECK(rig.cap.coordStride() == 1); // ...so it is sent whole, not downsampled
+ CHECK(rig.cap.coordCount() == rig.cap.frameCount());
}
// Default fps is the rate-limited preview stream rate.
@@ -130,6 +192,38 @@ TEST_CASE("PreviewDriver fps default") {
CHECK(driver.fps == 24);
}
+// Regression: a coordinate table dropped under backpressure must be RETRIED, and colour
+// frames withheld until it lands — otherwise the device sends 0x02 frames the browser skips
+// (count mismatch) and the preview freezes for the whole session. Drives loop() (where the
+// coord-pending logic lives) with a broadcaster that drops every 0x03, then lets it through.
+TEST_CASE("PreviewDriver retries a dropped coordinate table, withholds frames until it lands") {
+ mm::GridLayout g; g.width = 16; g.height = 16; g.depth = 1; // 256 lights, full res
+ PreviewRig rig(&g);
+ rig.cap.dropCoord = true; // every coord table is lost to backpressure
+ rig.cap.frameMsgs = 0; // ignore any frame from rig construction
+ rig.cap.generation = 1; // a "new client" — forces loop() to rebuild+resend
+ // the coord table, which dropCoord now loses
+
+ // Advance the test clock past the fps gate (interval = 1000/24 ≈ 42 ms) before each loop().
+ uint32_t t = 1000;
+ auto tick = [&] { t += 100; mm::platform::setTestNowMs(t); rig.preview->loop(); };
+
+ // Pump loop() several times. The rebuilt 0x03 never lands, so NO colour frame may go out —
+ // a 0x02 now would carry a count the browser can't map (the freeze the guard prevents).
+ for (int i = 0; i < 5; i++) tick();
+ CHECK(rig.cap.frameMsgs == 0); // frames withheld while the table is pending
+
+ // Link recovers: the table now lands, and frames resume — matching the same count.
+ rig.cap.dropCoord = false;
+ tick(); // retries the pending table (it lands)
+ tick(); // now a colour frame may go out
+ CHECK(rig.cap.coordMsgs > 0); // the table finally reached the client
+ CHECK(rig.cap.frameMsgs > 0); // frames resumed
+ CHECK(rig.cap.coordCount() == rig.cap.frameCount()); // and they agree (no freeze)
+
+ mm::platform::setTestNowMs(0); // release the clock override
+}
+
// Regression: deleting the active Layer must not leave a driver holding a
// dangling layer_ pointer. Previously Drivers::passBufferToDrivers early-returned
// when the active Layer was null, leaving PreviewDriver's layer_ pointing at the
@@ -174,3 +268,33 @@ TEST_CASE("PreviewDriver tolerates the active Layer being deleted") {
preview->sendFrame();
CHECK(cap.frameMsgs == 0); // nothing to send with no layer
}
+
+// Coordinates are sent ONLY when the geometry changes or a new client connects — never
+// per-frame and never on a timer (a periodic full-table rebuild would starve the tick).
+// A new client (clientGeneration bump) re-sends immediately so a page refresh shows the
+// preview at once. Driven through loop() with a frozen clock for determinism.
+TEST_CASE("PreviewDriver sends coordinates only on change / new client, never on a timer") {
+ mm::platform::setTestNowMs(100000);
+ PreviewRig rig(new mm::GridLayout(), 3);
+
+ rig.preview->loop(); // first loop: coords sent (count was 0)
+ int afterFirst = rig.cap.coordMsgs;
+ CHECK(afterFirst >= 1);
+
+ // Advance a FULL 3 seconds with no new client and no rebuild: loop() keeps sending
+ // colour frames but must NOT re-send the coordinate table. This is the regression
+ // guard — the removed ~1 Hz timer would have re-sent ~3 times here.
+ for (int t = 1; t <= 3; t++) {
+ mm::platform::setTestNowMs(100000 + t * 1000);
+ rig.preview->loop();
+ }
+ CHECK(rig.cap.coordMsgs == afterFirst); // no timer-driven re-send across 3s
+
+ // A new client connects (generation bumps). The next loop() re-sends coords at once.
+ rig.cap.generation++;
+ mm::platform::setTestNowMs(104200);
+ rig.preview->loop();
+ CHECK(rig.cap.coordMsgs == afterFirst + 1); // re-sent for the fresh client
+
+ mm::platform::setTestNowMs(0); // restore the real clock for other tests
+}