From 41303e48d55449ff9848b997469df21a3ce87b87 Mon Sep 17 00:00:00 2001
From: ewowi
Date: Mon, 22 Jun 2026 09:39:19 +0200
Subject: [PATCH 01/10] Validate controls in the backend; drop the
SET_DEVICE_MODEL Improv RPC
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
deviceModel is now set like any other catalog default — an APPLY_OP `set System.deviceModel` op (installer over serial) or POST /api/control (MoonDeck) — instead of a bespoke SET_DEVICE_MODEL Improv RPC. Its printable-ASCII rule moves onto the control as a per-control validator on ControlDescriptor, so every write path (HTTP, serial APPLY_OP, persistence load) runs the same check in one backend place. This is architecturally cleaner — any control that needs input rules declares them on itself — and it removes a whole vendor RPC + its device-side handler, buffers, and JS frame builder. SET_TX_POWER stays a dedicated RPC because it genuinely must land before the radio associates; deviceModel has no such ordering constraint, so it rides the generic transport (like deviceName already does).
KPI: 16384lights | PC:365KB | tick:112/87/111/9/1/313/37/15/18/111/11us(FPS:8928/11494/9009/111111/1000000/3194/27027/66666/55555/9009/90909) | src:97(19660) | test:68(10232) | lizard:76w
(No ESP32 tick line: this change touches only Improv provisioning + the validator hook + boot wiring — no render-path code — so the device tick is unchanged from the last committed ESP32 value, tick:5155us(FPS:193). ESP32 esp32 + esp32p4-eth both build clean under -Werror.)
Core:
- Control: ControlDescriptor gains an optional per-control validator (`bool (*validate)(const char*)`, Text/Password only). applyControlValue runs it on the incoming value BEFORE the write and returns Malformed on reject (prior value preserved, no partial write), so the check covers HTTP, APPLY_OP-over-serial, and persistence load in one place. addText takes an optional validator arg.
- SystemModule: deviceModel's printable-ASCII rule (1..31, 0x20-0x7E, no NUL) is now that validator (validateDeviceModel), declared on the control via addText. Removed setDeviceModel().
- ImprovProvisioningModule: dropped the SET_DEVICE_MODEL pending buffer + per-tick poll + the deviceModelOut init args; deviceModel arrives via the APPLY_OP poll like any other control. systemModule wiring stays (GET_DEVICE_INFO device name).
Platform:
- platform_esp32_improv.cpp: removed the SET_DEVICE_MODEL (0xFE) handler, its dispatch case, and the deviceModelOut/deviceModelReady g_improv fields + init args.
- platform.h + platform_desktop.cpp: improvProvisioningInit drops the deviceModelOut/deviceModelOutLen/deviceModelReady params.
- main.cpp: comment-only — deviceModel injection now goes through the apply-core + validator, not a dedicated RPC.
UI:
- improv-frame.js: removed the IMPROV_CMD_SET_DEVICE_MODEL export.
- install-orchestrator.js: removed encodeSetDeviceModelPayload + sendSetBoardFrame; pushDefaultsOverSerial sends only APPLY_OP ops now (the deviceModel name is one of the catalog `set` ops). The TX-power frame still precedes provisioning.
Tests:
- unit_Control_apply_absent_key: a per-control validator accepts valid input, rejects a raw control byte / empty value (Malformed), preserves the prior value on reject; a no-validator Text control accepts anything that fits.
- unit_HttpServerModule_apply: a validated Text control (Tag) is enforced THROUGH the apply-core — valid value applies via both applySetControl (HTTP) and applyOp (APPLY_OP-over-serial), bad bytes / empty rejected with the prior value kept. Proves the serial path is guarded with no per-transport special-casing.
Docs / CI:
- CLAUDE.md: extended "Present tense only" to ban absence-narration ("no longer", "anymore", "formerly", "X was removed") — state what is, not what stopped being; decisions.md lessons exempt (the before/after contrast is the lesson).
- ImprovProvisioningModule.md + SystemModule.md: deviceModel arrives via APPLY_OP set + the per-control validator; removed the SET_DEVICE_MODEL RPC from the wire contract.
- install/README.md, index.html, landing/index.html: APPLY_OP-only wording; landing page links to the getting-started guide; installer credit line drops the inaccurate "secure handshake" phrasing.
- backlog: removed the per-control-validator-hook item (shipped here).
Co-Authored-By: Claude Opus 4.8
---
CLAUDE.md | 2 +-
docs/backlog/backlog.md | 2 -
docs/install/README.md | 2 +-
docs/install/improv-frame.js | 5 -
docs/install/index.html | 8 +-
docs/install/install-orchestrator.js | 94 ++++------------
docs/landing/index.html | 3 +
.../core/ImprovProvisioningModule.md | 7 +-
docs/moonmodules/core/SystemModule.md | 2 +-
src/core/Control.cpp | 12 +++
src/core/Control.h | 14 ++-
src/core/ImprovProvisioningModule.h | 19 +---
src/core/SystemModule.h | 55 ++++------
src/main.cpp | 13 ++-
src/platform/desktop/platform_desktop.cpp | 2 -
src/platform/esp32/platform_esp32_improv.cpp | 101 +-----------------
src/platform/platform.h | 19 ++--
.../core/unit_Control_apply_absent_key.cpp | 51 +++++++++
.../unit/core/unit_HttpServerModule_apply.cpp | 63 +++++++++++
19 files changed, 213 insertions(+), 261 deletions(-)
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/backlog/backlog.md b/docs/backlog/backlog.md
index 30c9cfa..e260d24 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.
diff --git a/docs/install/README.md b/docs/install/README.md
index e581939..fb3fc5d 100644
--- a/docs/install/README.md
+++ b/docs/install/README.md
@@ -7,7 +7,7 @@ This directory holds the source for the **custom installer page** (driven by
End users land here, pick a channel + device, click Install. The browser flashes
the device over USB (Web Serial → ESP32), runs Improv-Serial provisioning, then
pushes the picked device-model's whole config over the **same serial port** as REST
-operations (**"Improv = REST over serial"**: SET_DEVICE_MODEL + APPLY_OP) — all from
+operations (**"Improv = REST over serial"**: `APPLY_OP` frames, one per control/module) — all from
the same orchestrator, no ESP Web Tools dependency. Pushing over serial (not HTTP)
is what makes the deployed HTTPS installer work: a browser blocks an HTTPS page from
POSTing to a plain-`http://` device (mixed-content), so the old HTTP fan-out + the
diff --git a/docs/install/improv-frame.js b/docs/install/improv-frame.js
index 409ca0d..9051df8 100644
--- a/docs/install/improv-frame.js
+++ b/docs/install/improv-frame.js
@@ -12,11 +12,6 @@
// [I][M][P][R][O][V][version=1][type][length][payload×length][checksum]
// checksum = sum-mod-256 of the first 9+length bytes.
-// SET_DEVICE_MODEL vendor RPC command ID. High end of the conventional 0x80-0xFE
-// vendor extension range. Matches the device-side handler at
-// src/platform/esp32/platform_esp32_improv.cpp.
-export const IMPROV_CMD_SET_DEVICE_MODEL = 0xFE;
-
// SET_TX_POWER vendor RPC command ID — the pre-association TX-power cap for boards
// whose LDO browns out at full power. Sent BEFORE provisioning so the very first
// association runs capped. Matches the device-side handler.
diff --git a/docs/install/index.html b/docs/install/index.html
index fc13db4..65a70fc 100644
--- a/docs/install/index.html
+++ b/docs/install/index.html
@@ -692,7 +692,7 @@ projectMM Installer improv-wifi-serial-sdk
(WiFi provisioning), and the
Improv-Serial protocol
- for the secure handshake.
+ for provisioning and pushing device defaults over USB.
@@ -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 @@ projectMM
⚡ Flash an ESP32 from your browser
+ New here? Read the step-by-step getting-started guide →
+
diff --git a/docs/moonmodules/core/ImprovProvisioningModule.md b/docs/moonmodules/core/ImprovProvisioningModule.md
index bf126b9..0487065 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.
@@ -26,11 +26,10 @@ Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV
- `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 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.)
-**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*`). 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_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.
`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..32ea5b5 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. (Was its own `BoardModule` child until folded into System.)
**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/src/core/Control.cpp b/src/core/Control.cpp
index 70ef1d8..11a8a13 100644
--- a/src/core/Control.cpp
+++ b/src/core/Control.cpp
@@ -267,6 +267,18 @@ 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).
+ if (c.validate) {
+ char scratch[64];
+ mm::json::parseString(json, key, scratch, sizeof(scratch) < maxLen ? sizeof(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/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/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..9c790c9 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -564,8 +564,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*/,
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..a666e79 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -245,31 +245,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,
diff --git a/test/unit/core/unit_Control_apply_absent_key.cpp b/test/unit/core/unit_Control_apply_absent_key.cpp
index 187b3ff..f7f418f 100644
--- a/test/unit/core/unit_Control_apply_absent_key.cpp
+++ b/test/unit/core/unit_Control_apply_absent_key.cpp
@@ -99,3 +99,54 @@ 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);
+}
+
+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..a44261a 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\":\"Olimex Gateway\"}")
+ == OpResult::Ok);
+ CHECK(std::strcmp(tag->label, "Olimex Gateway") == 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, "Olimex Gateway") == 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, "Olimex Gateway") == 0);
+
+ s.deleteTree(root);
+}
From 37be15686370207f1d61c71d34a73d0c585e7775 Mon Sep 17 00:00:00 2001
From: ewowi
Date: Mon, 22 Jun 2026 11:42:29 +0200
Subject: [PATCH 02/10] Responsive preview (docked split / floating PiP) + PR
#24 review fixes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reworks the UI layout so the 3D preview no longer crowds the module cards, and processes the CodeRabbit review of the per-control-validator commit. On a wide screen the preview and cards sit side by side; on a narrow screen (or when popped out) the preview becomes a small draggable picture-in-picture and the cards take the full width, so configuration always has room.
KPI: 16384lights | PC:365KB | tick:111/88/112/9/1/310/36/15/18/111/11us(FPS:9009/11363/8928/111111/1000000/3225/27777/66666/55555/9009/90909) | ESP32:tick:3661us(FPS:273) | heap:8329KB | src:97(19663) | test:68(10251) | lizard:76w
(ESP32 tick captured live from the S3 running this firmware. The preview rework is front-end only; the device tick reflects the testbench render config, unchanged by it.)
UI:
- index.html / style.css: .content's main area is a .workspace with two modes. .mode-docked (wide ≥960px) lays the preview pane beside a fixed 480px card column that scrolls on its own — the preview is sticky and stable, replacing the old scroll-shrink stack that ate vertical space above the cards. .mode-pip (narrow, or popped out) detaches the preview into a fixed, draggable, corner-snapping picture-in-picture (a drag bar with dock/close), cards full-width; a re-show pill brings a dismissed PiP back.
- preview3d.js: setupShrink → setupLayout — width-driven mode toggle (matchMedia + rAF-throttled resize), pointer-drag with viewport clamp + corner snap, dock/close/show wiring, localStorage-persisted corner/dismissed/forcePip. The single WebGL canvas is restyled in place across modes (never duplicated), and the drag handle is a separate element so it doesn't fight the camera-orbit pointer handler. The existing resize re-fit keeps the canvas correct through dock↔PiP.
- app.js: setupShrink() → setupLayout() call site.
Core:
- Control.cpp: the validator scratch buffer is sized to the control's full buffer (maxLen ≤ 255), not a fixed 64, so a long-but-valid Text value reaches the validator intact rather than being silently truncated first. (🐇 CodeRabbit)
Tests:
- unit_Control_apply_absent_key: added the validator length boundary — a 31-char value is accepted, a 32-char value is rejected (Malformed) with the prior value preserved, using a 64-byte buffer so the validator's own length check (not parse truncation) is what rejects. (🐇 CodeRabbit)
Docs / CI:
- ImprovProvisioningModule.md: APPLY_OP pacing is open-loop (a fixed inter-op delay, not an ack read-back) with idempotent ops; corrected the inaccurate "awaits each ack". (🐇 CodeRabbit)
- SystemModule.md: dropped the trailing "Was its own BoardModule" history (present-tense rule). (🐇 CodeRabbit)
- docs/history/plans: saved the responsive-preview plan.
Reviews:
- 🐇 scratch buffer truncation (Control.cpp) — fixed.
- 🐇 "awaits each ack" inaccuracy (ImprovProvisioningModule.md) — fixed (open-loop pacing).
- 🐇 BoardModule history narration (SystemModule.md) — fixed (removed).
- 🐇 validator length-boundary test gap — fixed (31-accept / 32-reject).
- 🐇 validate pointer on ControlDescriptor (>16B) — accepted: the descriptor is already ~28B on ESP32 (int32 min/max + flags, predating the 16B target); a validator side-table to reclaim 4 bytes on a fixed-capacity array is a bespoke lookup that fails Common-patterns-first. The pointer-on-descriptor is the recognizable, minimal choice.
Co-Authored-By: Claude Opus 4.8
---
...e split-pane preview with draggable PiP.md | 35 +++++
.../core/ImprovProvisioningModule.md | 2 +-
docs/moonmodules/core/SystemModule.md | 2 +-
src/core/Control.cpp | 7 +-
src/ui/app.js | 2 +-
src/ui/index.html | 24 ++-
src/ui/preview3d.js | 142 +++++++++++++++---
src/ui/style.css | 118 ++++++++++++---
.../light/scenario_Driver_mutation.json | 4 +-
.../core/unit_Control_apply_absent_key.cpp | 19 +++
10 files changed, 302 insertions(+), 53 deletions(-)
create mode 100644 docs/history/plans/Plan-20260622 - Responsive split-pane preview with draggable PiP.md
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 `` stays one element — it's *reparented* (or its wrapper is restyled) between modes, never duplicated (one WebGL context).
+- **`src/ui/style.css`** — `.content` row layout; `.preview-pane` (sticky, flex:1) + `#main` (fixed 480px, `overflow-y:auto`, `height: calc(100vh - 44px)`) for docked. `.mode-pip` rules: preview becomes `position:fixed`, small, draggable; `#main` goes full-width. The `<820px` block + a new `~960px` breakpoint drive the auto-switch. Remove `.preview-wrap` sticky-scroll styling + the `max-height:50vh`.
+- **`src/ui/preview3d.js`** — replace `setupShrink` (scroll-shrink) with `setupLayout`: the mode toggle (matchMedia/ResizeObserver) + PiP drag/snap + dismiss/re-show. Keep the existing `resize` re-fit. Drag = pointer events, clamp to viewport, snap to nearest corner on release; persist PiP corner + dismissed state in localStorage (hostile-storage guarded, like the other UI prefs).
+- **`src/ui/app.js`** — `setupShrink()` call site → `setupLayout()`.
+
+## Verification
+
+- `node --check` the JS; manual responsive sweep: wide (docked split), drag-narrow (auto-pops to PiP, canvas re-fits), drag-back-wide (re-docks), PiP drag + corner-snap, ×-dismiss + re-show, mobile (<820px nav drawer still works with PiP). On a real device (S3 UI) at phone width.
+- No backend change → no ctest/scenario/ESP32 impact; the commit gates that fire are spec (none — no control names change) + the build only if `src/ui` compiles into the binary (it's embedded via `embed_ui.cmake`, so a desktop build confirms the embed).
+- Confirm the preview still renders binary frames in both modes (one canvas, one WebGL context throughout).
+
+## Risks / notes
+
+- **One WebGL context**: the canvas must never be duplicated — reparent or restyle in place, or the context is lost. Test the dock↔PiP transition keeps rendering.
+- **Drag vs. orbit**: the PiP's drag handle must be a separate element from the canvas, or dragging the window fights the camera-orbit pointer handler (preview3d.js owns `touch-action:none` on the canvas).
+- **Cards column height**: `height: calc(100vh - 44px)` with its own scroll means the page itself no longer scrolls in docked mode — verify the status bar + nav still behave.
+- Pure front-end, UI-only; no protocol/control/spec change.
diff --git a/docs/moonmodules/core/ImprovProvisioningModule.md b/docs/moonmodules/core/ImprovProvisioningModule.md
index 0487065..43592d7 100644
--- a/docs/moonmodules/core/ImprovProvisioningModule.md
+++ b/docs/moonmodules/core/ImprovProvisioningModule.md
@@ -27,7 +27,7 @@ Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV
- `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_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 — **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 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 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 (`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. (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.)
**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_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.
diff --git a/docs/moonmodules/core/SystemModule.md b/docs/moonmodules/core/SystemModule.md
index 32ea5b5..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 — 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. (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/src/core/Control.cpp b/src/core/Control.cpp
index 11a8a13..1cbea20 100644
--- a/src/core/Control.cpp
+++ b/src/core/Control.cpp
@@ -271,9 +271,12 @@ ApplyResult applyControlValue(const ControlDescriptor& c,
// 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[64];
- mm::json::parseString(json, key, scratch, sizeof(scratch) < maxLen ? sizeof(scratch) : maxLen);
+ 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;
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..fa3ef21 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -23,11 +23,27 @@
-
-
-
⌖
+
+
+
+
+
+ ⠿
+
+ ⤢
+ ✕
+
+
+
⌖
+
+
-
+
+
◳ preview
diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js
index 47292ff..97607e0 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
@@ -158,30 +158,126 @@ 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});
+
+ // 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 PiP; reveal the re-show pill.
+ document.getElementById("preview-close")?.addEventListener("click", () => {
+ dismissed = true;
+ savePrefs({ corner, dismissed, forcePip });
+ applyMode();
+ });
+ document.getElementById("preview-show")?.addEventListener("click", () => {
+ dismissed = false;
+ savePrefs({ corner, dismissed, forcePip });
+ applyMode();
+ });
+
+ // 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.
@@ -369,10 +465,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..11b853b 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -212,46 +212,80 @@ 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;
}
+/* 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-bar-spacer { flex: 1; }
+.preview-bar-btn {
+ background: transparent;
+ border: none;
+ color: var(--fg-muted);
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 1;
+ padding: 2px 4px;
+ border-radius: 4px;
+ opacity: 0.6;
+}
+.preview-bar-btn:hover { opacity: 1; color: var(--accent); }
+
#preview-reset {
position: absolute;
- top: 16px;
+ top: 30px;
right: 8px;
background: var(--bg-1);
border: 1px solid var(--border);
@@ -270,6 +304,52 @@ body {
}
#preview-reset:hover { opacity: 1; color: var(--accent); border-color: var(--accent); }
+/* ---- 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_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json
index c1514e0..831bdf2 100644
--- a/test/scenarios/light/scenario_Driver_mutation.json
+++ b/test/scenarios/light/scenario_Driver_mutation.json
@@ -155,7 +155,7 @@
"pc-macos": {
"tick_us": [
8,
- 10
+ 11
],
"free_heap": [
0,
@@ -167,7 +167,7 @@
],
"at": [
"2026-06-13",
- "2026-06-16"
+ "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 f7f418f..9ee9bf0 100644
--- a/test/unit/core/unit_Control_apply_absent_key.cpp
+++ b/test/unit/core/unit_Control_apply_absent_key.cpp
@@ -141,6 +141,25 @@ TEST_CASE("a per-control validator accepts a valid value and rejects bad input")
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] = {};
From 0c9fbd3c17d480e2a83074e896eb4df4e58e325e Mon Sep 17 00:00:00 2001
From: ewowi
Date: Mon, 22 Jun 2026 18:07:14 +0200
Subject: [PATCH 03/10] Fix mDNS refresh crash; sharper preview; MoonDeck
inject-defaults button
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Fixes a crash where a UI refresh could reboot the device, sharpens the 3D preview rendering (crisp LEDs, off-LEDs now visible, a bounding box), and adds an explicit "inject defaults" button to MoonDeck. The preview no longer holds the device's mDNS browse handle across ticks, which was the crash; discovery still works via a throttled synchronous query.
KPI: 16384lights | PC:365KB | tick:118/90/134/9/2/379/45/24/22/124/15us(FPS:8474/11111/7462/111111/500000/2638/22222/41666/45454/8064/66666) | ESP32(S3,REST):tick:3215us(FPS:311) | src:97(19677) | test:68(10283) | lizard:76w
(ESP32 tick read from the live S3's REST, not the serial KPI capture — the S3's USB serial dropped mid-session; the device stayed up on the network, uptime 4135s, confirming the mDNS fix holds. P4 also stable: 2452us/407fps, uptime 2715s.)
Core:
- platform_esp32.cpp + platform.h: replaced the async mDNS browse (mdnsBrowseStart/Poll/Stop — held a query handle across ticks and polled it) with one synchronous mdnsBrowse(service, proto, timeoutMs, cb). The async handle's internal queue is owned + freed by the mDNS component's own task when the query window expires; a poll landing after that expiry asserted on a null queue (xQueueSemaphoreTake queue.c:1709), rebooting the device — intermittently, on a UI refresh, grid-size-sensitive (a longer tick widened the window). A self-contained synchronous call holds no handle across ticks, so the race can't exist. mDNS advertising (.local) is untouched — only the peer-discovery browse changed.
- DevicesModule: stepMdns calls the synchronous mdnsBrowse, throttled to one ~60ms query every ~8th tick (mdns_query_ptr blocks its full timeout and loop1s is charged to the tick, so an unthrottled per-tick query would tank FPS; one brief hiccup every ~8s is invisible for discovery). Removed the mdnsQuerying_ state.
- BinaryBroadcaster + HttpServerModule: clientGeneration() bumps on each new WS client so PreviewDriver re-sends the coordinate table the moment a page connects (a refresh shows the preview at once).
- PreviewDriver: send the coordinate table only when geometry changes (onBuildState) or a new client connects — never on a timer (the old ~1Hz rebuild was a hot-path waste).
Light domain:
- PreviewDriver: frame builder now sends EVERY light (dark ones too) so the shader can draw off LEDs as faint placeholder rings — the grid shape stays visible and an all-off scene isn't a black screen.
UI:
- preview3d.js: crisp-disc shader (a ~1px AA rim instead of a half-radius fade that read as blurry at small grids); off/near-black LEDs render as a dim placeholder ring; a faint wireframe bounding box around the light volume (a second line program). setupShrink → setupLayout already shipped; this is the render-quality pass.
- index.html: preview button aria-labels.
Scripts / MoonDeck:
- moondeck_ui: explicit "inject defaults" button per discovered device — re-pushes the SELECTED device-model's full deviceModels.json config on demand (reuses POST /api/push-board → _push_board_to_device), distinct from the implicit on-pick push. Inline injecting…/injected ✓/failed ✗ feedback.
Tests:
- unit_PreviewDriver: a new client (clientGeneration bump) forces a coord-table re-send; advancing the clock several seconds with no client change asserts NO re-send (guards against re-introducing a timer-based rebuild).
- scenario JSONs: live observed.* write-backs from running perf_light / perf_full / GridLayout_resize on the S3 and P4.
Docs:
- decisions.md: the mDNS async-handle-lifecycle-race lesson (don't hold a vendor library's async handle across your event loop; it races the library's internal timers) and the preview coord-cadence keepalive-timer lesson.
- ImprovProvisioningModule.md: present-tense wording fix.
Co-Authored-By: Claude Opus 4.8
---
docs/history/decisions.md | 4 +
.../core/ImprovProvisioningModule.md | 2 +-
scripts/moondeck_ui/app.js | 38 ++-
scripts/moondeck_ui/style.css | 23 +-
src/core/BinaryBroadcaster.h | 9 +
src/core/DevicesModule.h | 47 +--
src/core/HttpServerModule.cpp | 3 +
src/core/HttpServerModule.h | 5 +
src/light/drivers/PreviewDriver.h | 25 +-
src/platform/desktop/platform_desktop.cpp | 9 +-
src/platform/esp32/platform_esp32.cpp | 44 ++-
src/platform/platform.h | 26 +-
src/ui/index.html | 8 +-
src/ui/preview3d.js | 107 ++++++-
.../light/scenario_GridLayout_resize.json | 66 +++-
test/scenarios/light/scenario_perf_full.json | 300 +++++++++---------
test/scenarios/light/scenario_perf_light.json | 64 ++--
test/unit/light/unit_PreviewDriver.cpp | 32 ++
18 files changed, 518 insertions(+), 294 deletions(-)
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/moonmodules/core/ImprovProvisioningModule.md b/docs/moonmodules/core/ImprovProvisioningModule.md
index 43592d7..ee5b8c6 100644
--- a/docs/moonmodules/core/ImprovProvisioningModule.md
+++ b/docs/moonmodules/core/ImprovProvisioningModule.md
@@ -27,7 +27,7 @@ Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV
- `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_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 — **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 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 (`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. (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 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 (`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_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.
diff --git a/scripts/moondeck_ui/app.js b/scripts/moondeck_ui/app.js
index 69e3cad..65148a3 100644
--- a/scripts/moondeck_ui/app.js
+++ b/scripts/moondeck_ui/app.js
@@ -886,6 +886,17 @@ 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) => {
+ fetch("/api/push-board", {
+ method: "POST",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({ip: device.ip, board}),
+ }).then(r => onDone && onDone(r.ok)).catch(() => onDone && onDone(false));
+ };
boardPicker.addEventListener("change", () => {
device.board = boardPicker.value;
saveState();
@@ -895,11 +906,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 +965,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/src/core/BinaryBroadcaster.h b/src/core/BinaryBroadcaster.h
index 3ddc626..4cd2679 100644
--- a/src/core/BinaryBroadcaster.h
+++ b/src/core/BinaryBroadcaster.h
@@ -1,6 +1,7 @@
#pragma once
#include "platform/platform.h" // platform::WriteChunk
+#include
namespace mm {
@@ -15,6 +16,14 @@ struct BinaryBroadcaster {
// skip the frame; corrupt / dead sockets are dropped.
virtual void broadcastBinary(const platform::WriteChunk* payload, int chunkCount) = 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/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..426ad01 100644
--- a/src/core/HttpServerModule.cpp
+++ b/src/core/HttpServerModule.cpp
@@ -1075,6 +1075,9 @@ void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, con
for (auto& ws : wsClients_) {
if (!ws.valid()) {
ws = std::move(conn);
+ // A fresh client joined — bump the generation so stateful producers
+ // (PreviewDriver's coordinate table) re-send their priming message now.
+ wsClientGeneration_++;
return;
}
}
diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h
index a3c0d60..804ee2e 100644
--- a/src/core/HttpServerModule.h
+++ b/src/core/HttpServerModule.h
@@ -38,6 +38,10 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
// 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;
+ // Bumped on each new WS client (see handleWebSocketUpgrade). PreviewDriver watches
+ // it to re-send its coordinate table the moment a fresh page connects, so a refresh
+ // shows the preview immediately instead of waiting for the next ~1 Hz re-broadcast.
+ 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 +91,7 @@ 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()
// All JSON API responses (/api/state, /api/types, /api/system) and the WS
// state push stream through a JsonSink — no shared fixed-size buffer.
diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h
index 2e4be08..83a21c5 100644
--- a/src/light/drivers/PreviewDriver.h
+++ b/src/light/drivers/PreviewDriver.h
@@ -15,8 +15,9 @@ 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 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:u16][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).
@@ -66,13 +67,16 @@ 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) {
+ // The coordinate table is sent only when the geometry actually changes
+ // (onBuildState — a grid resize, layout/LUT rebuild) or when the UI asks for it
+ // (a new WS client bumps the broadcaster's clientGeneration, so a page refresh
+ // gets the positions immediately). NOT per-frame and NOT on a timer: rebuilding
+ // the full table every tick would starve the render loop, and the colour frames
+ // below already reference the last-sent positions. coordCount_==0 covers the cold
+ // case where the layout wasn't wired yet at onBuildState time.
+ uint32_t gen = broadcaster_ ? broadcaster_->clientGeneration() : 0;
+ if (coordCount_ == 0 || gen != lastClientGen_) {
+ lastClientGen_ = gen;
buildAndSendCoordTable();
}
@@ -130,7 +134,6 @@ class PreviewDriver : public DriverBase {
p->out++;
}, &pc);
- lastCoordTime_ = platform::millis();
sendCoordTable();
}
@@ -218,7 +221,7 @@ class PreviewDriver : public DriverBase {
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
};
} // namespace mm
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 9c790c9..33cf6a7 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -406,11 +406,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,
diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index 2c0c3b4..d3ac6c2 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -1002,28 +1002,26 @@ void mdnsShutdown() {
// --- 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.
+// handle lets us poll a few ms each tick instead. (mdnsSearch_ is forward-declared above,
+// next to cancelMdnsBrowse, so mdnsInit/mdnsStop can cancel an in-flight query.)
+
+// 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 +1054,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
diff --git a/src/platform/platform.h b/src/platform/platform.h
index a666e79..ff43f16 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
diff --git a/src/ui/index.html b/src/ui/index.html
index fa3ef21..80aeaef 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -34,16 +34,16 @@
⠿
- ⤢
- ✕
+ ⤢
+ ✕
- ⌖
+ ⌖
- ◳ preview
+ ◳ preview
diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js
index 97607e0..7f78139 100644
--- a/src/ui/preview3d.js
+++ b/src/ui/preview3d.js
@@ -37,6 +37,11 @@ let previewCoords_ = null; // Float32Array[count*3], normalised + box-centred
let previewCoordCount_ = 0;
let previewMaxDim_ = 1;
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 +53,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 +61,36 @@ 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;
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). Replaces the old
+ // half-radius alpha fade that read as "blurry" when each LED was big.
+ 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 disc, 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 treated as off (drawn as a placeholder) but a genuinely lit one is solid.
+ float lit = smoothstep(0.02, 0.10, lum);
+ // Off / near-black LEDs would otherwise vanish into the background, hiding the
+ // grid shape (and making an all-off scene a black screen). Draw the unlit ones
+ // as a faint hollow placeholder ring so the layout is always visible. A lit LED
+ // is a full solid disc; an off LED is just its grey outline.
+ float ringInner = 0.5 - aa - 0.08;
+ float ring = smoothstep(ringInner - aa, ringInner, d); // 1 only on the rim band
+ vec3 col = mix(vec3(0.35), bright, lit); // grey rim when off, real color when lit
+ float a = mix(ring * 0.5, disc, lit); // faint ring when off, solid disc when lit
+ if (a < 0.01) discard;
+ gl_FragColor = vec4(col, a);
}
`;
@@ -89,6 +113,23 @@ function initWebGL() {
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; });
@@ -230,14 +271,19 @@ function setupLayout() {
savePrefs({ corner, dismissed, forcePip });
applyMode();
});
- // Hide the PiP; reveal the re-show pill.
+ // 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();
});
@@ -334,18 +380,20 @@ function renderPreviewFrame(view, buf) {
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[i * 3] / 255;
+ vertsBuf[vi++] = rgb[i * 3 + 1] / 255;
+ vertsBuf[vi++] = rgb[i * 3 + 2] / 255;
}
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_;
@@ -425,6 +473,41 @@ function drawVerts() {
gl.uniform1f(glLocs.uPointSize, pointSize);
gl.drawArrays(gl.POINTS, 0, lastVertCount);
+
+ drawBoundingBox(mvp);
+}
+
+// Faint wireframe cuboid around the light volume. Rebuilt only when the box extent
+// changes (cached by boxKey). Half-extents match the normalised, box-centred point
+// coords (pos/maxDim - 0.5*box/maxDim), plus half a cell so the box encloses the
+// outermost LED centres rather than bisecting them.
+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
}
function buildMVP(ex, ey, ez, aspect) {
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_perf_full.json b/test/scenarios/light/scenario_perf_full.json
index 48467ff..aabf17d 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,
+ 111,
133
],
"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
+ 8543995
],
"max_alloc_block": [
106496,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -406,10 +406,10 @@
"esp32p4-eth": {
"tick_us": [
96,
- 98
+ 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
+ 107,
+ 124
],
"free_heap": [
8535099,
- 8540027
+ 8545811
],
"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,
+ 109,
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,
+ 112,
130
],
"free_heap": [
8537691,
- 8537719
+ 8545807
],
"max_alloc_block": [
106496,
- 106496
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"esp32": {
@@ -886,7 +886,7 @@
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,
+ 102,
119
],
"free_heap": [
8535703,
- 8536683
+ 8545823
],
"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,
+ 289,
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,
+ 735,
799
],
"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
+ 4587
],
"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,
+ 48051,
50555
],
"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,
+ 410
],
"free_heap": [
8540231,
- 8540231
+ 8543995
],
"max_alloc_block": [
110592,
- 110592
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"pc-macos": {
@@ -1755,7 +1755,7 @@
163
],
"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,20 +1883,20 @@
"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": {
@@ -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,20 +1976,20 @@
"observed": {
"esp32s3-n16r8": {
"tick_us": [
- 29647,
- 29647
+ 28061,
+ 29722
],
"free_heap": [
8398527,
- 8398527
+ 8402371
],
"max_alloc_block": [
110592,
- 110592
+ 114688
],
"at": [
"2026-06-17",
- "2026-06-17"
+ "2026-06-22"
]
},
"pc-macos": {
@@ -2031,10 +2031,10 @@
"esp32p4-eth": {
"tick_us": [
9846,
- 10143
+ 10153
],
"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..3de4079 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"
]
}
}
@@ -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/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp
index 5bfd676..cdd47d5 100644
--- a/test/unit/light/unit_PreviewDriver.cpp
+++ b/test/unit/light/unit_PreviewDriver.cpp
@@ -25,6 +25,7 @@ namespace {
struct CaptureBroadcaster : mm::BinaryBroadcaster {
int coordMsgs = 0, frameMsgs = 0;
std::vector lastCoord, lastFrame;
+ uint32_t generation = 0; // bump to simulate a new client connecting
void broadcastBinary(const mm::platform::WriteChunk* payload, int chunkCount) override {
std::vector buf;
@@ -34,6 +35,7 @@ struct CaptureBroadcaster : mm::BinaryBroadcaster {
if (buf[0] == 0x03) { coordMsgs++; lastCoord = buf; }
else if (buf[0] == 0x02) { frameMsgs++; lastFrame = buf; }
}
+ 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; }
@@ -174,3 +176,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
+}
From 1e48e9226fd5ce1882ce46162abeb01b6381cf78 Mon Sep 17 00:00:00 2001
From: ewowi
Date: Tue, 23 Jun 2026 08:24:22 +0200
Subject: [PATCH 04/10] Non-blocking preview streaming + full-resolution
preview, no tick stalls
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 3D preview now streams every light at high resolution (up to 16K and beyond) over WebSocket without freezing, tearing, or stalling the render loop — large grids that previously showed nothing or a garbled image now render. The preview send was reworked from a blocking all-or-nothing socket write on the render task into a staged, non-blocking drain with adaptive resolution. Also bundles this session's preview UX polish (filled-panel dots, sequence numbers, cursor/deep zoom, grey-circle off-LEDs, one control bar, dot-size slider).
KPI: 16384lights | PC:365KB | tick:121/92/123/9/1/338/36/16/19/118/11us(FPS:8264/10869/8130/111111/1000000/2958/27777/62500/52631/8474/90909) | ESP32 (P4-eth, bench): tick~2.6ms idle / streams 16K live; src:97(19807) | test:68(10349) | lizard:76w
Core:
- HttpServerModule: broadcastBinary now STAGES one frame into an owned buffer and returns (never blocks the render task on the socket); new drainWsSends() on loop20ms streams it to all clients a slice per tick via the new non-blocking platform::TcpConnection::writeSome. Backpressure: a new frame is dropped while the previous drains (broadcastBinary returns bool); lastDrainTicks() reports per-frame drain latency. 64-bit WS length form for frames >64KB. A stuck client (no progress ~3s) is closed, never spliced. This is the producer→consumer seam the architecture's two-task split will host.
- platform: added writeSome (non-blocking partial write); removed the now-dead writeChunks + WriteResult enum + MAX_WRITE_CHUNKS from platform.h and both platform impls; dropped the now-unused includes.
- BinaryBroadcaster: broadcastBinary returns bool (accepted/dropped) + new lastDrainTicks() — the producer's adaptive-resolution signal.
Light domain:
- PreviewDriver: RAM-derived MAX_PREVIEW_POINTS (131072 PSRAM / 16384 no-PSRAM) replacing the fixed 1800; spatial-lattice downsample kept as the fallback above the cap; wire count widened u16->u32 (>65535-light HUB75 walls). Adaptive downscaling driven by drain LATENCY (not dropped frames): sustained high latency coarsens the lattice, a low-latency stretch refines back, with hysteresis; the factor rides the coord header's stride field to the browser. Coord table is retried when dropped under backpressure and colour frames are withheld until it lands, so a rebuild can never desync the stream and freeze the preview.
UI:
- preview3d.js: parse u32 count + the new 10/7-byte headers; skip a colour frame whose count != the coord-table count (mid-rebuild) to prevent a scrambled mapping; "preview 1/N · link limited" status when the device downscales; plus this session's filled-panel dot sizing (cube-root pitch for 3D), fit-to-bulb sequence numbers, cursor-anchored + deep zoom, soft grey-circle off-LED placeholders, lit-on-top two-pass draw.
- index.html/style.css: dot-size slider + preview-status element + all preview controls consolidated into one bar; ⌖ reset now resets camera, dot size, numbers, and docked/PiP layout (browser-local, no backend).
Tests:
- unit_PreviewDriver: u32 headers, RAM-derived cap + spatial-lattice regularity, and a regression test pinning the coord-table-dropped-under-backpressure freeze the reviewer found (retry + frames-withheld-until-it-lands).
Docs:
- PreviewDriver.md / HttpServerModule.md: wire contracts rewritten for the u32 count, staged drain, RAM cap, spatial lattice, and adaptive downscale (the old single-writev / u16 / 1800-cap / index-downsample text was stale).
- Plan-20260622 - Non-blocking preview send.md saved to docs/history/plans/.
Reviews:
- 👾 BLOCKER (fixed): adaptive rebuild dropped the coord table under backpressure -> browser count-mismatch guard skipped every colour frame -> preview froze for the session; fixed via coord-pending retry + a regression test (the test mock had masked it by always accepting 0x03).
- 👾 (fixed): stale wire-contract specs rewritten; dead includes + stale writeChunks/mdns comments removed; dropStreak_ renamed slowStreak_ (latency, not drops); sampledIdx_ alloc switched to new(std::nothrow) so OOM degrades instead of aborting; seq-number label shows the sent index (i*stride was wrong for a spatial lattice).
- 👾 (deferred): PreviewDriver still holds rgb_/coords_ alongside the HttpServer staging buffer (two frame copies); zero-copy rgb_ removal + a live classic-board 16K RAM-headroom check are a follow-up (need the classic board connected). 195² adaptive-transition churn is a known tuning rough edge, not a freeze.
Co-Authored-By: Claude Opus 4.8
---
...an-20260622 - Non-blocking preview send.md | 76 ++++
docs/moonmodules/core/HttpServerModule.md | 6 +-
.../core/ImprovProvisioningModule.md | 4 +-
.../light/drivers/PreviewDriver.md | 21 +-
scripts/moondeck_ui/app.js | 8 +-
scripts/scenario/run_live_scenario.py | 70 +++-
src/core/BinaryBroadcaster.h | 16 +-
src/core/HttpServerModule.cpp | 141 +++++--
src/core/HttpServerModule.h | 32 +-
src/light/drivers/PreviewDriver.h | 250 ++++++++++---
src/platform/desktop/platform_desktop.cpp | 64 +---
src/platform/esp32/platform_esp32.cpp | 76 +---
src/platform/platform.h | 23 +-
src/ui/index.html | 11 +-
src/ui/preview3d.js | 352 +++++++++++++++---
src/ui/style.css | 43 ++-
.../light/scenario_Audio_mutation.json | 16 +-
.../light/scenario_Driver_mutation.json | 10 +-
.../light/scenario_Layouts_mutation.json | 12 +-
test/scenarios/light/scenario_perf_full.json | 46 +--
test/scenarios/light/scenario_perf_light.json | 4 +-
test/unit/light/unit_PreviewDriver.cpp | 98 ++++-
22 files changed, 993 insertions(+), 386 deletions(-)
create mode 100644 docs/history/plans/Plan-20260622 - Non-blocking preview send.md
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..3f2586d
--- /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()` (after the accept early-return): flush the staging buffer with the **non-blocking** `writeChunks` — 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.
+- **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.
+- **`maxDrainMs`** added to `writeChunks` during diagnosis: revert to keep the diff tight.
diff --git a/docs/moonmodules/core/HttpServerModule.md b/docs/moonmodules/core/HttpServerModule.md
index 38503cb..cb09c41 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:** `broadcastBinary(chunks)` stages one binary WS message (FIN+binary opcode) for non-blocking fan-out to every connected client — it prepends the WS frame header (16-bit length, or the 64-bit form above 64 KB) and copies the bytes; the meaning is 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 lives in the driver, not here.
- **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.
+`broadcastBinary` **stages the frame and returns** — it never blocks the render task on the socket. The frame is held in one buffer with a per-client byte offset; `drainWsSends()` (called from `loop20ms`, the transport poll) flushes each client's remaining bytes via the non-blocking `TcpConnection::writeSome`, a slice per tick, so a frame larger than the lwIP send buffer streams across ticks instead of dropping. **Backpressure:** `broadcastBinary` returns `false` and drops a new frame while the previous one is still draining (one buffer, newest-wins) — the producer reads that as "the link can't keep up". `lastDrainTicks()` reports how long the last frame took to drain (the producer's adaptive-resolution signal). A genuinely stuck client (no progress for ~3 s) is closed so it can't freeze the stream for the others; the browser auto-reconnects. This producer (stage) → consumer (drain) split is the seam the [two-task render/transport split](../../architecture.md) will later host on separate cores.
## 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 (`broadcastBinary` + `lastDrainTicks` + `clientGeneration`); the light-domain `PreviewDriver` holds a `BinaryBroadcaster*` and pushes each frame's bytes to it. `main.cpp` wires `PreviewDriver`'s broadcaster to the HttpServerModule instance — the only file that knows both. The preview's point budget (RAM-derived) 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 ee5b8c6..f036c19 100644
--- a/docs/moonmodules/core/ImprovProvisioningModule.md
+++ b/docs/moonmodules/core/ImprovProvisioningModule.md
@@ -27,9 +27,9 @@ Both transports speak the same Improv-WiFi serial protocol — frames of `IMPROV
- `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_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 — **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 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 (`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.)
+- `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_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/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md
index ff72837..6882bfa 100644
--- a/docs/moonmodules/light/drivers/PreviewDriver.md
+++ b/docs/moonmodules/light/drivers/PreviewDriver.md
@@ -16,25 +16,30 @@ 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][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)
+## Large layouts (spatial downsample + adaptive)
-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).
+A preview frame is **staged once and drained across transport-poll ticks** — `HttpServerModule::broadcastBinary` copies the frame into a single staging buffer and returns (never blocking the render task on the socket); `drainWsSends()` (on the HTTP `loop20ms`) streams it to every client a slice at a time via non-blocking `writeSome`. So a frame larger than one `writev`/the lwIP send buffer no longer drops the connection — it just takes a few ticks to send. See [HttpServerModule](../../core/HttpServerModule.md) for the transport contract.
-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.
+Two things bound the point count:
+
+- **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) and any grid under the cap send every light (`stride` = 1, exact).
+- **Adaptive downscale** — the driver watches `broadcaster_->lastDrainTicks()` (how many ticks the last frame took to fully send). Sustained high latency → coarsen the lattice (`stride`++) so frames shrink; a low-latency stretch → refine back toward full resolution (hysteresis stops oscillation). The current 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/scripts/moondeck_ui/app.js b/scripts/moondeck_ui/app.js
index 65148a3..8964e25 100644
--- a/scripts/moondeck_ui/app.js
+++ b/scripts/moondeck_ui/app.js
@@ -891,11 +891,17 @@ function renderDevices() {
// `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}),
- }).then(r => onDone && onDone(r.ok)).catch(() => onDone && onDone(false));
+ 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;
diff --git a/scripts/scenario/run_live_scenario.py b/scripts/scenario/run_live_scenario.py
index 51074fd..6993a9c 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"],
@@ -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 4cd2679..4374a83 100644
--- a/src/core/BinaryBroadcaster.h
+++ b/src/core/BinaryBroadcaster.h
@@ -11,10 +11,18 @@ 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;
+ // Stage one binary WS frame (the implementation prepends the WS header) for non-blocking
+ // fan-out to all clients. Returns true if the frame was accepted, false if DROPPED because
+ // a previous frame is still draining (backpressure) — the producer reads that as "the link
+ // can't keep up at this rate" and can adapt (e.g. PreviewDriver downscales the preview).
+ virtual bool broadcastBinary(const platform::WriteChunk* payload, int chunkCount) = 0;
+
+ // How many transport-poll ticks the LAST fully-sent frame took to drain to all clients
+ // (1 = went out immediately; higher = the link is backpressured). This is the real
+ // "can the link keep up" signal — unlike a dropped-frame count, which a producer running
+ // faster than the per-frame drain trips even on a healthy link. PreviewDriver reads this
+ // to adapt its resolution: high latency → downscale, low → refine back to full.
+ virtual uint16_t lastDrainTicks() const = 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
diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp
index 426ad01..155c7c7 100644
--- a/src/core/HttpServerModule.cpp
+++ b/src/core/HttpServerModule.cpp
@@ -38,6 +38,9 @@ void HttpServerModule::setup() {
void HttpServerModule::teardown() {
for (auto& ws : wsClients_) ws.close();
server_.close();
+ if (wsPreviewBuf_) { platform::free(wsPreviewBuf_); wsPreviewBuf_ = nullptr; }
+ wsPreviewCap_ = wsPreviewLen_ = 0;
+ for (auto& s : wsPreviewSent_) s = 0;
}
void HttpServerModule::loop20ms() {
@@ -48,9 +51,11 @@ void HttpServerModule::loop20ms() {
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.
+ // Drain the live-preview frame queued by broadcastBinary() — a slice per tick, never
+ // blocking. This is the consumer side of the preview producer/consumer handoff; the
+ // producer (PreviewDriver) only stages bytes, the transport poll sends them. (When the
+ // §145 two-task split lands, this call moves to the consumer/network task unchanged.)
+ drainWsSends();
}
void HttpServerModule::loop1s() {
@@ -1072,12 +1077,15 @@ 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-send their priming message now.
wsClientGeneration_++;
+ // Start this client at the END of any in-flight preview frame so it doesn't
+ // receive a half-frame mid-stream; it picks up cleanly from the next frame.
+ wsPreviewSent_[i] = wsPreviewLen_;
return;
}
}
@@ -1126,16 +1134,28 @@ 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;
+bool HttpServerModule::broadcastBinary(const platform::WriteChunk* payload, int chunkCount) {
+ if (!payload || chunkCount <= 0) return false;
// 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];
+ if (totalLen == 0) return false;
+
+ bool anyClient = false;
+ for (auto& ws : wsClients_) if (ws.valid()) { anyClient = true; break; }
+ if (!anyClient) return false;
+
+ // BACKPRESSURE: the staging buffer holds ONE frame, fanned out to all clients. While any
+ // client is still draining it (wsPreviewLen_ != 0), refuse a new frame — overwriting it
+ // would corrupt the in-flight sends. The new frame is DROPPED (return false → the producer
+ // reads this as "the link can't keep up" and downscales); this is what keeps a slow client
+ // from ever stalling the render task. drainWsSends() clears wsPreviewLen_ when all are done.
+ if (wsPreviewLen_ != 0) return false;
+
+ // WebSocket binary frame header. 16-bit length form up to 65535 B; the 64-bit form
+ // (RFC 6455) above that, so a full-resolution preview frame (tens of KB to >MB) is legal.
+ uint8_t wsHeader[10];
int wsHeaderLen = 0;
wsHeader[0] = 0x82; // FIN + binary opcode
if (totalLen < 126) {
@@ -1147,36 +1167,83 @@ void HttpServerModule::broadcastBinary(const platform::WriteChunk* payload, int
wsHeader[3] = static_cast(totalLen & 0xFF);
wsHeaderLen = 4;
} else {
- return; // frame too large for the 16-bit length form
+ wsHeader[1] = 127;
+ for (int i = 0; i < 8; i++) {
+ wsHeader[2 + i] = static_cast((static_cast(totalLen) >> (56 - 8 * i)) & 0xFF);
+ }
+ wsHeaderLen = 10;
}
- // 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;
+ // Stage the whole frame (header + payload) ONCE into the owned buffer, then return — the
+ // socket writes happen later in drainWsSends() on the transport poll, never here on the
+ // render task. (This copy is the producer→consumer handoff the §145 two-task split needs;
+ // the staging buffer is the queue.) Grow the buffer if a bigger grid needs more room.
+ const size_t frameLen = static_cast(wsHeaderLen) + totalLen;
+ if (wsPreviewCap_ < frameLen) {
+ if (wsPreviewBuf_) platform::free(wsPreviewBuf_);
+ wsPreviewBuf_ = static_cast(platform::alloc(frameLen));
+ wsPreviewCap_ = wsPreviewBuf_ ? frameLen : 0;
+ }
+ if (!wsPreviewBuf_) { wsPreviewCap_ = 0; return false; } // OOM: drop the frame, stay healthy
- for (auto& ws : wsClients_) {
+ std::memcpy(wsPreviewBuf_, wsHeader, wsHeaderLen);
+ size_t off = wsHeaderLen;
+ for (int i = 0; i < chunkCount; i++) {
+ std::memcpy(wsPreviewBuf_ + off, payload[i].data, payload[i].len);
+ off += payload[i].len;
+ }
+ wsPreviewLen_ = frameLen;
+ wsPreviewAge_ = 0;
+ wsPreviewDrainTicks_ = 0;
+ for (int i = 0; i < MAX_WS_CLIENTS; i++) wsPreviewSent_[i] = 0; // every client starts at 0
+ return true;
+}
+
+void HttpServerModule::drainWsSends() {
+ if (wsPreviewLen_ == 0) return; // nothing queued
+ wsPreviewDrainTicks_++; // this frame has now been draining for one more tick
+
+ // Push each client's remaining bytes (non-blocking, never spins). We own the per-client
+ // offset, so a mid-frame partial is correct — the WS message finishes across successive
+ // loop20ms calls. The frame stays staged until EVERY client has drained it (or dropped
+ // out); only then is wsPreviewLen_ cleared so broadcastBinary stages the next one. A slow
+ // client just lags its own offset; it can't overwrite the buffer or stall the others.
+ bool allDone = true;
+ size_t progressed = 0;
+ for (int i = 0; i < MAX_WS_CLIENTS; i++) {
+ platform::TcpConnection& ws = wsClients_[i];
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 (wsPreviewSent_[i] >= wsPreviewLen_) continue; // this client already finished
+ int n = ws.writeSome(wsPreviewBuf_ + wsPreviewSent_[i], wsPreviewLen_ - wsPreviewSent_[i]);
+ if (n < 0) { // socket error — drop this client, not the frame
+ ws.close();
+ wsPreviewSent_[i] = wsPreviewLen_; // mark done so it doesn't hold up the frame
+ continue;
+ }
+ progressed += static_cast(n);
+ wsPreviewSent_[i] += static_cast(n);
+ if (wsPreviewSent_[i] < wsPreviewLen_) allDone = false; // still bytes to send next tick
+ }
+ if (allDone) {
+ wsPreviewLastDrainTicks_ = wsPreviewDrainTicks_; // how long this frame took (latency)
+ wsPreviewLen_ = 0; wsPreviewAge_ = 0;
+ return;
+ }
+
+ // Stuck-client guard. Count only ticks with NO forward progress (a big frame on a healthy
+ // link legitimately spans many ticks — any progress resets the counter). If a client
+ // wedges (TCP window stuck, never erroring) and nothing moves for kPreviewMaxDrainTicks,
+ // we must free the buffer for the next frame — BUT a client still mid-send has a half-sent
+ // WS message, and staging a new frame would splice its bytes into that message (a torn,
+ // garbled frame in the browser). So CLOSE any unfinished client instead of just abandoning:
+ // a clean reconnect resyncs it. Finished clients are untouched.
+ if (progressed > 0) { wsPreviewAge_ = 0; return; }
+ if (++wsPreviewAge_ >= kPreviewMaxDrainTicks) {
+ for (int i = 0; i < MAX_WS_CLIENTS; i++) {
+ if (wsClients_[i].valid() && wsPreviewSent_[i] < wsPreviewLen_) wsClients_[i].close();
}
+ wsPreviewLen_ = 0;
+ wsPreviewAge_ = 0;
}
}
diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h
index 804ee2e..dd95784 100644
--- a/src/core/HttpServerModule.h
+++ b/src/core/HttpServerModule.h
@@ -37,11 +37,12 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
// 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;
+ bool broadcastBinary(const platform::WriteChunk* payload, int chunkCount) override;
// Bumped on each new WS client (see handleWebSocketUpgrade). PreviewDriver watches
// it to re-send its coordinate table the moment a fresh page connects, so a refresh
// shows the preview immediately instead of waiting for the next ~1 Hz re-broadcast.
uint32_t clientGeneration() const override { return wsClientGeneration_; }
+ uint16_t lastDrainTicks() const override { return wsPreviewLastDrainTicks_; }
// 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
@@ -93,6 +94,32 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
platform::TcpConnection wsClients_[MAX_WS_CLIENTS];
uint32_t wsClientGeneration_ = 0; // ++ on each new WS client; see clientGeneration()
+ // Live-preview send queue. broadcastBinary() copies ONE in-flight frame here and
+ // returns — it never blocks the render task on the socket. drainWsSends() (called from
+ // loop20ms, the transport poll) flushes it across ticks as each client's socket accepts
+ // bytes. The frame is staged ONCE (one buffer, not ×clients — the no-PSRAM RAM budget);
+ // the only per-client state is a sent-byte offset, so the same staged frame fans out to
+ // every connected client. Backpressure is PER CLIENT: broadcastBinary refuses a new frame
+ // while ANY client is still draining the previous one (the buffer is single — we can't
+ // overwrite it mid-send), so a slow browser drops frames (its offset just lags) without
+ // ever stalling the tick or overflowing. This producer→consumer handoff is the seam the
+ // architecture's two-task split (§145) will host on the consumer/network task.
+ uint8_t* wsPreviewBuf_ = nullptr; // owned; WS header + payload of one frame
+ size_t wsPreviewCap_ = 0; // allocated capacity (bytes)
+ size_t wsPreviewLen_ = 0; // bytes of the current frame (0 = idle/empty)
+ size_t wsPreviewSent_[MAX_WS_CLIENTS] = {}; // per-client bytes already written
+ uint16_t wsPreviewAge_ = 0; // consecutive NO-PROGRESS drain ticks
+ uint16_t wsPreviewDrainTicks_ = 0; // ticks the CURRENT frame has been draining
+ uint16_t wsPreviewLastDrainTicks_ = 1; // ticks the last COMPLETED frame took (lastDrainTicks)
+ // Stuck-client guard: counts only drain ticks where NOT ONE byte moved (any progress
+ // resets it), so a big frame on a healthy link legitimately spans many ticks. If a client
+ // wedges (TCP window stuck, never erroring) and nothing moves for this many ticks, the
+ // frame is abandoned so the preview never freezes for everyone; the lagging client resyncs
+ // on the next self-contained frame. ~3 s of ZERO progress at 20 ms/tick — long enough that
+ // a momentarily-busy (rendering-bound) browser, which still reads a little each tick, is
+ // never killed; only a genuinely wedged socket trips it.
+ static constexpr uint16_t kPreviewMaxDrainTicks = 150;
+
// All JSON API responses (/api/state, /api/types, /api/system) and the WS
// state push stream through a JsonSink — no shared fixed-size buffer.
@@ -158,6 +185,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);
+ // Flush the live-preview staging buffer to its client a slice at a time (non-blocking).
+ // Called each loop20ms; finishes a frame across as many ticks as the socket needs.
+ void drainWsSends();
};
} // namespace mm
diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h
index 83a21c5..430fbdb 100644
--- a/src/light/drivers/PreviewDriver.h
+++ b/src/light/drivers/PreviewDriver.h
@@ -4,6 +4,7 @@
#include "light/light_types.h" // lengthType, nrOfLightsType
#include "core/BinaryBroadcaster.h"
#include "platform/platform.h"
+#include // std::nothrow
namespace mm {
@@ -18,11 +19,13 @@ namespace mm {
// 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: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]
// 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.
//
@@ -77,10 +80,49 @@ class PreviewDriver : public DriverBase {
uint32_t gen = broadcaster_ ? broadcaster_->clientGeneration() : 0;
if (coordCount_ == 0 || gen != lastClientGen_) {
lastClientGen_ = gen;
- buildAndSendCoordTable();
+ buildAndSendCoordTable(); // sets coordPending_ if the 0x03 was dropped
+ } else if (coordPending_) {
+ // A previous coord table was dropped under backpressure. Retry it (no rebuild —
+ // the geometry hasn't changed, just re-broadcast the same bytes) until it lands.
+ coordPending_ = !sendCoordTable();
}
- sendFrame();
+ // Hold colour frames until the browser has the matching coordinate table: a 0x02 with
+ // the new count plotted against the old coords would be skipped by the browser's
+ // count-mismatch guard, and if the 0x03 stays dropped that would freeze the preview.
+ if (!coordPending_) sendFrame();
+
+ // Adaptive downscaling, driven by DRAIN LATENCY (not dropped frames). A dropped frame
+ // is normal — at fps=24 the producer naturally outruns the per-frame socket drain even
+ // on a fast link, so "frame dropped" over-triggers. The real signal is how many
+ // transport-poll ticks the last frame took to fully send: 1-2 ticks = the link keeps
+ // up; many ticks = genuinely backpressured. Coarsen (downscale_++) when latency is high
+ // for a sustained run; refine (downscale_--) when it's been low. Hysteresis via the
+ // streaks stops oscillation. A change rebuilds the coordinate table (the lattice
+ // changed) and re-primes the browser, whose status line shows the new factor. Skip the
+ // adaptation while a coord table is still pending — don't stack another rebuild on top.
+ const uint16_t drainTicks = broadcaster_ ? broadcaster_->lastDrainTicks() : 1;
+ if (coordPending_) {
+ // pending coord table: don't change the factor until it lands
+ } else if (drainTicks > kDrainTicksHigh) {
+ cleanStreak_ = 0;
+ if (++slowStreak_ >= kDownscaleAfterSlow && downscale_ < 64) {
+ slowStreak_ = 0;
+ downscale_++;
+ buildAndSendCoordTable();
+ }
+ } else if (drainTicks <= kDrainTicksLow) {
+ slowStreak_ = 0;
+ if (downscale_ > 1 && ++cleanStreak_ >= kUpscaleAfterFast) {
+ cleanStreak_ = 0;
+ downscale_--;
+ buildAndSendCoordTable();
+ }
+ } else {
+ // Mid-range latency: stable, hold the current factor (don't drift either way).
+ slowStreak_ = 0;
+ cleanStreak_ = 0;
+ }
}
// Build (or rebuild) the cached coordinate table from the layout's real
@@ -91,8 +133,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
@@ -109,48 +149,107 @@ 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_;
- 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
+ // Downsample SPATIALLY, not by flat index. Picking every Nth light in driver
+ // order moirés on a 2D grid (the sampled column drifts each row when N doesn't
+ // divide the width → diagonal blank streaks). Instead, keep a light only when its
+ // grid position falls on a coarse lattice (qx%sx==0 && qy%sy==0 && qz%sz==0): a
+ // regular sub-grid, no drift — and it generalises to 3D (cube, sphere) since the
+ // lattice is per-axis on the real coordinates, not the index. sx/sy/sz are chosen
+ // so the kept count fits MAX_PREVIEW_POINTS. The kept lights' DRIVER indices are
+ // recorded in sampledIdx_ so the per-frame colour pass sends the SAME lights in the
+ // SAME order (lockstep); the wire stride is 1 (the browser maps colour k → coord k).
+ 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 sx = 1, sy = 1, sz = 1;
+ // Grow the per-axis lattice stride uniformly until the lattice-point count fits.
+ // (Active axes only — a flat 2D grid leaves sz at 1.)
+ auto latticeCount = [&](nrOfLightsType s) {
+ nrOfLightsType cx = (ax + s - 1) / s, cy = (ay + s - 1) / s, cz = (az + s - 1) / s;
+ return static_cast(cx) * cy * cz;
+ };
+ nrOfLightsType s = 1;
+ while (latticeCount(s) > MAX_PREVIEW_POINTS) s++;
+ if (s < downscale_) s = downscale_; // adaptive: never finer than the link sustains
+ sx = sy = sz = s;
+
+ // Buffers sized to the points we'll actually send — min(n, cap) — not the full cap:
+ // an 8×8 grid uses 192 B, not 65 KB. A grid ≤ ~145² sends every light (s==1); only a
+ // bigger one downsamples (s>1) and the lattice count is the upper bound. coords_/rgb_
+ // are PSRAM-backed (platform::alloc); sampledIdx_ is the index list.
+ const nrOfLightsType sendCap = n < MAX_PREVIEW_POINTS ? n : MAX_PREVIEW_POINTS;
+ if (!coords_.data() || coords_.count() < sendCap) {
+ coords_.allocate(sendCap, 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_};
+ if (!sampledIdx_ || sampledIdxCap_ < sendCap) {
+ delete[] sampledIdx_;
+ // nothrow so the !sampledIdx_ guard below actually catches OOM — plain new aborts
+ // on the ESP32, which would crash the device instead of degrading the preview.
+ sampledIdx_ = new (std::nothrow) nrOfLightsType[sendCap];
+ sampledIdxCap_ = sampledIdx_ ? sendCap : 0;
+ }
+ if (!coords_.data() || !sampledIdx_) { coordCount_ = 0; coordPending_ = false; return; }
+
+ struct PackCtx {
+ PreviewDriver* self; uint8_t* dst; nrOfLightsType* idxOut;
+ nrOfLightsType sx, sy, sz, out, cap;
+ };
+ PackCtx pc{this, coords_.data(), sampledIdx_, sx, sy, sz, 0, sendCap};
layouts->forEachCoord([](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) {
auto* p = static_cast(c);
- if (idx % p->stride != 0) return;
+ // Keep only lattice points — a regular spatial sub-sample (works in 2D and 3D).
+ if (x % p->sx != 0 || y % p->sy != 0 || z % p->sz != 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->idxOut[p->out] = idx; // driver index → colour pass reads the same lights
p->out++;
}, &pc);
+ coordCount_ = pc.out;
+ // The wire "stride" field now carries the effective DOWNSCALE factor (per axis) for the
+ // browser's status line — colour k still maps 1:1 to coord k (the index list picks the
+ // lights). 1 = full resolution; >1 = "showing 1/s of the lights, link can't keep up".
+ stride_ = s;
+
+ // Header: [0x03][count:u32 LE][bx][by][bz][stride:u16 LE] (10 bytes)
+ uint8_t* h = coordHeader_;
+ h[0] = 0x03;
+ h[1] = static_cast(coordCount_ & 0xFF);
+ 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(stride_ & 0xFF);
+ h[9] = static_cast(stride_ >> 8);
- sendCoordTable();
+ // The coordinate table MUST reach the browser before colour frames that carry the new
+ // count — else the browser's count-mismatch guard skips every colour frame and the
+ // preview freezes. broadcastBinary can DROP the 0x03 under backpressure (a colour frame
+ // still draining), so track whether it landed; loop() retries while pending and holds
+ // off colour frames until it lands.
+ coordPending_ = !sendCoordTable();
}
- // 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;
+ // Produce + push one per-frame 0x02 RGB message. Returns the broadcaster's accept/drop
+ // result (false = dropped under backpressure) so loop() can drive 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;
+ // RGB for exactly the lights the coordinate table sampled, in the same order —
+ // sampledIdx_[k] is the driver index of sent point k (built in buildAndSendCoordTable
+ // by the spatial lattice). Iterating that list keeps colour ↔ position in lockstep
+ // regardless of how the sampling chose them.
+ if (!rgb_.data() || rgb_.count() < coordCount_) rgb_.allocate(coordCount_, 3);
+ if (!rgb_.data() || !sampledIdx_) return false;
uint8_t* dst = rgb_.data();
nrOfLightsType out = 0;
- for (nrOfLightsType i = 0; i < n && out < coordCount_; i += stride_) {
+ for (nrOfLightsType k = 0; k < coordCount_; k++) {
+ nrOfLightsType i = sampledIdx_[k];
+ if (i >= n) continue; // layout shrank since the table was built
const uint8_t* s = src + static_cast(i) * cpl;
dst[out * 3 + 0] = s[0];
dst[out * 3 + 1] = cpl >= 2 ? s[1] : 0;
@@ -158,39 +257,39 @@ class PreviewDriver : public DriverBase {
out++;
}
- uint8_t header[5];
+ // Header: [0x02][count:u32 LE][stride:u16 LE] (7 bytes)
+ 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);
+ header[2] = static_cast((out >> 8) & 0xFF);
+ header[3] = static_cast((out >> 16) & 0xFF);
+ header[4] = static_cast((out >> 24) & 0xFF);
+ header[5] = static_cast(stride_ & 0xFF);
+ header[6] = static_cast(stride_ >> 8);
const platform::WriteChunk payload[] = {
{ header, sizeof(header) },
{ dst, static_cast(out) * 3 },
};
- broadcaster_->broadcastBinary(payload, 2);
+ return broadcaster_->broadcastBinary(payload, 2);
}
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. The old ~1800 limit was the single-writev wall; that's gone now —
+ // broadcastBinary enqueues the frame and HttpServerModule::drainWsSends() streams it
+ // across loop20ms ticks (non-blocking), so the cap is no longer "what fits one writev"
+ // but "how big a frame the device can stage + stream comfortably". That's RAM-bound:
+ // the staging frame is points×3 bytes (16K LEDs = 48 KB), so a no-PSRAM classic board
+ // tops out around 16K in internal RAM while PSRAM boards go far higher. Two compile-time
+ // tiers off platform::hasPsram; downsampling stays as the graceful fallback above the cap.
+ // Tune these 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
@@ -202,26 +301,55 @@ class PreviewDriver : public DriverBase {
return s > 255 ? 255 : static_cast(s);
}
- void sendCoordTable() {
- if (!broadcaster_ || coordCount_ == 0 || !coords_.data()) return;
+ // Returns whether the broadcaster accepted the table (false = dropped under backpressure).
+ bool sendCoordTable() {
+ if (!broadcaster_ || coordCount_ == 0 || !coords_.data()) return false;
const platform::WriteChunk payload[] = {
{ coordHeader_, sizeof(coordHeader_) },
{ coords_.data(), static_cast(coordCount_) * 3 },
};
- broadcaster_->broadcastBinary(payload, 2);
+ return 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;
+ uint8_t coordHeader_[10] = {}; // [0x03][count:u32][bx][by][bz][stride:u16]
+ nrOfLightsType coordCount_ = 0; // points actually sent
+ nrOfLightsType stride_ = 1; // wire field: the lattice/downscale factor (1 = full res)
+ bool coordPending_ = false; // a coord table was dropped under backpressure; loop() retries it
+ // Driver indices of the sampled lights (sent point k = driver light sampledIdx_[k]).
+ // Built in buildAndSendCoordTable, read by sendFrame so colour ↔ position stay locked.
+ // Raw heap (not Buffer) because it holds indices, not the u8×3 the Buffer helper packs.
+ nrOfLightsType* sampledIdx_ = nullptr;
+ nrOfLightsType sampledIdxCap_ = 0;
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 lastClientGen_ = 0; // last seen broadcaster_->clientGeneration() — re-send coords on change
+
+ // Adaptive downscaling. The preview streams at the finest resolution the WS link sustains;
+ // when a frame takes too many transport ticks to drain (high latency) we coarsen the
+ // lattice (downscale_++) so frames shrink and catch up. A low-latency stretch steps it
+ // back toward full resolution. Hysteresis (streak thresholds) stops oscillation. downscale_
+ // is an extra floor on the per-axis lattice stride, so it composes with the RAM-cap
+ // downsample. It rides the coord header's stride field to the browser, which shows
+ // "preview 1/N · link limited" while > 1. (kept ≥1; 1 = full resolution / no downscale.)
+ nrOfLightsType downscale_ = 1;
+ uint8_t slowStreak_ = 0; // consecutive HIGH-latency frames
+ uint8_t cleanStreak_ = 0; // consecutive LOW-latency frames
+ // Latency thresholds in transport-poll ticks (~20 ms each). A frame that drains in ≤2 ticks
+ // means the link has headroom; >4 ticks means it's struggling. The streak counts give
+ // hysteresis: coarsen after a short slow run, refine after a longer fast run (slower to
+ // refine so it doesn't flap right back into trouble).
+ static constexpr uint16_t kDrainTicksLow = 2;
+ static constexpr uint16_t kDrainTicksHigh = 4;
+ static constexpr uint8_t kDownscaleAfterSlow = 4; // coarsen after this many slow frames
+ static constexpr uint8_t kUpscaleAfterFast = 20; // refine after this many fast frames
+
+public:
+ ~PreviewDriver() override { delete[] sampledIdx_; }
};
} // namespace mm
diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 33cf6a7..8d7fd4b 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
@@ -699,59 +698,20 @@ 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;
+ auto n = ::send(sock(fd_), reinterpret_cast(data), static_cast(len), 0);
+ if (n > 0) return static_cast(n);
+ if (n == 0) return 0;
+ if (sockWouldBlock()) return 0; // buffer full — try later
+#ifndef _WIN32
+ if (errno == EINTR) return 0; // interrupted — try later
#endif
+ return -1; // real socket error
}
+
void TcpConnection::close() {
if (fd_ >= 0) {
close_sock(fd_);
@@ -804,7 +764,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_esp32.cpp b/src/platform/esp32/platform_esp32.cpp
index d3ac6c2..a6445dd 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 {
@@ -999,11 +998,7 @@ 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. (mdnsSearch_ is forward-declared above,
-// next to cancelMdnsBrowse, so mdnsInit/mdnsStop can cancel an in-flight query.)
+// --- 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
@@ -1165,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/platform.h b/src/platform/platform.h
index ff43f16..2246783 100644
--- a/src/platform/platform.h
+++ b/src/platform/platform.h
@@ -299,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;
@@ -328,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/index.html b/src/ui/index.html
index 80aeaef..9f786e3 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -33,12 +33,21 @@
it never fights the canvas's camera-orbit pointer handler). -->
⠿
+
+
+
+ №
+ ⌖
⤢
✕
+
- ⌖
+
diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js
index 7f78139..b738212 100644
--- a/src/ui/preview3d.js
+++ b/src/ui/preview3d.js
@@ -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,6 +49,13 @@ 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.
+let dotScale_ = parseFloat(localStorage.getItem("mm_preview_dot") || "1") || 1;
+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;
@@ -68,29 +88,40 @@ function initWebGL() {
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)); // 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). Replaces the old
- // half-radius alpha fade that read as "blurry" when each LED was big.
+ // (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 disc, thin soft rim
+ 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));
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 treated as off (drawn as a placeholder) but a genuinely lit one is solid.
+ // 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);
- // Off / near-black LEDs would otherwise vanish into the background, hiding the
- // grid shape (and making an all-off scene a black screen). Draw the unlit ones
- // as a faint hollow placeholder ring so the layout is always visible. A lit LED
- // is a full solid disc; an off LED is just its grey outline.
- float ringInner = 0.5 - aa - 0.08;
- float ring = smoothstep(ringInner - aa, ringInner, d); // 1 only on the rim band
- vec3 col = mix(vec3(0.35), bright, lit); // grey rim when off, real color when lit
- float a = mix(ring * 0.5, disc, lit); // faint ring when off, solid disc when lit
- if (a < 0.01) discard;
- gl_FragColor = vec4(col, a);
+
+ 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);
+ }
}
`;
@@ -102,12 +133,19 @@ 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);
@@ -143,8 +181,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});
@@ -182,7 +250,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();
}
@@ -256,6 +324,14 @@ function setupLayout() {
requestAnimationFrame(() => { placeCorner(); refit(); });
}
+ // 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;
@@ -287,6 +363,25 @@ function setupLayout() {
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_ = parseFloat(dotSlider.value) || 1;
+ 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).
@@ -328,13 +423,28 @@ function setupLayout() {
// 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);
@@ -346,11 +456,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);
@@ -369,13 +482,19 @@ function renderPreviewFrame(view, buf) {
// Hold frames until positions have arrived (the table is sent on rebuild +
// ~1 Hz, so a fresh client catches up within a second).
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);
+ 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 counts disagree, a geometry rebuild
+ // (a resize, or the device's adaptive downscale changing the lattice) is mid-flight: the
+ // new-count colours would land on the old-count positions, mapping colours to the wrong
+ // lights (a visibly scrambled frame). Skip such a frame; the matching coord table arrives
+ // within ~1 frame and they realign cleanly.
+ if (count !== previewCoordCount_) return;
+ const n = count;
if (!vertsBuf || vertsBuf.length < n * 6) vertsBuf = new Float32Array(n * 6);
let vi = 0;
@@ -417,13 +536,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();
}
@@ -448,10 +585,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.
@@ -469,18 +608,55 @@ 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 match the normalised, box-centred point
-// coords (pos/maxDim - 0.5*box/maxDim), plus half a cell so the box encloses the
-// outermost LED centres rather than bisecting them.
+// 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_;
@@ -510,9 +686,89 @@ function drawBoundingBox(mvp) {
gl.useProgram(glProgram); // restore the points program for the next frame
}
-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;
+// 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, 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;
@@ -527,7 +783,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,
diff --git a/src/ui/style.css b/src/ui/style.css
index 11b853b..a3532cf 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -260,6 +260,19 @@ body {
touch-action: none;
}
+/* 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;
+ 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 {
@@ -269,6 +282,7 @@ body {
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; }
.preview-bar-spacer { flex: 1; }
.preview-bar-btn {
background: transparent;
@@ -282,27 +296,20 @@ body {
opacity: 0.6;
}
.preview-bar-btn:hover { opacity: 1; color: var(--accent); }
-
-#preview-reset {
- position: absolute;
- top: 30px;
- right: 8px;
- background: var(--bg-1);
- border: 1px solid var(--border);
- color: var(--fg-muted);
- border-radius: 4px;
- width: 26px;
- height: 26px;
- font-size: 15px;
- line-height: 1;
- padding: 0;
+.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; }
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 831bdf2..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,
- 11
+ 20
],
"free_heap": [
0,
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 aabf17d..87a9784 100644
--- a/test/scenarios/light/scenario_perf_full.json
+++ b/test/scenarios/light/scenario_perf_full.json
@@ -105,7 +105,7 @@
"esp32s3-n16r8": {
"tick_us": [
111,
- 133
+ 186
],
"free_heap": [
8540003,
@@ -374,7 +374,7 @@
],
"free_heap": [
8536423,
- 8543995
+ 8544003
],
"max_alloc_block": [
106496,
@@ -405,7 +405,7 @@
},
"esp32p4-eth": {
"tick_us": [
- 96,
+ 94,
106
],
"free_heap": [
@@ -463,12 +463,12 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 107,
+ 106,
124
],
"free_heap": [
8535099,
- 8545811
+ 8545827
],
"max_alloc_block": [
94208,
@@ -747,7 +747,7 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 109,
+ 108,
142
],
"free_heap": [
@@ -846,12 +846,12 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 112,
+ 106,
130
],
"free_heap": [
8537691,
- 8545807
+ 8545823
],
"max_alloc_block": [
106496,
@@ -882,7 +882,7 @@
},
"esp32p4-eth": {
"tick_us": [
- 57,
+ 56,
63
],
"free_heap": [
@@ -955,12 +955,12 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 102,
+ 101,
119
],
"free_heap": [
8535703,
- 8545823
+ 8545827
],
"max_alloc_block": [
102400,
@@ -1048,7 +1048,7 @@
},
"esp32s3-n16r8": {
"tick_us": [
- 289,
+ 278,
328
],
"free_heap": [
@@ -1336,7 +1336,7 @@
"esp32s3-n16r8": {
"tick_us": [
735,
- 799
+ 871
],
"free_heap": [
8541939,
@@ -1558,7 +1558,7 @@
"esp32p4-eth": {
"tick_us": [
4358,
- 4587
+ 5101
],
"free_heap": [
34015299,
@@ -1615,7 +1615,7 @@
"esp32s3-n16r8": {
"tick_us": [
48051,
- 50555
+ 51127
],
"free_heap": [
8491607,
@@ -1698,7 +1698,7 @@
"esp32s3-n16r8": {
"tick_us": [
382,
- 410
+ 456
],
"free_heap": [
8540231,
@@ -1752,7 +1752,7 @@
"esp32p4-eth": {
"tick_us": [
154,
- 163
+ 164
],
"free_heap": [
34021691,
@@ -1902,7 +1902,7 @@
"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": {
@@ -1977,7 +1977,7 @@
"esp32s3-n16r8": {
"tick_us": [
28061,
- 29722
+ 33932
],
"free_heap": [
8398527,
@@ -1995,7 +1995,7 @@
"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,7 +2031,7 @@
"esp32p4-eth": {
"tick_us": [
9846,
- 10153
+ 10184
],
"free_heap": [
33883359,
diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json
index 3de4079..83eeaad 100644
--- a/test/scenarios/light/scenario_perf_light.json
+++ b/test/scenarios/light/scenario_perf_light.json
@@ -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": {
diff --git a/test/unit/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp
index cdd47d5..dbdc827 100644
--- a/test/unit/light/unit_PreviewDriver.cpp
+++ b/test/unit/light/unit_PreviewDriver.cpp
@@ -26,20 +26,32 @@ struct CaptureBroadcaster : mm::BinaryBroadcaster {
int coordMsgs = 0, frameMsgs = 0;
std::vector lastCoord, lastFrame;
uint32_t generation = 0; // bump to simulate a new client connecting
+ bool acceptNext = true; // false → drop colour frames (simulate backpressure)
+ bool dropCoord = false; // true → drop coord tables too (simulate a 0x03 lost to backpressure)
- void broadcastBinary(const mm::platform::WriteChunk* payload, int chunkCount) override {
+ bool 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.empty()) return false;
+ if (dropCoord && buf[0] == 0x03) return false; // coord table dropped under backpressure
+ if (!acceptNext && buf[0] == 0x02) return false; // colour frame dropped on demand
if (buf[0] == 0x03) { coordMsgs++; lastCoord = buf; }
else if (buf[0] == 0x02) { frameMsgs++; lastFrame = buf; }
+ return true;
}
uint32_t clientGeneration() const override { return generation; }
+ uint16_t drainTicks = 1; // simulate link latency for the adaptive-downscale test
+ uint16_t lastDrainTicks() const override { return drainTicks; }
- 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
@@ -84,8 +96,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.
@@ -97,8 +109,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).
@@ -113,17 +125,39 @@ 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é
}
// Default fps is the rate-limited preview stream rate.
@@ -132,6 +166,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
From c26ef2ad1a11b933b135c7e1144f30cdd91b59c0 Mon Sep 17 00:00:00 2001
From: ewowi
Date: Tue, 23 Jun 2026 13:01:03 +0200
Subject: [PATCH 05/10] Stream the preview (zero buffers); drivers process only
their own lights
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 3D preview now streams straight from the render buffer with no preview-side frame buffers, freeing the contiguous RAM that blocked large grids on no-PSRAM boards. Separately, two output drivers stop doing work for lights they don't drive: RmtLed encodes only the LEDs its strands transmit, and NetworkSend gains a light_count so a sink covers just its slice. Together these cut a 16K-LED tick on the S3 from ~180 ms to ~11 ms. Known limitation: the preview send is synchronous on its loop, so a 128x128+ frame briefly pauses the device — the resumable cross-tick send is the next follow-up.
KPI: 16384lights | PC:365KB | tick:129/93/121/9/1/486/43/17/25/139/32us(FPS:7751/10752/8264/111111/1000000/2057/23255/58823/40000/7194/31250) | ESP32 (S3, bench): 128² tick ~11ms with strands capped (was ~180ms); src:97(19716) | test:68(10375) | lizard:74w
Core:
- HttpServerModule / BinaryBroadcaster: replaced the chunk-array broadcastBinary + per-client staging buffer + drainWsSends with a streaming begin/pushBinary/endBinaryFrame trio — the WS header goes out on begin, each pushed slice fans to every client, end reports all-sent. No frame-sized buffer on either side. Removed the now-dead staging members, drainWsSends, directBroadcast, lastDrainTicks, and the broadcastBinary chunk form.
- platform (desktop): writeSome toggles non-blocking around send so the streamed push never blocks on a full kernel buffer.
Light domain:
- PreviewDriver: streams both messages — the 0x03 coordinate table from forEachCoord (no coords_ buffer) and the per-frame 0x02 colours straight from the producer buffer 1:1 at full resolution (no rgb_/sampledIdx_). A downsampled frame re-applies the same per-axis lattice skip over forEachCoord, so colour k lines up with coord k with no stored index map. Dropped coords_, rgb_, sampledIdx_ (≈80KB+ of contiguous internal RAM on a 16K grid); per-frame preview cost is now ~75µs. Adaptive downscale now keys off endBinaryFrame() (a frame that didn't reach every client) instead of drain latency.
- RmtLedDriver: bound the symbol-encode loop to txLightCount_ (Σ pinCounts, the lights the strands actually transmit) instead of the whole source buffer — a 64-leds/pin config on a 16K grid encoded all 16384, ~93ms; now ~2.6ms.
- NetworkSendDriver: new light_count control (uint16, 0 = whole buffer) sends only the first N lights, so a sink covers its slice — split some lights to LEDs and the rest to ArtNet, or run multiple senders for different ranges — and a frame isn't packed/sent for lights it doesn't own (~80ms → ~0.38ms at 64). ParallelLedDriver/LcdLedDriver already bound by maxLaneLights_; a start-offset for arbitrary slices across all drivers is a planned follow-up.
UI:
- preview3d.js: unchanged wire parsing (u32 count, 10/7-byte headers, count+stride mismatch guard) works with the streamed frames; comment fix (positions re-sent on rebuild / new client, not a ~1Hz timer).
Tests:
- unit_PreviewDriver: CaptureBroadcaster reassembles the begin/push/end stream; assertions (count, lattice regularity, sparse-large-box stays full-res, coord-pending retry) pass against the streamed model.
Docs:
- PreviewDriver.md / HttpServerModule.md: rewritten to the streamed (no-buffer) model; NetworkSendDriver.md documents light_count; ImprovProvisioningModule.md vendor-extension count corrected to two. Plan-20260623 saved.
Reviews:
- 🐇 (processed earlier, carried): loop20ms drain ordering, anyClient backpressure, latticeCount sparse-layout fix, dot-size clamp, input label, browser stride guard, dead-include + comment cleanups — all preserved or superseded by the streamed model.
Co-Authored-By: Claude Opus 4.8
---
...an-20260622 - Non-blocking preview send.md | 4 +-
...view from buffers, zero preview buffers.md | 58 ++++
docs/moonmodules/core/HttpServerModule.md | 6 +-
.../core/ImprovProvisioningModule.md | 2 +-
.../light/drivers/NetworkSendDriver.md | 1 +
.../light/drivers/PreviewDriver.md | 19 +-
src/core/BinaryBroadcaster.h | 26 +-
src/core/HttpServerModule.cpp | 170 +++-------
src/core/HttpServerModule.h | 60 ++--
src/light/drivers/NetworkSendDriver.h | 11 +-
src/light/drivers/PreviewDriver.h | 311 ++++++++----------
src/light/drivers/RmtLedDriver.h | 10 +-
src/platform/desktop/platform_desktop.cpp | 17 +-
src/ui/index.html | 1 +
src/ui/preview3d.js | 25 +-
src/ui/style.css | 5 +
test/unit/light/unit_PreviewDriver.cpp | 58 +++-
17 files changed, 396 insertions(+), 388 deletions(-)
create mode 100644 docs/history/plans/Plan-20260623 - Stream preview from buffers, zero preview buffers.md
diff --git a/docs/history/plans/Plan-20260622 - Non-blocking preview send.md b/docs/history/plans/Plan-20260622 - Non-blocking preview send.md
index 3f2586d..f77fbcb 100644
--- a/docs/history/plans/Plan-20260622 - Non-blocking preview send.md
+++ b/docs/history/plans/Plan-20260622 - Non-blocking preview send.md
@@ -30,7 +30,7 @@ Three seams, all in core transport + the driver. No new task yet (it arrives wit
- **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()` (after the accept early-return): flush the staging buffer with the **non-blocking** `writeChunks` — 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.
+- 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)
@@ -73,4 +73,4 @@ The preview is a *live view* — one viewer at a time is the real use case, and
- **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.
-- **`maxDrainMs`** added to `writeChunks` during diagnosis: revert to keep the diff tight.
+- The diagnostic `writeChunks`/`maxDrainMs` machinery was removed entirely; the transport primitive is the non-blocking `writeSome`.
diff --git a/docs/history/plans/Plan-20260623 - Stream preview from buffers, zero preview buffers.md b/docs/history/plans/Plan-20260623 - Stream preview from buffers, zero preview buffers.md
new file mode 100644
index 0000000..6e705ba
--- /dev/null
+++ b/docs/history/plans/Plan-20260623 - Stream preview from buffers, zero preview buffers.md
@@ -0,0 +1,58 @@
+# Plan — Stream the preview from the producer buffer; eliminate all preview-side buffers
+
+## Context
+
+The non-blocking preview rework (committed 1e48e92) made the WebSocket preview stream without stalling the render tick, but it introduced/retained **frame-sized buffers** in the preview path: `coords_` (~49 KB packed positions), `rgb_` (per-frame colour copy), `sampledIdx_` (the lattice index map), and the HttpServer **staging buffer** (~49 KB). On a no-PSRAM classic ESP32 these compete for scarce *contiguous* internal RAM: at 128² (16384 lights) the render buffer (49 KB) and a preview buffer (49 KB) can't both find a contiguous block once the heap fragments from grid-resize churn — so the preview (and sometimes the render) fails to allocate. Measured on the bench: a clean boot has a 108 KB contiguous block (128² fits); after resize churn it collapses to ~20–40 KB (128² fails).
+
+The principle this violates is CLAUDE.md's **minimal memory / data-over-objects / hot-path** rules, applied to the preview: the colours already live in the **producer/consumer buffer** (the Layer's logical buffer, or the blend buffer for multi-layer/non-identity mapping). The preview should **stream that buffer to the client**, holding no frame-sized copy of its own. Positions are communicated **once** (event-based: on a layout/modifier change, or when a client connects/refreshes); after that the per-frame stream is just the buffer, 1:1, and the client already knows where each light goes.
+
+architecture.md describes the preview *mechanism* (a one-time coord table + per-frame RGB, §"Output stage"/§UI) but does **not** state the memory model — that the per-frame stream is the producer buffer with no intermediate buffer, and downsampling is "send every Nth light." This plan implements that, and the doc gets updated to capture it.
+
+## The model (settled with the product owner)
+
+- **Coordinates are sent once** (0x03), on geometry change or client (re)connect. They need positions → built from `Layouts::forEachCoord` (a cold/rate-limited path, never the LED render hot path — verified: forEachCoord callers are LUT build, status, and the preview coord build only).
+- **Colours are streamed per frame** (0x02) straight from the producer/consumer buffer the driver already holds (`sourceBuffer_`), **1:1, no copy**. The client places colour[i] at coord[i] from the table it already has.
+- **Downsampling = send every Nth light**, applied identically to the coord table and the colour frame so they match by construction. Two regimes:
+ - **Full resolution (the common case, stride 1):** colour frame is a pure 1:1 buffer stream — no `forEachCoord`, no skip, no buffers.
+ - **Downsampled (rare: grid > cap, or link too slow):** to avoid the diagonal moiré that flat `i % N` striding causes on a 2D grid, both passes use the **spatial-lattice** skip (`x%s && y%s && z%s`) via `forEachCoord`. This walks positions (cheap integer loop, rate-limited, off the LED hot path) but still streams — no stored index map.
+- **No preview-side frame buffers at all**: streaming via the broadcaster's begin/push/end means neither pass ever holds a frame-sized buffer. `coords_`, `rgb_`, `sampledIdx_`, and the HttpServer staging buffer are all removed.
+
+## Approach
+
+### 1. Broadcaster: streaming begin/push/end (already implemented)
+`BinaryBroadcaster` gains `beginBinaryFrame(totalLen)` / `pushBinaryFrame(data,len)` / `endBinaryFrame()`. HttpServerModule sends the WS header on begin, fans each pushed slice to every client via the non-blocking `sendAllOrClose` (close a client that can't keep up — it reconnects), and reports all-sent on end. No frame-sized staging buffer.
+
+### 2. PreviewDriver: stream both passes, drop the buffers
+- **Coord table** (`buildAndSendCoordTable` → `streamCoordTable`): compute the per-axis lattice step `s` (1 = full res; >1 when the light count exceeds the cap or adaptive downscale raised it). `beginBinaryFrame(coordCount*3 + …)`, then walk `forEachCoord` pushing scaled (x,y,z) for lights on the lattice (`x%s && y%s && z%s`); `endBinaryFrame`. No `coords_`.
+- **Colour frame** (`sendFrame`):
+ - **stride 1:** `beginBinaryFrame(n*3)`, then push the producer buffer directly. If `cpl==3` it's one push of `sourceBuffer_->data()`; if `cpl!=3` (RGBW) push per-light 3 bytes through a tiny stack temp. No `rgb_`.
+ - **stride > 1:** walk `forEachCoord`; for each light on the lattice push its 3 colour bytes from `sourceBuffer_[idx]`. Same predicate as the coord table → same subset/order. No `sampledIdx_`.
+- Remove members: `coords_`, `rgb_`, `sampledIdx_`/`sampledIdxCap_`, and the `~PreviewDriver` delete.
+- Keep: the **cap** (now just "downsample above N points" — bounds the per-frame work/wire size, not a buffer), the **adaptive downscale** (latency + pending-drop driven), `coordPending_` retry, the u32 count, the browser count/stride guard.
+
+### 3. HttpServerModule: remove the staging machinery
+With both passes streamed, the staging buffer + `wsPreviewBuf_/Cap_/Len_/Sent_[]`, the stage-vs-DIRECT branch in `broadcastBinary`, `drainWsSends`, `directBroadcast`, and the drain-tick/stuck-client guard are no longer used by the preview. `broadcastBinary` (the chunk-array form) and `lastDrainTicks` may become unused → remove what's dead. (Adaptive downscale now keys off `endBinaryFrame()` returning false / the coord-pending retry, not `lastDrainTicks` — confirm and simplify.)
+
+### 4. Tests + docs
+- `unit_PreviewDriver`: update the `CaptureBroadcaster` mock to implement begin/push/end (accumulate pushed bytes into `lastCoord`/`lastFrame`). Keep the assertions (count, header sizes, lattice regularity, full-res-not-downsampled, coord-pending retry). Add: colour frame at stride 1 equals the source buffer (1:1, no copy).
+- `docs/moonmodules/light/drivers/PreviewDriver.md` + `core/HttpServerModule.md`: rewrite to the streamed model (no buffers; positions once; colours 1:1 from the producer buffer; every-Nth downsample; begin/push/end wire).
+- `docs/architecture.md`: add the preview **memory model** to the output-stage/UI section — the preview streams the producer buffer with no intermediate copy; this is the data-over-objects / minimal-memory principle applied to the preview.
+
+## Files
+- `src/core/BinaryBroadcaster.h` — begin/push/end (done); remove `broadcastBinary` chunk-form + `lastDrainTicks` if dead.
+- `src/core/HttpServerModule.h/.cpp` — begin/push/end impl (done); remove staging buffer + drain machinery + stage/DIRECT.
+- `src/light/drivers/PreviewDriver.h` — stream both passes; drop `coords_`/`rgb_`/`sampledIdx_`; keep cap + adaptive.
+- `test/unit/light/unit_PreviewDriver.cpp` — mock + assertions for the streamed model.
+- docs: PreviewDriver.md, HttpServerModule.md, architecture.md.
+
+## Verification
+- Host: build (-Werror), ctest, scenarios, spec, platform-boundary.
+- ESP32: S3 + classic build.
+- **Classic 128² (the target):** with `coords_`/`rgb_`/`sampledIdx_`/staging gone, confirm the render buffer allocates AND the preview streams at 128² without those competing 49 KB blocks; measure `freeInternal`/`maxBlock` to confirm the contiguous-RAM pressure is relieved. Confirm full-res streams 1:1 (no moiré) and a grid past the cap downsamples cleanly (no moiré, matched colour/coord counts).
+- S3/P4: confirm no regression (full-res 128²+ still streams; adaptive downscale still engages on a slow link).
+
+## Risks / notes
+- **Streaming is synchronous on the preview loop** (rate-limited ≤ fps, off the LED render tick). A slow client is closed (bounded), never an unbounded tick stall. The adaptive downscale shrinks frames on slow links so per-tick send stays small.
+- **Multi-client**: each pushed slice fans to all clients in order; a forward-only producer (forEachCoord / buffer walk) is walked once per frame, slices sent to all. Fine for the handful of WS clients.
+- **cpl≠3 (RGBW)** stays a per-light 3-byte push (no buffer); cpl==3 is the bulk 1:1 push.
+- This is a net **subtraction**: removes ~3 buffers + the staging/drain code; the colour hot path becomes "stream the buffer."
diff --git a/docs/moonmodules/core/HttpServerModule.md b/docs/moonmodules/core/HttpServerModule.md
index cb09c41..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)` stages one binary WS message (FIN+binary opcode) for non-blocking fan-out to every connected client — it prepends the WS frame header (16-bit length, or the 64-bit form above 64 KB) and copies the bytes; the meaning is 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 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` **stages the frame and returns** — it never blocks the render task on the socket. The frame is held in one buffer with a per-client byte offset; `drainWsSends()` (called from `loop20ms`, the transport poll) flushes each client's remaining bytes via the non-blocking `TcpConnection::writeSome`, a slice per tick, so a frame larger than the lwIP send buffer streams across ticks instead of dropping. **Backpressure:** `broadcastBinary` returns `false` and drops a new frame while the previous one is still draining (one buffer, newest-wins) — the producer reads that as "the link can't keep up". `lastDrainTicks()` reports how long the last frame took to drain (the producer's adaptive-resolution signal). A genuinely stuck client (no progress for ~3 s) is closed so it can't freeze the stream for the others; the browser auto-reconnects. This producer (stage) → consumer (drain) split is the seam the [two-task render/transport split](../../architecture.md) will later host on separate cores.
+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` + `lastDrainTicks` + `clientGeneration`); the light-domain `PreviewDriver` holds a `BinaryBroadcaster*` and pushes each frame's bytes to it. `main.cpp` wires `PreviewDriver`'s broadcaster to the HttpServerModule instance — the only file that knows both. The preview's point budget (RAM-derived) 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 f036c19..ad08b2f 100644
--- a/docs/moonmodules/core/ImprovProvisioningModule.md
+++ b/docs/moonmodules/core/ImprovProvisioningModule.md
@@ -20,7 +20,7 @@ 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`).
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 6882bfa..9e99b8b 100644
--- a/docs/moonmodules/light/drivers/PreviewDriver.md
+++ b/docs/moonmodules/light/drivers/PreviewDriver.md
@@ -10,11 +10,11 @@ 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:u32][bx:u8][by:u8][bz:u8][stride:u16][ (x:u8, y:u8, z:u8) × count ]` (10-byte header)
@@ -30,14 +30,19 @@ Two binary message types (first byte selects):
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 (spatial downsample + adaptive)
+**No preview-side buffers.** Both messages STREAM — neither holds a copy of a frame:
+
+- **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).
-A preview frame is **staged once and drained across transport-poll ticks** — `HttpServerModule::broadcastBinary` copies the frame into a single staging buffer and returns (never blocking the render task on the socket); `drainWsSends()` (on the HTTP `loop20ms`) streams it to every client a slice at a time via non-blocking `writeSome`. So a frame larger than one `writev`/the lwIP send buffer no longer drops the connection — it just takes a few ticks to send. See [HttpServerModule](../../core/HttpServerModule.md) for the transport contract.
+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)
-Two things bound the point count:
+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) and any grid under the cap send every light (`stride` = 1, exact).
-- **Adaptive downscale** — the driver watches `broadcaster_->lastDrainTicks()` (how many ticks the last frame took to fully send). Sustained high latency → coarsen the lattice (`stride`++) so frames shrink; a low-latency stretch → refine back toward full resolution (hysteresis stops oscillation). The current factor rides the `0x03` `stride` field to the browser's status line.
+- **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.
diff --git a/src/core/BinaryBroadcaster.h b/src/core/BinaryBroadcaster.h
index 4374a83..033bdae 100644
--- a/src/core/BinaryBroadcaster.h
+++ b/src/core/BinaryBroadcaster.h
@@ -2,6 +2,7 @@
#include "platform/platform.h" // platform::WriteChunk
#include
+#include // size_t
namespace mm {
@@ -11,18 +12,19 @@ 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 {
- // Stage one binary WS frame (the implementation prepends the WS header) for non-blocking
- // fan-out to all clients. Returns true if the frame was accepted, false if DROPPED because
- // a previous frame is still draining (backpressure) — the producer reads that as "the link
- // can't keep up at this rate" and can adapt (e.g. PreviewDriver downscales the preview).
- virtual bool broadcastBinary(const platform::WriteChunk* payload, int chunkCount) = 0;
-
- // How many transport-poll ticks the LAST fully-sent frame took to drain to all clients
- // (1 = went out immediately; higher = the link is backpressured). This is the real
- // "can the link keep up" signal — unlike a dropped-frame count, which a producer running
- // faster than the per-frame drain trips even on a healthy link. PreviewDriver reads this
- // to adapt its resolution: high latency → downscale, low → refine back to full.
- virtual uint16_t lastDrainTicks() const = 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
diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp
index 155c7c7..c787664 100644
--- a/src/core/HttpServerModule.cpp
+++ b/src/core/HttpServerModule.cpp
@@ -38,24 +38,14 @@ void HttpServerModule::setup() {
void HttpServerModule::teardown() {
for (auto& ws : wsClients_) ws.close();
server_.close();
- if (wsPreviewBuf_) { platform::free(wsPreviewBuf_); wsPreviewBuf_ = nullptr; }
- wsPreviewCap_ = wsPreviewLen_ = 0;
- for (auto& s : wsPreviewSent_) s = 0;
}
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)
- }
-
- // Drain the live-preview frame queued by broadcastBinary() — a slice per tick, never
- // blocking. This is the consumer side of the preview producer/consumer handoff; the
- // producer (PreviewDriver) only stages bytes, the transport poll sends them. (When the
- // §145 two-task split lands, this call moves to the consumer/network task unchanged.)
- drainWsSends();
+ if (conn.valid()) handleConnection(conn);
}
void HttpServerModule::loop1s() {
@@ -1081,11 +1071,8 @@ void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, con
if (!wsClients_[i].valid()) {
wsClients_[i] = std::move(conn);
// A fresh client joined — bump the generation so stateful producers
- // (PreviewDriver's coordinate table) re-send their priming message now.
+ // (PreviewDriver's coordinate table) re-stream their priming message now.
wsClientGeneration_++;
- // Start this client at the END of any in-flight preview frame so it doesn't
- // receive a half-frame mid-stream; it picks up cleanly from the next frame.
- wsPreviewSent_[i] = wsPreviewLen_;
return;
}
}
@@ -1134,117 +1121,64 @@ bool HttpServerModule::sendWsTextFrame(platform::TcpConnection& conn, const char
return conn.write(reinterpret_cast(data), len);
}
-bool HttpServerModule::broadcastBinary(const platform::WriteChunk* payload, int chunkCount) {
- if (!payload || chunkCount <= 0) return false;
-
- // 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 false;
-
- bool anyClient = false;
- for (auto& ws : wsClients_) if (ws.valid()) { anyClient = true; break; }
- if (!anyClient) return false;
-
- // BACKPRESSURE: the staging buffer holds ONE frame, fanned out to all clients. While any
- // client is still draining it (wsPreviewLen_ != 0), refuse a new frame — overwriting it
- // would corrupt the in-flight sends. The new frame is DROPPED (return false → the producer
- // reads this as "the link can't keep up" and downscales); this is what keeps a slow client
- // from ever stalling the render task. drainWsSends() clears wsPreviewLen_ when all are done.
- if (wsPreviewLen_ != 0) return false;
+// 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;
+}
- // WebSocket binary frame header. 16-bit length form up to 65535 B; the 64-bit form
- // (RFC 6455) above that, so a full-resolution preview frame (tens of KB to >MB) is legal.
+// 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 = 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;
+ 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++) {
+ for (int i = 0; i < 8; i++)
wsHeader[2 + i] = static_cast((static_cast(totalLen) >> (56 - 8 * i)) & 0xFF);
- }
wsHeaderLen = 10;
}
-
- // Stage the whole frame (header + payload) ONCE into the owned buffer, then return — the
- // socket writes happen later in drainWsSends() on the transport poll, never here on the
- // render task. (This copy is the producer→consumer handoff the §145 two-task split needs;
- // the staging buffer is the queue.) Grow the buffer if a bigger grid needs more room.
- const size_t frameLen = static_cast(wsHeaderLen) + totalLen;
- if (wsPreviewCap_ < frameLen) {
- if (wsPreviewBuf_) platform::free(wsPreviewBuf_);
- wsPreviewBuf_ = static_cast(platform::alloc(frameLen));
- wsPreviewCap_ = wsPreviewBuf_ ? frameLen : 0;
- }
- if (!wsPreviewBuf_) { wsPreviewCap_ = 0; return false; } // OOM: drop the frame, stay healthy
-
- std::memcpy(wsPreviewBuf_, wsHeader, wsHeaderLen);
- size_t off = wsHeaderLen;
- for (int i = 0; i < chunkCount; i++) {
- std::memcpy(wsPreviewBuf_ + off, payload[i].data, payload[i].len);
- off += payload[i].len;
+ for (auto& ws : wsClients_) {
+ if (ws.valid() && !sendAllOrClose(ws, wsHeader, static_cast(wsHeaderLen)))
+ wsFrameAllSent_ = false;
}
- wsPreviewLen_ = frameLen;
- wsPreviewAge_ = 0;
- wsPreviewDrainTicks_ = 0;
- for (int i = 0; i < MAX_WS_CLIENTS; i++) wsPreviewSent_[i] = 0; // every client starts at 0
- return true;
}
-void HttpServerModule::drainWsSends() {
- if (wsPreviewLen_ == 0) return; // nothing queued
- wsPreviewDrainTicks_++; // this frame has now been draining for one more tick
-
- // Push each client's remaining bytes (non-blocking, never spins). We own the per-client
- // offset, so a mid-frame partial is correct — the WS message finishes across successive
- // loop20ms calls. The frame stays staged until EVERY client has drained it (or dropped
- // out); only then is wsPreviewLen_ cleared so broadcastBinary stages the next one. A slow
- // client just lags its own offset; it can't overwrite the buffer or stall the others.
- bool allDone = true;
- size_t progressed = 0;
- for (int i = 0; i < MAX_WS_CLIENTS; i++) {
- platform::TcpConnection& ws = wsClients_[i];
- if (!ws.valid()) continue;
- if (wsPreviewSent_[i] >= wsPreviewLen_) continue; // this client already finished
- int n = ws.writeSome(wsPreviewBuf_ + wsPreviewSent_[i], wsPreviewLen_ - wsPreviewSent_[i]);
- if (n < 0) { // socket error — drop this client, not the frame
- ws.close();
- wsPreviewSent_[i] = wsPreviewLen_; // mark done so it doesn't hold up the frame
- continue;
- }
- progressed += static_cast(n);
- wsPreviewSent_[i] += static_cast(n);
- if (wsPreviewSent_[i] < wsPreviewLen_) allDone = false; // still bytes to send next tick
- }
- if (allDone) {
- wsPreviewLastDrainTicks_ = wsPreviewDrainTicks_; // how long this frame took (latency)
- wsPreviewLen_ = 0; wsPreviewAge_ = 0;
- return;
- }
-
- // Stuck-client guard. Count only ticks with NO forward progress (a big frame on a healthy
- // link legitimately spans many ticks — any progress resets the counter). If a client
- // wedges (TCP window stuck, never erroring) and nothing moves for kPreviewMaxDrainTicks,
- // we must free the buffer for the next frame — BUT a client still mid-send has a half-sent
- // WS message, and staging a new frame would splice its bytes into that message (a torn,
- // garbled frame in the browser). So CLOSE any unfinished client instead of just abandoning:
- // a clean reconnect resyncs it. Finished clients are untouched.
- if (progressed > 0) { wsPreviewAge_ = 0; return; }
- if (++wsPreviewAge_ >= kPreviewMaxDrainTicks) {
- for (int i = 0; i < MAX_WS_CLIENTS; i++) {
- if (wsClients_[i].valid() && wsPreviewSent_[i] < wsPreviewLen_) wsClients_[i].close();
- }
- wsPreviewLen_ = 0;
- wsPreviewAge_ = 0;
+void HttpServerModule::pushBinaryFrame(const uint8_t* data, size_t len) {
+ if (!data || len == 0) return;
+ for (auto& ws : wsClients_) {
+ 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 dd95784..444f615 100644
--- a/src/core/HttpServerModule.h
+++ b/src/core/HttpServerModule.h
@@ -34,15 +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.
- bool broadcastBinary(const platform::WriteChunk* payload, int chunkCount) override;
- // Bumped on each new WS client (see handleWebSocketUpgrade). PreviewDriver watches
- // it to re-send its coordinate table the moment a fresh page connects, so a refresh
- // shows the preview immediately instead of waiting for the next ~1 Hz re-broadcast.
+ // 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_; }
- uint16_t lastDrainTicks() const override { return wsPreviewLastDrainTicks_; }
// 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
@@ -94,31 +95,18 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
platform::TcpConnection wsClients_[MAX_WS_CLIENTS];
uint32_t wsClientGeneration_ = 0; // ++ on each new WS client; see clientGeneration()
- // Live-preview send queue. broadcastBinary() copies ONE in-flight frame here and
- // returns — it never blocks the render task on the socket. drainWsSends() (called from
- // loop20ms, the transport poll) flushes it across ticks as each client's socket accepts
- // bytes. The frame is staged ONCE (one buffer, not ×clients — the no-PSRAM RAM budget);
- // the only per-client state is a sent-byte offset, so the same staged frame fans out to
- // every connected client. Backpressure is PER CLIENT: broadcastBinary refuses a new frame
- // while ANY client is still draining the previous one (the buffer is single — we can't
- // overwrite it mid-send), so a slow browser drops frames (its offset just lags) without
- // ever stalling the tick or overflowing. This producer→consumer handoff is the seam the
- // architecture's two-task split (§145) will host on the consumer/network task.
- uint8_t* wsPreviewBuf_ = nullptr; // owned; WS header + payload of one frame
- size_t wsPreviewCap_ = 0; // allocated capacity (bytes)
- size_t wsPreviewLen_ = 0; // bytes of the current frame (0 = idle/empty)
- size_t wsPreviewSent_[MAX_WS_CLIENTS] = {}; // per-client bytes already written
- uint16_t wsPreviewAge_ = 0; // consecutive NO-PROGRESS drain ticks
- uint16_t wsPreviewDrainTicks_ = 0; // ticks the CURRENT frame has been draining
- uint16_t wsPreviewLastDrainTicks_ = 1; // ticks the last COMPLETED frame took (lastDrainTicks)
- // Stuck-client guard: counts only drain ticks where NOT ONE byte moved (any progress
- // resets it), so a big frame on a healthy link legitimately spans many ticks. If a client
- // wedges (TCP window stuck, never erroring) and nothing moves for this many ticks, the
- // frame is abandoned so the preview never freezes for everyone; the lagging client resyncs
- // on the next self-contained frame. ~3 s of ZERO progress at 20 ms/tick — long enough that
- // a momentarily-busy (rendering-bound) browser, which still reads a little each tick, is
- // never killed; only a genuinely wedged socket trips it.
- static constexpr uint16_t kPreviewMaxDrainTicks = 150;
+ // 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.
@@ -185,9 +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);
- // Flush the live-preview staging buffer to its client a slice at a time (non-blocking).
- // Called each loop20ms; finishes a frame across as many ticks as the socket needs.
- void drainWsSends();
+ // 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/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h
index 71b7d6d..de9cee3 100644
--- a/src/light/drivers/NetworkSendDriver.h
+++ b/src/light/drivers/NetworkSendDriver.h
@@ -37,12 +37,17 @@ 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. A start offset for
+ // arbitrary slices is a planned follow-up across all drivers.
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 +118,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 *
diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h
index 430fbdb..635fb83 100644
--- a/src/light/drivers/PreviewDriver.h
+++ b/src/light/drivers/PreviewDriver.h
@@ -4,7 +4,6 @@
#include "light/light_types.h" // lengthType, nrOfLightsType
#include "core/BinaryBroadcaster.h"
#include "platform/platform.h"
-#include // std::nothrow
namespace mm {
@@ -70,58 +69,47 @@ class PreviewDriver : public DriverBase {
if (now - lastSendTime_ < interval) return; // rate-limit gate
lastSendTime_ = now;
- // The coordinate table is sent only when the geometry actually changes
- // (onBuildState — a grid resize, layout/LUT rebuild) or when the UI asks for it
- // (a new WS client bumps the broadcaster's clientGeneration, so a page refresh
- // gets the positions immediately). NOT per-frame and NOT on a timer: rebuilding
- // the full table every tick would starve the render loop, and the colour frames
- // below already reference the last-sent positions. coordCount_==0 covers the cold
- // case where the layout wasn't wired yet at onBuildState time.
+ // 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_) {
+ if (coordCount_ == 0 || gen != lastClientGen_ || coordPending_) {
lastClientGen_ = gen;
- buildAndSendCoordTable(); // sets coordPending_ if the 0x03 was dropped
- } else if (coordPending_) {
- // A previous coord table was dropped under backpressure. Retry it (no rebuild —
- // the geometry hasn't changed, just re-broadcast the same bytes) until it lands.
- coordPending_ = !sendCoordTable();
+ buildAndSendCoordTable(); // streams positions; sets coordPending_ if not all clients got it
}
- // Hold colour frames until the browser has the matching coordinate table: a 0x02 with
- // the new count plotted against the old coords would be skipped by the browser's
- // count-mismatch guard, and if the 0x03 stays dropped that would freeze the preview.
- if (!coordPending_) 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 downscaling, driven by DRAIN LATENCY (not dropped frames). A dropped frame
- // is normal — at fps=24 the producer naturally outruns the per-frame socket drain even
- // on a fast link, so "frame dropped" over-triggers. The real signal is how many
- // transport-poll ticks the last frame took to fully send: 1-2 ticks = the link keeps
- // up; many ticks = genuinely backpressured. Coarsen (downscale_++) when latency is high
- // for a sustained run; refine (downscale_--) when it's been low. Hysteresis via the
- // streaks stops oscillation. A change rebuilds the coordinate table (the lattice
- // changed) and re-primes the browser, whose status line shows the new factor. Skip the
- // adaptation while a coord table is still pending — don't stack another rebuild on top.
- const uint16_t drainTicks = broadcaster_ ? broadcaster_->lastDrainTicks() : 1;
- if (coordPending_) {
- // pending coord table: don't change the factor until it lands
- } else if (drainTicks > kDrainTicksHigh) {
+ // 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 if (drainTicks <= kDrainTicksLow) {
+ } else {
slowStreak_ = 0;
if (downscale_ > 1 && ++cleanStreak_ >= kUpscaleAfterFast) {
cleanStreak_ = 0;
downscale_--;
buildAndSendCoordTable();
}
- } else {
- // Mid-range latency: stable, hold the current factor (don't drift either way).
- slowStreak_ = 0;
- cleanStreak_ = 0;
}
}
@@ -149,129 +137,124 @@ class PreviewDriver : public DriverBase {
by_ = scaleAxis(layer_->physicalHeight());
bz_ = scaleAxis(layer_->physicalDepth());
- // Downsample SPATIALLY, not by flat index. Picking every Nth light in driver
- // order moirés on a 2D grid (the sampled column drifts each row when N doesn't
- // divide the width → diagonal blank streaks). Instead, keep a light only when its
- // grid position falls on a coarse lattice (qx%sx==0 && qy%sy==0 && qz%sz==0): a
- // regular sub-grid, no drift — and it generalises to 3D (cube, sphere) since the
- // lattice is per-axis on the real coordinates, not the index. sx/sy/sz are chosen
- // so the kept count fits MAX_PREVIEW_POINTS. The kept lights' DRIVER indices are
- // recorded in sampledIdx_ so the per-frame colour pass sends the SAME lights in the
- // SAME order (lockstep); the wire stride is 1 (the browser maps colour k → coord k).
+ // 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 sx = 1, sy = 1, sz = 1;
- // Grow the per-axis lattice stride uniformly until the lattice-point count fits.
- // (Active axes only — a flat 2D grid leaves sz at 1.)
- auto latticeCount = [&](nrOfLightsType s) {
- nrOfLightsType cx = (ax + s - 1) / s, cy = (ay + s - 1) / s, cz = (az + s - 1) / s;
- return static_cast(cx) * cy * cz;
- };
nrOfLightsType s = 1;
- while (latticeCount(s) > MAX_PREVIEW_POINTS) s++;
- if (s < downscale_) s = downscale_; // adaptive: never finer than the link sustains
- sx = sy = sz = s;
-
- // Buffers sized to the points we'll actually send — min(n, cap) — not the full cap:
- // an 8×8 grid uses 192 B, not 65 KB. A grid ≤ ~145² sends every light (s==1); only a
- // bigger one downsamples (s>1) and the lattice count is the upper bound. coords_/rgb_
- // are PSRAM-backed (platform::alloc); sampledIdx_ is the index list.
- const nrOfLightsType sendCap = n < MAX_PREVIEW_POINTS ? n : MAX_PREVIEW_POINTS;
- if (!coords_.data() || coords_.count() < sendCap) {
- coords_.allocate(sendCap, 3); // owned u8×3 position buffer
- }
- if (!sampledIdx_ || sampledIdxCap_ < sendCap) {
- delete[] sampledIdx_;
- // nothrow so the !sampledIdx_ guard below actually catches OOM — plain new aborts
- // on the ESP32, which would crash the device instead of degrading the preview.
- sampledIdx_ = new (std::nothrow) nrOfLightsType[sendCap];
- sampledIdxCap_ = sampledIdx_ ? sendCap : 0;
+ 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 (!coords_.data() || !sampledIdx_) { coordCount_ = 0; coordPending_ = false; return; }
+ if (s < downscale_) s = downscale_; // adaptive: never finer than the link sustains
+ previewStride_ = s;
- struct PackCtx {
- PreviewDriver* self; uint8_t* dst; nrOfLightsType* idxOut;
- nrOfLightsType sx, sy, sz, out, cap;
- };
- PackCtx pc{this, coords_.data(), sampledIdx_, sx, sy, sz, 0, sendCap};
- layouts->forEachCoord([](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) {
- auto* p = static_cast(c);
- // Keep only lattice points — a regular spatial sub-sample (works in 2D and 3D).
- if (x % p->sx != 0 || y % p->sy != 0 || z % p->sz != 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->idxOut[p->out] = idx; // driver index → colour pass reads the same lights
- p->out++;
- }, &pc);
- coordCount_ = pc.out;
- // The wire "stride" field now carries the effective DOWNSCALE factor (per axis) for the
- // browser's status line — colour k still maps 1:1 to coord k (the index list picks the
- // lights). 1 = full resolution; >1 = "showing 1/s of the lights, link can't keep up".
- stride_ = 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; }
- // Header: [0x03][count:u32 LE][bx][by][bz][stride:u16 LE] (10 bytes)
- uint8_t* h = coordHeader_;
+ // 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) & 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(stride_ & 0xFF);
- h[9] = static_cast(stride_ >> 8);
+ h[8] = static_cast(s & 0xFF);
+ h[9] = static_cast(s >> 8);
- // The coordinate table MUST reach the browser before colour frames that carry the new
- // count — else the browser's count-mismatch guard skips every colour frame and the
- // preview freezes. broadcastBinary can DROP the 0x03 under backpressure (a colour frame
- // still draining), so track whether it landed; loop() retries while pending and holds
- // off colour frames until it lands.
- coordPending_ = !sendCoordTable();
+ 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. Returns the broadcaster's accept/drop
- // result (false = dropped under backpressure) so loop() can drive adaptive downscaling.
- // Public so tests can drive it without the loop() rate-limit.
+ // 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();
+ const uint8_t cpl = sourceBuffer_->channelsPerLight();
+ const nrOfLightsType n = sourceBuffer_->count();
+ const nrOfLightsType s = previewStride_;
- // RGB for exactly the lights the coordinate table sampled, in the same order —
- // sampledIdx_[k] is the driver index of sent point k (built in buildAndSendCoordTable
- // by the spatial lattice). Iterating that list keeps colour ↔ position in lockstep
- // regardless of how the sampling chose them.
- if (!rgb_.data() || rgb_.count() < coordCount_) rgb_.allocate(coordCount_, 3);
- if (!rgb_.data() || !sampledIdx_) return false;
- uint8_t* dst = rgb_.data();
- nrOfLightsType out = 0;
- for (nrOfLightsType k = 0; k < coordCount_; k++) {
- nrOfLightsType i = sampledIdx_[k];
- if (i >= n) continue; // layout shrank since the table was built
- 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++;
- }
-
- // Header: [0x02][count:u32 LE][stride:u16 LE] (7 bytes)
+ // 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) & 0xFF);
- header[3] = static_cast((out >> 16) & 0xFF);
- header[4] = static_cast((out >> 24) & 0xFF);
- header[5] = static_cast(stride_ & 0xFF);
- header[6] = static_cast(stride_ >> 8);
+ 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);
- const platform::WriteChunk payload[] = {
- { header, sizeof(header) },
- { dst, static_cast(out) * 3 },
- };
- return broadcaster_->broadcastBinary(payload, 2);
+ 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; uint8_t cpl, s;
+ uint8_t buf[1536]; uint16_t fill;
+ };
+ ColCtx col{broadcaster_, src, n, cpl, static_cast(s > 255 ? 255 : s), {}, 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:
@@ -301,55 +284,29 @@ class PreviewDriver : public DriverBase {
return s > 255 ? 255 : static_cast(s);
}
- // Returns whether the broadcaster accepted the table (false = dropped under backpressure).
- bool sendCoordTable() {
- if (!broadcaster_ || coordCount_ == 0 || !coords_.data()) return false;
- const platform::WriteChunk payload[] = {
- { coordHeader_, sizeof(coordHeader_) },
- { coords_.data(), static_cast(coordCount_) * 3 },
- };
- return 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_[10] = {}; // [0x03][count:u32][bx][by][bz][stride:u16]
- nrOfLightsType coordCount_ = 0; // points actually sent
- nrOfLightsType stride_ = 1; // wire field: the lattice/downscale factor (1 = full res)
- bool coordPending_ = false; // a coord table was dropped under backpressure; loop() retries it
- // Driver indices of the sampled lights (sent point k = driver light sampledIdx_[k]).
- // Built in buildAndSendCoordTable, read by sendFrame so colour ↔ position stay locked.
- // Raw heap (not Buffer) because it holds indices, not the u8×3 the Buffer helper packs.
- nrOfLightsType* sampledIdx_ = nullptr;
- nrOfLightsType sampledIdxCap_ = 0;
+ 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 lastClientGen_ = 0; // last seen broadcaster_->clientGeneration() — re-send coords on change
- // Adaptive downscaling. The preview streams at the finest resolution the WS link sustains;
- // when a frame takes too many transport ticks to drain (high latency) we coarsen the
- // lattice (downscale_++) so frames shrink and catch up. A low-latency stretch steps it
- // back toward full resolution. Hysteresis (streak thresholds) stops oscillation. downscale_
- // is an extra floor on the per-axis lattice stride, so it composes with the RAM-cap
- // downsample. It rides the coord header's stride field to the browser, which shows
- // "preview 1/N · link limited" while > 1. (kept ≥1; 1 = full resolution / no downscale.)
+ // 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 HIGH-latency frames
- uint8_t cleanStreak_ = 0; // consecutive LOW-latency frames
- // Latency thresholds in transport-poll ticks (~20 ms each). A frame that drains in ≤2 ticks
- // means the link has headroom; >4 ticks means it's struggling. The streak counts give
- // hysteresis: coarsen after a short slow run, refine after a longer fast run (slower to
- // refine so it doesn't flap right back into trouble).
- static constexpr uint16_t kDrainTicksLow = 2;
- static constexpr uint16_t kDrainTicksHigh = 4;
- static constexpr uint8_t kDownscaleAfterSlow = 4; // coarsen after this many slow frames
- static constexpr uint8_t kUpscaleAfterFast = 20; // refine after this many fast frames
-
-public:
- ~PreviewDriver() override { delete[] sampledIdx_; }
+ 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..b490673 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).
@@ -261,6 +266,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 +321,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/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp
index 8d7fd4b..11c606a 100644
--- a/src/platform/desktop/platform_desktop.cpp
+++ b/src/platform/desktop/platform_desktop.cpp
@@ -701,14 +701,25 @@ bool TcpConnection::write(const uint8_t* data, size_t len) {
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 (sockWouldBlock()) return 0; // buffer full — try later
+ if (wouldBlock) return 0; // buffer full — try later
#ifndef _WIN32
- if (errno == EINTR) return 0; // interrupted — try later
+ if (interrupted) return 0; // interrupted — try later
#endif
- return -1; // real socket error
+ return -1; // real socket error
}
diff --git a/src/ui/index.html b/src/ui/index.html
index 9f786e3..d837c43 100644
--- a/src/ui/index.html
+++ b/src/ui/index.html
@@ -37,6 +37,7 @@
WebSocket link can't stream every light fast enough. -->
+ Dot size
№
⌖
diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js
index b738212..a4d39e6 100644
--- a/src/ui/preview3d.js
+++ b/src/ui/preview3d.js
@@ -54,7 +54,10 @@ let showSeqNumbers_ = false; // sequence-number overlay toggle (preview-numbers
// 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.
-let dotScale_ = parseFloat(localStorage.getItem("mm_preview_dot") || "1") || 1;
+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
@@ -377,7 +380,7 @@ function setupLayout() {
if (dotSlider) {
dotSlider.value = String(dotScale_);
dotSlider.addEventListener("input", () => {
- dotScale_ = parseFloat(dotSlider.value) || 1;
+ dotScale_ = clampDot(parseFloat(dotSlider.value));
localStorage.setItem("mm_preview_dot", String(dotScale_));
if (lastVerts) redrawCached();
});
@@ -479,21 +482,21 @@ function parsePreviewCoords(view, buf) {
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;
// 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 counts disagree, a geometry rebuild
- // (a resize, or the device's adaptive downscale changing the lattice) is mid-flight: the
- // new-count colours would land on the old-count positions, mapping colours to the wrong
- // lights (a visibly scrambled frame). Skip such a frame; the matching coord table arrives
- // within ~1 frame and they realign cleanly.
- if (count !== previewCoordCount_) return;
+ // 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;
const n = count;
if (!vertsBuf || vertsBuf.length < n * 6) vertsBuf = new Float32Array(n * 6);
diff --git a/src/ui/style.css b/src/ui/style.css
index a3532cf..8c643ff 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -283,6 +283,11 @@ body {
}
.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: rect(0 0 0 0); white-space: nowrap; border: 0;
+}
.preview-bar-spacer { flex: 1; }
.preview-bar-btn {
background: transparent;
diff --git a/test/unit/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp
index dbdc827..8efdfff 100644
--- a/test/unit/light/unit_PreviewDriver.cpp
+++ b/test/unit/light/unit_PreviewDriver.cpp
@@ -21,28 +21,37 @@
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;
- uint32_t generation = 0; // bump to simulate a new client connecting
- bool acceptNext = true; // false → drop colour frames (simulate backpressure)
- bool dropCoord = false; // true → drop coord tables too (simulate a 0x03 lost to backpressure)
-
- bool 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 false;
- if (dropCoord && buf[0] == 0x03) return false; // coord table dropped under backpressure
- if (!acceptNext && buf[0] == 0x02) return false; // colour frame dropped on demand
- if (buf[0] == 0x03) { coordMsgs++; lastCoord = buf; }
- else if (buf[0] == 0x02) { frameMsgs++; lastFrame = buf; }
+ 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 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; }
- uint16_t drainTicks = 1; // simulate link latency for the adaptive-downscale test
- uint16_t lastDrainTicks() const override { return drainTicks; }
// 0x03 = [type][count:u32][bx][by][bz][stride:u16] (10-byte header)
// 0x02 = [type][count:u32][stride:u16] (7-byte header)
@@ -160,6 +169,23 @@ TEST_CASE("PreviewDriver downsamples a large layout on a regular spatial lattice
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.
TEST_CASE("PreviewDriver fps default") {
mm::PreviewDriver driver;
From e0e5b328e277892f9611d6650e8c3fb47dd8d4fe Mon Sep 17 00:00:00 2001
From: ewowi
Date: Tue, 23 Jun 2026 16:00:18 +0200
Subject: [PATCH 06/10] Add grid serpentine; measure mapping identity; process
review findings
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds a serpentine control to GridLayout so a dense grid can be wired in reverse on odd rows — a one-control lever to exercise the non-identity mapping path without a sparse layout or modifier. The Layer now MEASURES whether a dense grid is in natural order (rather than assuming it) before taking the memcpy fast path, so a shuffled-but-dense grid correctly routes through the mapping LUT. Also processes a batch of CodeRabbit findings, fixes the preview not drawing on a UI refresh, names the preview's graceful-degradation model in the architecture doc, and genericises incidental "Olimex" references (the classic board's default Ethernet pin map isn't specific to that one early board).
KPI: 16384lights | PC:365KB | tick:120/89/122/9/1/326/37/15/19/119/11us(FPS:8333/11235/8196/111111/1000000/3067/27027/66666/52631/8403/90909) | ESP32:1221KB | src:97(19757) | test:68(10442) | lizard:74w
Core:
- Layer: identity mapping is now MEASURED, not declared — isNaturalOrder() walks the layout's coords once (allocation-free, cold path) and the dense memcpy fast path is taken only when driver index i actually equals box cell i. A dense-but-shuffled grid (serpentine) falls through to the box→driver LUT exactly as a sparse layout does. Replaces the count-only sparse check that would have wrongly memcpy'd a serpentine grid. No isIdentityOrder() virtual — the order is a fact of the coords, with one source of truth.
- platform (esp32): reworded the Ethernet default-pin-map comments — the classic default is the common LAN8720 RMII wiring (reset GPIO5, MDIO addr 0, clock GPIO17), described as the capability rather than as one specific board's map (Olimex demoted to an example).
Light domain:
- GridLayout: new serpentine bool control — odd rows wired in reverse (boustrophedon); forEachCoord reverses x there while still emitting the true (x,y,z), so the index→position order is what changes. Drives the non-identity mapping path on demand.
- PreviewDriver (🐇 R1): the downsampled colour pass kept its lattice stride in a uint8_t clamped to 255, so above stride 255 it disagreed with the coord table's predicate and the browser dropped every frame. Widened to the full nrOfLightsType so colour[k] lines up with coord[k] at any stride.
- RmtLedDriver (🐇 R2): cap each pin's transmit at the n lights actually encoded this frame — if the buffer shrank since the last config parse, a pin would otherwise clock out stale symbols past the encoded boundary.
- NetworkSendDriver (🐇 R3): the no-correction passthrough branch ignored light_count and sent the whole buffer; it now honours the same slice cap as the corrected path. (🐇 R4) light_count comment made present-tense.
UI:
- preview3d.js: the grid now draws the instant the coordinate table arrives (a new drawLights() helper inits GL + builds the geometry with off-LED placeholders), so a page/UI refresh shows the layout immediately instead of waiting for the first colour frame (which never comes on a paused scene).
- style.css (🐇 R6): .visually-hidden uses clip-path: inset(50%) instead of the deprecated clip: rect().
Scripts / MoonDeck:
- run_live_scenario.py, MoonDeck.md, build_esp32.py: Ethernet-default and settle-time references genericised from "Olimex" to the capability ("classic board", "default LAN8720 pins"); firmwares.json regenerated to match the build_esp32.py description edit.
Tests:
- unit_GridLayout: serpentine reverses x on odd rows (even rows L→R, odd R→L), non-serpentine unchanged.
- unit_Layer_sparse_mapping: a serpentine grid leaves the identity path and builds a box→driver LUT (with the reversed-row box→driver assertions), and returns to the memcpy fast path when toggled off.
- unit_HttpServerModule_apply: arbitrary "Olimex Gateway" test label → "Living Room".
Docs:
- architecture.md: named "graceful degradation under transport backpressure" as the transport sibling of the memory degradation cascade (resumable send + newest-wins backpressure + adaptive lattice); fixed a stale writeChunks reference to writeSome.
- GridLayout.md: documents serpentine + the measured-identity mapping. testing.md/building.md: classic-tier framing leads with the capability, board named only as measured-on provenance / example.
- backlog: captured the mid/long-term preview items (self-describing frame header, RGBW end-to-end, fixture model for moving heads, ThrottledChannel extraction).
Reviews:
- 🐇 R5 (frame-level stall budget + over-push guard): deferred into the resumable cross-tick send — it needs exactly that frame-state, so building it now then rebuilding would be throwaway.
- 🐇 R7 (Plan-20260622 stale drainWsSends line): skipped — docs/history plan archives intentionally record the approved design, not the final code, and are present-tense-exempt per CLAUDE.md.
Co-Authored-By: Claude Opus 4.8
---
docs/architecture.md | 4 ++-
docs/backlog/backlog.md | 16 +++++++++
docs/building.md | 2 +-
docs/install/firmwares.json | 2 +-
docs/moonmodules/light/layouts/GridLayout.md | 4 +--
docs/testing.md | 2 +-
scripts/MoonDeck.md | 4 +--
scripts/build/build_esp32.py | 2 +-
scripts/scenario/run_live_scenario.py | 2 +-
src/light/drivers/NetworkSendDriver.h | 7 ++--
src/light/drivers/PreviewDriver.h | 28 ++++++++-------
src/light/drivers/RmtLedDriver.h | 14 ++++++--
src/light/layers/Layer.h | 27 ++++++++++++---
src/light/layouts/GridLayout.h | 11 +++++-
src/platform/esp32/platform_config.h | 6 ++--
src/platform/esp32/platform_esp32.cpp | 4 +--
src/ui/preview3d.js | 23 ++++++++++---
src/ui/style.css | 2 +-
.../unit/core/unit_HttpServerModule_apply.cpp | 8 ++---
test/unit/light/unit_GridLayout.cpp | 34 +++++++++++++++++++
test/unit/light/unit_Layer_sparse_mapping.cpp | 33 ++++++++++++++++++
21 files changed, 188 insertions(+), 47 deletions(-)
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 e260d24..5939884 100644
--- a/docs/backlog/backlog.md
+++ b/docs/backlog/backlog.md
@@ -393,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/install/firmwares.json b/docs/install/firmwares.json
index 7384da9..f9e6904 100644
--- a/docs/install/firmwares.json
+++ b/docs/install/firmwares.json
@@ -5,7 +5,7 @@
"chip": "esp32",
"eth_only": false,
"ships": true,
- "description": "ESP32 classic — WiFi + Ethernet (RMII; per-board pins/PHY from deviceModels.json, Olimex defaults)."
+ "description": "ESP32 classic — WiFi + Ethernet (RMII; per-board pins/PHY from deviceModels.json, default LAN8720 pins)."
},
{
"name": "esp32-16mb",
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/scenario/run_live_scenario.py b/scripts/scenario/run_live_scenario.py
index 6993a9c..b360ebd 100644
--- a/scripts/scenario/run_live_scenario.py
+++ b/scripts/scenario/run_live_scenario.py
@@ -407,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)
diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h
index de9cee3..502e49c 100644
--- a/src/light/drivers/NetworkSendDriver.h
+++ b/src/light/drivers/NetworkSendDriver.h
@@ -39,8 +39,7 @@ class NetworkSendDriver : public DriverBase {
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. A start offset for
- // arbitrary slices is a planned follow-up across all drivers.
+ // the rest to ArtNet) instead of every light.
uint8_t fps = 50;
void onBuildControls() override {
@@ -146,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 635fb83..8e44961 100644
--- a/src/light/drivers/PreviewDriver.h
+++ b/src/light/drivers/PreviewDriver.h
@@ -239,10 +239,13 @@ class PreviewDriver : public DriverBase {
// 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; uint8_t cpl, s;
+ mm::BinaryBroadcaster* bc; const uint8_t* src; nrOfLightsType n, s; uint8_t cpl;
uint8_t buf[1536]; uint16_t fill;
};
- ColCtx col{broadcaster_, src, n, cpl, static_cast(s > 255 ? 255 : s), {}, 0};
+ // 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;
@@ -259,17 +262,16 @@ class PreviewDriver : public DriverBase {
private:
// Frame cap: the most points one preview frame carries before the spatial-lattice
- // downsample engages. The old ~1800 limit was the single-writev wall; that's gone now —
- // broadcastBinary enqueues the frame and HttpServerModule::drainWsSends() streams it
- // across loop20ms ticks (non-blocking), so the cap is no longer "what fits one writev"
- // but "how big a frame the device can stage + stream comfortably". That's RAM-bound:
- // the staging frame is points×3 bytes (16K LEDs = 48 KB), so a no-PSRAM classic board
- // tops out around 16K in internal RAM while PSRAM boards go far higher. Two compile-time
- // tiers off platform::hasPsram; downsampling stays as the graceful fallback above the cap.
- // Tune these 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.
+ // 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
diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h
index b490673..d21eb9f 100644
--- a/src/light/drivers/RmtLedDriver.h
+++ b/src/light/drivers/RmtLedDriver.h
@@ -231,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 */);
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/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 a6445dd..175f00f 100644
--- a/src/platform/esp32/platform_esp32.cpp
+++ b/src/platform/esp32/platform_esp32.cpp
@@ -399,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();
@@ -435,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.
diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js
index a4d39e6..35d537b 100644
--- a/src/ui/preview3d.js
+++ b/src/ui/preview3d.js
@@ -477,6 +477,10 @@ 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) {
@@ -497,7 +501,18 @@ function renderPreviewFrame(view, buf) {
// 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;
- const n = count;
+ 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;
@@ -509,9 +524,9 @@ function renderPreviewFrame(view, buf) {
vertsBuf[vi++] = previewCoords_[i * 3 + 0];
vertsBuf[vi++] = previewCoords_[i * 3 + 1];
vertsBuf[vi++] = previewCoords_[i * 3 + 2];
- vertsBuf[vi++] = rgb[i * 3] / 255;
- vertsBuf[vi++] = rgb[i * 3 + 1] / 255;
- vertsBuf[vi++] = rgb[i * 3 + 2] / 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;
diff --git a/src/ui/style.css b/src/ui/style.css
index 8c643ff..47ebe5d 100644
--- a/src/ui/style.css
+++ b/src/ui/style.css
@@ -286,7 +286,7 @@ body {
/* 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: rect(0 0 0 0); white-space: nowrap; border: 0;
+ overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
.preview-bar-spacer { flex: 1; }
.preview-bar-btn {
diff --git a/test/unit/core/unit_HttpServerModule_apply.cpp b/test/unit/core/unit_HttpServerModule_apply.cpp
index a44261a..698902d 100644
--- a/test/unit/core/unit_HttpServerModule_apply.cpp
+++ b/test/unit/core/unit_HttpServerModule_apply.cpp
@@ -208,9 +208,9 @@ TEST_CASE("apply-core: a control validator rejects bad input on the set/APPLY_OP
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\":\"Olimex Gateway\"}")
+ CHECK(http.applyOp("{\"op\":\"set\",\"module\":\"T\",\"control\":\"label\",\"value\":\"Living Room\"}")
== OpResult::Ok);
- CHECK(std::strcmp(tag->label, "Olimex Gateway") == 0);
+ 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','"',',',
@@ -218,11 +218,11 @@ TEST_CASE("apply-core: a control validator rejects bad input on the set/APPLY_OP
'"','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, "Olimex Gateway") == 0); // unchanged — no partial write
+ 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, "Olimex Gateway") == 0);
+ 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") {
From 63a8fe83eb4554a62c54355521106a3bbe7860cd Mon Sep 17 00:00:00 2001
From: ewowi
Date: Wed, 24 Jun 2026 00:11:33 +0200
Subject: [PATCH 07/10] Resumable preview colour send; closed-form downsample;
display cap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The 3D preview now streams the full-resolution colour frame resumably — drained from the producer buffer a chunk per transport poll, off the LED render tick — so a 128x128 grid previews smoothly without stalling the device, verified sustaining on all three boards (classic ~9 fps, S3 ~7.7 fps, P4 ~8 fps). The downsample for large grids is now closed-form index arithmetic instead of a per-frame coordinate walk, the point budget adapts to a sane display limit and to free memory, and a batch of smaller fixes ride along (preview wireframe box, Lines sweep range, a discovery-sweep stutter). Effective preview frame rate self-limits to what the link sustains; the LED output path is never charged for preview work.
KPI: 16384lights | PC:383KB | tick:117/87/119/9/1/320/36/15/18/116/11us(FPS:8547/11494/8403/111111/1000000/3125/27777/66666/55555/8620/90909) | ESP32:1261KB | src:97(20031) | test:68(10574) | lizard:75w
Core:
- HttpServerModule / BinaryBroadcaster: added a resumable buffered send (sendBufferedFrame / bufferedSendIdle / cancelBufferedSend) that streams one WS message from a stable caller-owned buffer, drained a memory-adaptive chunk per client on loop20ms — never spins the caller's loop, never blocks the render tick, newest-wins backpressure drop. The full-res colour frame uses it (body = the producer buffer, zero copy); the rarely-sent coord table + downsampled frames keep the synchronous begin/push/end stream. A new client resets its slot cursor and cancels any in-flight send so it starts clean.
- Layer: identity mapping is MEASURED (isNaturalOrder walks the coords once) so a dense grid in natural order takes the memcpy fast path and a shuffled/sparse one routes through the LUT — feeds the preview's closed-form vs forEachCoord choice.
Light domain:
- PreviewDriver: full-res colour frame routed through the resumable send (gated on bufferedSendIdle, so the effective fps self-limits to the link). Downsample is now closed-form for a dense grid — count, coord positions, and downsampled colours stride the box directly (z*H*W + y*W + x), no per-frame forEachCoord walk; sparse/serpentine layouts (arbitrary index<->position map) still walk forEachCoord. Point cap = min(display cap ~4096, memory-derived from maxAllocBlock) so every board downsamples a large grid to a link-friendly, visually-sufficient frame. Coord-table box extent is size-1 (the max coordinate the positions reach), so the wireframe box hugs the lights instead of sitting one cell large. Adaptive downscale keys off send latency.
- LinesEffect: the sweep now reaches index 0..N-1 (beat * N / 65536) — the prior beat * (N-1) / 65535 never hit the last row/column because beat tops out below full scale; the strip stalled one short of the far edge.
Core (Devices):
- DevicesModule: boot HTTP discovery probe timeout cut 150ms -> 30ms and the mDNS browse throttled, so the once-a-second blocking probe stops hitching animation during the ~minutes-long boot sweep (a live host on a LAN answers in a few ms).
UI:
- preview3d.js: the grid draws the instant the coordinate table arrives (a drawLights() helper renders off-LED placeholders), so a page refresh shows the layout immediately. Effective preview fps shown in the status bar as a 1-second sliding-window count (immune to bursty WS arrivals).
Tests:
- unit_PreviewDriver: CaptureBroadcaster models the resumable send; cases pin the closed-form downsample exact placement, dense vs sparse buffer selection (sphere uses the LUT-mapped driver buffer, not the dense box), full-res routing through the buffered send, resize-during-send cancel, and adaptive-fps gating. unit_GridLayout / unit_Layer_sparse_mapping cover serpentine + measured identity.
Docs / CI:
- architecture.md: named "graceful degradation under transport backpressure" as the transport sibling of the memory degradation cascade. HttpServerModule.md / PreviewDriver.md: the resumable + closed-form + display-cap model. backlog: mid/long-term preview items (self-describing frame header, RGBW, fixture model for moving heads, ThrottledChannel extraction); socket-pair WS test fixture. Plan-20260623 saved.
Reviews:
- 👾 self: further send-path changes were tried to chase a P4-Ethernet preview drop (treating lwIP ENOMEM as transient, yielding instead of spinning in sendAllOrClose, a non-fatal periodic state push, a buffered coord table) and ALL reverted — they regressed the working S3/classic 128 preview and the P4 drop turned out to be those very changes, not a pre-existing bug. The committed midpoint (resumable colour + synchronous coord table + the original spin) sustains on all three boards. Lesson: stop at the first failed fix on a working path; don't re-engineer the seam that already works.
Co-Authored-By: Claude Opus 4.8
---
docs/architecture.md | 2 +-
docs/backlog/backlog.md | 4 +
...60623 - Resumable adaptive preview send.md | 76 +++++
docs/moonmodules/core/HttpServerModule.md | 8 +-
.../light/drivers/PreviewDriver.md | 18 +-
src/core/BinaryBroadcaster.h | 22 ++
src/core/DevicesModule.h | 15 +-
src/core/HttpServerModule.cpp | 131 +++++++-
src/core/HttpServerModule.h | 36 +-
src/light/drivers/PreviewDriver.h | 312 ++++++++++++------
src/light/effects/LinesEffect.h | 19 +-
src/light/layers/Layer.h | 9 +-
src/ui/preview3d.js | 25 +-
.../light/scenario_Driver_mutation.json | 4 +-
test/unit/light/unit_PreviewDriver.cpp | 152 ++++++++-
15 files changed, 672 insertions(+), 161 deletions(-)
create mode 100644 docs/history/plans/Plan-20260623 - Resumable adaptive preview send.md
diff --git a/docs/architecture.md b/docs/architecture.md
index b1f8253..065c9c5 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -311,7 +311,7 @@ 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.
+**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**, shedding in the order video streaming does — frame rate first, then resolution: (1) the frame streams from the driver buffer with no intermediate copy, drained a memory-adaptive chunk per transport tick (a **resumable** send), and the next frame starts only when the previous one finished — so the **effective frame rate self-limits** to what the link sustains, with no loop stall; (2) only when even one frame can't drain promptly does it shed **resolution** via a spatial-lattice downsample, the same congestion-responsive, adaptive-bitrate idea behind HLS/DASH/WebRTC applied to a binary WebSocket. The point budget is itself memory-derived (per [§ Scaling to available memory](#scaling-to-available-memory)), so a tighter board downsamples sooner. The render loop is never charged more than a bounded slice per tick; the worst case is a faithful **complete** frame at a lower rate or coarser sample — never a partial/torn frame (a WebSocket message is atomic to the browser) and never a stall. This is *graceful degradation*: a fast link sees every light at full rate, a slow link sees a faithful coarser sample at a few fps, and neither stalls the device. The mechanism (resumable cross-tick send + newest-wins backpressure + adaptive frame rate + adaptive lattice) lives in `PreviewDriver` + `HttpServerModule`; 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.
diff --git a/docs/backlog/backlog.md b/docs/backlog/backlog.md
index 5939884..b1f6c93 100644
--- a/docs/backlog/backlog.md
+++ b/docs/backlog/backlog.md
@@ -434,6 +434,10 @@ Fix options: (a) make every live mutate scenario clear+rebuild its own canvas (c
## Housekeeping
+### Socket-pair fixture for HttpServerModule WS-send tests (test infra)
+
+`HttpServerModule`'s resumable preview send (`sendBufferedFrame` / `drainPreviewSend` / `cancelBufferedSend`, the newest-wins drop, the per-client cursor over `[hdr ++ body]`, the memory-adaptive chunk) has no direct unit test because driving it needs real `TcpConnection` clients whose `writeSome` returns partial / WouldBlock under control — and there's no socket-pair test fixture today. The send *contract* is covered indirectly: `unit_PreviewDriver` drives a `CaptureBroadcaster` mock for route-to-buffered / gate-on-idle / cancel-on-rebuild, and the live device sweep exercises the real drain across ticks. A loopback `socketpair()` fixture on the desktop platform (a `TcpConnection` pair where the test reads the bytes the server pushed, and can simulate a stalled receiver by not draining) would let the drain/drop/cancel/over-push paths be pinned host-side. Build it when the next core transport change lands (it'd also serve future WS tests).
+
### ESP-IDF version pinning (pending)
The build IDF is `v6.1-dev-399-gd1b91b79b5`, a dev-branch snapshot (2025-11-05) ahead of the v6.0 stable but on the unreleased v6.1 line. The version facts (what v6.0 vs v6.1 changed, the release schedule, the 30-month support policy, how to check for a newer tag) live in [building.md § ESP-IDF version](../building.md#esp-idf-version); this entry tracks only the **open decisions** the doc doesn't make. Being on a dev branch already cost us once — the missing `ESP_ROM_ELF_DIR` in the post-build gdbinit step (fixed in `build_esp32.py`). **Partly landed:** `setup_esp_idf.py` carries `PINNED_IDF_COMMIT`/`PINNED_IDF_VERSION` and **warns on drift** (installed HEAD vs pinned) — it can't `checkout` for you (it doesn't own the clone), but a silent `git pull` or a stray shallow clone is now visible. **Still to do:** (a) a MoonDeck UI banner / status dot surfacing the same drift (the CLI warning only shows during Setup), and (b) the migrate-or-stay call — stay on the pinned commit (chosen for now: it's what all targets incl. P4 were validated against), or move to `v6.1` stable (skipping v6.0, since v6.1 is close); migration is a full re-validation pass across classic/S3/P4, a deliberate task, not a pull. Until then: don't `git pull` the IDF. **Schedule note:** the v6.1-stable target of 2026-07-31 is unlikely to hold — v6.0 slipped ~1 month (planned 2026-02-27, shipped late March), and Espressif minors historically slip 2-6 weeks on the *final* even when betas land on time. So migrate **to the event** (v6.1 stable actually tagging on the releases page), not to the calendar date. `v6.0` stable is the lower-risk fallback if the dev-branch warts (`ESP_ROM_ELF_DIR`, API-churn risk) get worse before v6.1 lands.
diff --git a/docs/history/plans/Plan-20260623 - Resumable adaptive preview send.md b/docs/history/plans/Plan-20260623 - Resumable adaptive preview send.md
new file mode 100644
index 0000000..67bbf4f
--- /dev/null
+++ b/docs/history/plans/Plan-20260623 - Resumable adaptive preview send.md
@@ -0,0 +1,76 @@
+# Plan — Resumable, memory-adaptive preview send with adaptive frame rate
+
+> Saved per CLAUDE.md *Plan before implementing* (2026-06-23). Product-owner archive; agents don't auto-read it.
+
+## Problem (measured this session)
+
+At 128²+ the preview send spins synchronously until the whole frame drains, stalling PreviewDriver's loop on a slow link (observed: classic WiFi AND P4 ethernet — "uptime not progressing"). The adaptive downscale doesn't rescue it on a fast-but-saturated link, because the frame *does* eventually send (`endBinaryFrame()` true) → no struggle signal → factor stays at 1.
+
+## Core idea
+
+Three coupled changes, all flowing from one principle — **adapt to what the link and the memory actually allow, measured not assumed**:
+
+1. **Resumable send** (no spin, no buffer): a byte cursor over `{header, producerBuffer}`, drained across `loop20ms` ticks via the existing non-blocking `writeSome`. The producer buffer is already a stable contiguous block that persists across ticks, so no copy.
+2. **Adaptive frame rate**: a frame only starts when the previous one finished draining → the send rate **self-limits to link speed**, with the `fps` slider as the *ceiling*. Shed frame rate before resolution.
+3. **Memory-derived cap + chunk**: both `MAX_PREVIEW_POINTS` and the per-tick drain chunk come from `maxAllocBlock()`/free memory — per architecture.md *"Buffer counts and sizes are determined at runtime based on available memory and reallocated when configuration changes"* (§ Scaling to available memory). Replaces the `hasPsram ? 131072 : 16384` constant.
+
+## CRITICAL INVARIANT — "always show something complete, never a partial frame"
+
+A WebSocket message is **atomic to the browser**: `ws.onmessage` fires only when the *whole* message has arrived, and `renderPreviewFrame` rejects an incomplete buffer (`buf.byteLength < 7 + count*3 → return`). Therefore:
+
+- The resumable send MUST keep each frame as **one complete WS message** at the browser level. "Resume across ticks" is about not *spinning the device loop* — the device still delivers a complete message, just spread over wall-clock; the browser draws it whole when it lands. Splitting a frame into multiple WS messages at the byte level would make the browser show **nothing** until 100% arrives (worse than today).
+- "Best effort / show something" at huge grids (the 196² case) is **a complete frame that is either downsampled (sparse, every Nth light) or delivered at a reduced frame rate** — NEVER a torn/half-delivered frame. The no-tearing guarantee (fixed earlier this session via the count/stride match guard) is preserved.
+- Concretely at 196² (38416 lights): classic (cap 16384) → downsampled complete sparse frame; PSRAM/P4 (under the old constant cap) → complete full-res frame delivered over more ticks at low effective fps. Both draw whole. The memory-derived cap (§3) decides which, per board, from actual free contiguous memory.
+
+This invariant is the headline acceptance criterion: at every grid size on every board, the preview shows a **complete** frame (full-res, downsampled, or low-fps) and the device loop never stalls.
+
+## Design
+
+### §1 Resumable send (core)
+`BinaryBroadcaster::sendBufferedFrame(header, hdrLen, body, bodyLen)` — `body` is the caller's stable producer buffer (pointer, NOT copied). HttpServerModule holds one in-flight send:
+
+```
+struct PreviewSend {
+ uint8_t hdr[16]; size_t hdrLen; // small WS+app header, COPIED (caller's is a stack local)
+ const uint8_t* body; size_t bodyLen; // producer buffer — pointer only
+ size_t sent[MAX_WS_CLIENTS]; // per-client byte cursor (a slow client lags, not blocks)
+ uint32_t bodyGeneration; // invalidation tag (§4)
+ bool active;
+}
+```
+
+- `sendBufferedFrame` while one is **active** → newest-wins **drop** (backpressure). Else: send the WS header to each client, init cursors, mark active.
+- `drainPreviewSend()` from `loop20ms`: per client, push **one memory-adaptive chunk** via `writeSome`, advance its cursor; a real socket error closes that client. When every live client reaches `bodyLen`, the send completes; expose the all-sent / idle result for the adaptive signal.
+- **Subsumes CodeRabbit R5**: `PreviewSend` IS the frame-level state R5 asked for (remaining = `bodyLen - sent[i]`, a frame-wide budget, over-push guard) — built once, here, instead of grafting it onto the spinning `sendAllOrClose`.
+
+### §2 Adaptive frame rate (the elegant part)
+PreviewDriver only calls `sendBufferedFrame` when `bufferedSendIdle()` (previous frame fully drained). So:
+- Fast link → drains in ~1 tick → next frame fires next loop → runs at the `fps` ceiling.
+- Slow link → drains over many ticks → next frame waits → **effective fps drops automatically to what the link sustains.** Zero extra logic; the resumable send *is* the rate limiter.
+- `fps` slider becomes the **max** fps (spec/label intent; keep the control name `fps`). Status line shows effective fps when below the ceiling ("preview · 6/24 fps · link limited").
+- **Degradation order** (textbook, like video): shed frame rate first (this, free); downscale resolution only when even a full-res frame can't drain within a bounded number of ticks at the floor (the deeper fallback). Re-tune `slowStreak_`/`cleanStreak_` against this latency signal (closes the old "195² churn" + the "P4 factor stuck at 1 on a slow link" follow-up: the latency signal fires even when the frame eventually sends).
+
+### §3 Memory-derived cap
+`MAX_PREVIEW_POINTS` → a runtime value from `maxAllocBlock()` (largest contiguous, any memory) with a reserve margin for stack/WiFi/HTTP. A fragmented classic downscales sooner; a big PSRAM board goes higher — replacing the `hasPsram` tiers, per arch.md § Scaling to available memory. The spatial-lattice downsample stays as the graceful fallback above the cap. Reserve margin = one named constant, tuned from the measured classic headroom (the 16K-at-128² figure), not a guess.
+
+### §4 Robustness — invalidate on resize (the use-after-free guard)
+`Buffer::allocate()` does `free()` + `alloc()`, so a grid resize mid-send dangles `body`. PreviewDriver bumps `bodyGeneration` and calls `cancelBufferedSend()` from `onBuildState()` (the geometry-change signal). `drainPreviewSend` checks the tag and abandons a stale send (those clients' partial WS messages end incomplete → the browser discards them → fresh coord table + frame next tick). **Regression test** pins it (resize during an active send ≠ use-after-free), per the robustness Hard Rule.
+
+### §5 Multi-client
+Per-client cursors over one shared body handle ≤4 clients (a slow client lags its own cursor, doesn't hold the others). Newest-wins drop → never two frames queued → bounded memory (one header copy + the cursors; the body is the producer buffer, not ours).
+
+## Files
+- `src/core/BinaryBroadcaster.h` — `sendBufferedFrame` / `cancelBufferedSend` / `bufferedSendIdle`.
+- `src/core/HttpServerModule.h/.cpp` — `PreviewSend` state, `drainPreviewSend()` from `loop20ms`, memory-adaptive chunk, generation guard. The synchronous header push keeps a tight bounded spin; the body drains by cursor.
+- `src/light/drivers/PreviewDriver.h` — full-res → `sendBufferedFrame`; gate on `bufferedSendIdle()`; memory-derived `MAX_PREVIEW_POINTS`; effective-fps status; re-tuned downscale on the latency signal; bump generation + cancel on `onBuildState`.
+- `src/ui/preview3d.js` — status shows effective vs max fps + "link limited".
+- Tests: `unit_HttpServerModule` (drain-across-ticks, newest-wins drop, cancel-on-generation, over-push guard); `unit_PreviewDriver` (full-res routes buffered; resize-during-send safe; effective-fps falls under a throttled broadcaster; the "complete frame at every size" invariant — downsample engages past the memory cap).
+- Docs: `HttpServerModule.md` / `PreviewDriver.md` (resumable + adaptive-fps + memory-derived cap; remove the synchronous-stall caveat); `architecture.md` graceful-degradation made non-aspirational + cross-ref § Scaling to available memory.
+
+## Verification
+- Host: build -Werror, ctest (new cases), scenarios, spec, boundary.
+- ESP32 S3 + classic build.
+- **Live (the real test)**: classic 128² WiFi — uptime progresses while streaming (stall gone), effective fps drops gracefully. Sweep 16²→256² on classic/S3/P4: at EVERY size a **complete** frame shows (full-res, low-fps, or downsampled), the tick never stalls, fps adapts, downscale engages only past the memory cap. The 196² "show something" case shows a complete frame on every board. KPI tick unchanged at small sizes.
+
+## Open question (one)
+The memory-derived cap's reserve margin — derived from the measured classic headroom, a single named constant tuned by the live sweep. Flagged so it's not a surprise in the diff.
diff --git a/docs/moonmodules/core/HttpServerModule.md b/docs/moonmodules/core/HttpServerModule.md
index 60b5aba..d9b6de9 100644
--- a/docs/moonmodules/core/HttpServerModule.md
+++ b/docs/moonmodules/core/HttpServerModule.md
@@ -53,14 +53,16 @@ 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:** **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.
+- **Server → client binary frames:** two paths, both with no frame-sized buffer.
+ - **Synchronous stream** — `beginBinaryFrame(totalLen)` / `pushBinaryFrame(data,len)` / `endBinaryFrame()`: `begin` sends the WS header (16-bit, 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 got the whole frame. For a forward-only producer that builds the payload as it goes (PreviewDriver's coordinate table and downsampled colour frames, walked from `forEachCoord`). Each push spins `writeSome` a bounded number of times for the lwIP buffer to drain, then closes a client that can't keep up. These frames are small/infrequent, so the bounded spin is fine.
+ - **Resumable buffered send** — `sendBufferedFrame(header, headerLen, body, bodyLen)`: for a payload that lives in a **stable caller-owned buffer** (PreviewDriver's full-res colour frame, whose body is the driver buffer). The header is copied; `body` is a pointer the caller keeps stable. One WS message is then **drained a memory-adaptive chunk per client per `loop20ms`** via `writeSome` — so a large frame is delivered over wall-clock ticks **without spinning any loop**, yet stays one atomic WS message to the browser. One send in flight at a time: a new `sendBufferedFrame` while one is active is **dropped** (newest-wins backpressure → the producer reads "link busy"). `bufferedSendIdle()` reports when the previous frame finished draining; `cancelBufferedSend()` abandons an in-flight send before its `body` is freed (a geometry rebuild). The chunk size comes from `maxAllocBlock()` so a tight board takes small bites (bounded tick cost) and a roomy board drains fast.
- **Client → server:** none. Mutations go through the REST API.
-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.
+Both paths are domain-neutral (the server doesn't interpret the bytes). The resumable drain runs on **`loop20ms` (the 20 ms transport-poll), deliberately NOT the per-render-tick `loop()`** — pushing preview bytes to the socket must not be charged to the LED render hot path. The LED path (the driver output) is never delayed by the preview; the preview frame rate is instead bounded by the 20 ms drain cadence (a few fps at large full-res frames, higher for small grids), which is the right trade since the preview is a *view* and the LEDs are not. The resumable path lets a 128²+ full-res frame stream on a slow link without stalling the device: the effective frame rate self-limits (the next frame waits for `bufferedSendIdle()`), so the link sheds frame rate gracefully instead of freezing. When the two-core render/transport split lands ([architecture.md § Parallelism](../../architecture.md#parallelism)) the drain moves to the transport core and the cadence limit lifts — `loop20ms` is already that seam.
## 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 (`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.
+HttpServerModule is core infrastructure with **no** light-domain dependencies — no `PreviewFrame`, no light types, no light includes. It exposes the `BinaryBroadcaster` interface (the synchronous `beginBinaryFrame` / `pushBinaryFrame` / `endBinaryFrame`, the resumable `sendBufferedFrame` / `bufferedSendIdle` / `cancelBufferedSend`, and `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/light/drivers/PreviewDriver.md b/docs/moonmodules/light/drivers/PreviewDriver.md
index 9e99b8b..2928436 100644
--- a/docs/moonmodules/light/drivers/PreviewDriver.md
+++ b/docs/moonmodules/light/drivers/PreviewDriver.md
@@ -28,21 +28,23 @@ Two binary message types (first byte selects):
## 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.
+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 the coordinate table is built in the same driver order (closed-form for a dense grid, via `Layouts::forEachCoord` for a sparse one — see *How the kept lights are chosen*), 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.
-**No preview-side buffers.** Both messages STREAM — neither holds a copy of a frame:
+**No preview-side buffers.** Both messages stream straight from the driver buffer — neither holds a copy of a frame:
-- **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).
+- **Colour frame (`0x02`)** at full resolution (`stride`=1, `cpl`=3) is the **driver buffer streamed 1:1** through the resumable `sendBufferedFrame` (header copied, body = the buffer pointer). For a dense identity grid that buffer is the Layer's box buffer; for a sparse/mapped layout (a sphere, a serpentine grid) it's the LUT-mapped output buffer — only the real lights, in driver order, the **same buffer the LED drivers consume**, not the dense box. So the colour count always equals the coordinate-table count. A downsampled (`stride`>1) or non-RGB (`cpl`≠3) frame packs only the kept lights, in the same subset + order the coordinate table used (colour `k` ↔ coord `k`, no stored index map), through the synchronous `begin`/`push`/`end`. Either way: no `rgb_`/gather buffer.
+- **Coordinate table (`0x03`)** streams the kept lights' scaled positions — no `coords_` buffer. Sent only on a geometry change / new client / downscale change (rare).
-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.
+**How the kept lights are chosen (closed-form vs walk).** A **dense grid in natural box order** (no mapping LUT) is a regular box, so the kept set, the count, each position, and each light's buffer index are all **closed-form from the box dimensions and the stride** — the driver strides the box directly (`for z in 0,s,2s…; y; x`, light `(x,y,z)` at buffer index `z·H·W + y·W + x`), touching only the kept lights. No per-frame `forEachCoord` walk over skipped cells. A **sparse / serpentine / modified** layout has a LUT (arbitrary index↔position map), so those three paths walk `Layouts::forEachCoord` applying the lattice predicate. In practice the sparse case stays under the point cap and sends at `stride`=1 (the 1:1 buffered path, no walk at all), so the per-frame colour walk is effectively never on the hot path.
+
+**Resumable send + adaptive frame rate (no stall, no buffer).** The full-res colour frame rides [HttpServerModule](../../core/HttpServerModule.md)'s `sendBufferedFrame`, drained a chunk per `loop20ms` from the stable driver buffer — so a large frame (128² = ~49 KB, 196² = ~115 KB) never spins the preview loop. The driver starts a new frame only when `bufferedSendIdle()` (the previous one fully drained), so the **effective frame rate self-limits to what the link sustains**: a fast link hits the `fps` ceiling, a slow link drops to a few fps — and the browser's status line shows the measured rate. A geometry rebuild frees+reallocs the driver buffer, so `onBuildState()` calls `cancelBufferedSend()` first (the browser discards the half-sent message and gets the fresh table + frame next tick) — a use-after-free guard, pinned by a test.
## Large layouts (spatial downsample + adaptive)
-The point count is bounded two ways:
+The preview never freezes and never tears at any grid size: it always delivers a **complete** frame — full-res, at a reduced frame rate, or spatially downsampled — and sheds in that order (rate first, then resolution). 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.
+- **Point cap = min(display, memory).** `maxPreviewPoints()` takes the smaller of two bounds. (1) A **display cap** (~4096) — a preview is a browser canvas a few hundred px wide, so beyond a few thousand points the lights are sub-pixel and *more points only cost link bandwidth* (a 16K-point full-res frame streams at <1 fps even on Ethernet). Capping to a display-sensible count makes a big-RAM board downsample to a frame the **link** can push fast — the bottleneck at large grids is throughput, not memory. (2) A **memory cap** derived at runtime from `maxAllocBlock()` with a reserve margin (architecture.md *"sizes determined at runtime based on available memory"*) — it only bites on a board too tight to stream even the display cap, downscaling sooner. Above the resulting cap the driver downsamples on a **spatial lattice** — keep a light only where its grid position lands on a per-axis step `s` (`x,y,z ≡ 0 (mod s)`), which generalises to 2D/3D with no diagonal moiré because it samples *positions*, not flat indices. For a dense grid this is the closed-form `[::s]` stride above; for a sparse layout, the same predicate over `forEachCoord`. Any layout under the cap sends every light (`stride` = 1, exact).
+- **Adaptive downscale** — the *deeper* fallback, after frame rate. The struggle signal is **latency**: a buffered frame still draining after a few `fps` slots (the link can't sustain even one frame at this resolution), or a frame/coord table that didn't reach a client. This fires even when frames *eventually* send (the slow-but-complete case a pure all-sent signal misses — e.g. a full-res 196² frame on ethernet that delivers at 2 fps). On sustained struggle the driver coarsens the lattice (`stride`++); a sustained run of prompt, fully-sent frames refines back (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.
diff --git a/src/core/BinaryBroadcaster.h b/src/core/BinaryBroadcaster.h
index 033bdae..557b3c6 100644
--- a/src/core/BinaryBroadcaster.h
+++ b/src/core/BinaryBroadcaster.h
@@ -26,6 +26,28 @@ struct BinaryBroadcaster {
virtual void pushBinaryFrame(const uint8_t* data, size_t len) = 0;
virtual bool endBinaryFrame() = 0;
+ // RESUMABLE one-frame send for a payload that lives in a STABLE caller-owned buffer (no copy):
+ // one WS message = `header` (copied — small, may be a stack local) followed by `body` (a pointer
+ // the caller guarantees stable until the send completes or is cancelled). The implementation
+ // drains it across transport-poll ticks (a bounded chunk per tick, non-blocking), so a large
+ // frame never spins the caller's loop. The frame is still ONE atomic WS message to the browser
+ // — "resumable" means delivered over wall-clock, not split into multiple messages.
+ // sendBufferedFrame(...) — begin a send; while one is in flight a new call is DROPPED
+ // (newest-wins backpressure), the caller reads that as "link busy".
+ // bufferedSendIdle() — true when no send is in flight (the previous frame fully drained
+ // or was cancelled). The caller gates the next frame on this, so the
+ // effective frame rate self-limits to what the link sustains.
+ // cancelBufferedSend() — abandon the in-flight send NOW (its WS messages end incomplete →
+ // the browser discards them). The caller MUST call this before the
+ // `body` buffer is freed/reallocated (e.g. a geometry rebuild), so a
+ // cursor never reads freed memory.
+ // Only PreviewDriver uses this today (the full-res colour frame, whose payload is the producer
+ // buffer). The coord table / downsampled frames keep the begin/push/end path.
+ virtual bool sendBufferedFrame(const uint8_t* header, size_t headerLen,
+ const uint8_t* body, size_t bodyLen) = 0;
+ virtual bool bufferedSendIdle() const = 0;
+ virtual void cancelBufferedSend() = 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
diff --git a/src/core/DevicesModule.h b/src/core/DevicesModule.h
index 5b8f600..ab91456 100644
--- a/src/core/DevicesModule.h
+++ b/src/core/DevicesModule.h
@@ -270,7 +270,12 @@ class DevicesModule : public MoonModule, public ListSource {
// probe short-circuits after the FIRST GET times out (a dead host answers no
// URL), so a sparse subnet costs ~1×timeout per empty IP, not 3×.
static constexpr uint8_t kProbesPerTick = 1;
- static constexpr uint32_t kProbeTimeoutMs = 150;
+ // Short timeout: this GET blocks the scheduler thread (and thus one render tick) on a dead host,
+ // so it must stay small or the boot sweep stutters animation once a second for the ~4 min the
+ // /24 takes. A live host on a LAN answers in a few ms; 30 ms covers a slow responder while
+ // keeping the worst-case per-tick stall to ~30 ms. (The real fix — running the probe on its own
+ // task so it never touches the render thread — is backlogged; this just bounds the symptom.)
+ static constexpr uint32_t kProbeTimeoutMs = 30;
// Drop a non-self device unseen by ANY strategy for this long. 24 h is deliberately
// generous: mDNS re-confirms its devices every few-second browse lap (cheap), but an
// HTTP-scan-only device (a PC instance, a generic host) has no cheap recurring
@@ -375,14 +380,14 @@ class DevicesModule : public MoonModule, public ListSource {
// 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
+ // thread, so this time is charged to the tick. Keep the timeout SHORT AND browse only
+ // every kMdnsEveryTicks-th tick: one ~20 ms hiccup every ~15 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;
+ static constexpr uint32_t kMdnsBrowseMs = 20; // shorter blocking window → smaller render hiccup
+ static constexpr uint8_t kMdnsEveryTicks = 15; // browse less often → the hiccup is rarer (~15 s)
uint8_t mdnsIndex_ = 0; // which service in kMdnsServices is browsed
uint8_t mdnsTick_ = 0; // throttle counter for the browse cadence
diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp
index c787664..3b18ceb 100644
--- a/src/core/HttpServerModule.cpp
+++ b/src/core/HttpServerModule.cpp
@@ -36,14 +36,22 @@ void HttpServerModule::setup() {
}
void HttpServerModule::teardown() {
+ previewSend_.active = false; // drop any in-flight send before the clients go (body is borrowed)
for (auto& ws : wsClients_) ws.close();
server_.close();
}
void HttpServerModule::loop20ms() {
- // 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.)
+ // Drain the in-flight resumable preview frame on the TRANSPORT-poll cadence (20 ms), NOT the
+ // per-render-tick loop(): pushing frame bytes to the socket must not be charged to the LED
+ // render hot path. The render tick stays free of preview work; the preview frame rate is
+ // bounded by this 20 ms drain cadence (a few fps at large full-res frames) — an acceptable
+ // trade, since the preview is a *view* and the LEDs are not. (When the two-core render/transport
+ // split lands — architecture.md § Parallelism — this drain moves to the transport task and the
+ // cadence limit lifts; the seam is already here.) Drain BEFORE accept so a connection burst
+ // can't starve an active send. No-op when nothing is in flight.
+ drainPreviewSend();
+ // Accept one HTTP connection per tick.
auto conn = server_.accept();
if (conn.valid()) handleConnection(conn);
}
@@ -1070,8 +1078,12 @@ void HttpServerModule::handleWebSocketUpgrade(platform::TcpConnection& conn, con
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.
+ previewSend_.sent[i] = 0; // fresh slot: clear any stale cursor a prior client left here
+ // Abandon any in-flight buffered frame: this new client would otherwise either be skipped
+ // (its stale cursor ≥ total looked "done", so it got no frame and the browser showed
+ // nothing) or spliced into a half-sent message. Cancelling makes the next frame start
+ // clean for every client. The generation bump re-streams the coord table first.
+ previewSend_.active = false;
wsClientGeneration_++;
return;
}
@@ -1121,23 +1133,18 @@ bool HttpServerModule::sendWsTextFrame(platform::TcpConnection& conn, const char
return conn.write(reinterpret_cast(data), len);
}
-// 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).
+// Write the whole span via repeated non-blocking writeSome; close the client + return false if it
+// can't all go right now. Bounded TOTAL would-block spins (not reset on progress) hard-bound how
+// long this synchronous send can occupy the caller's loop; a span that doesn't complete in budget
+// closes the client (the browser reconnects). Used by the begin/push/end stream (the coord table
+// and downsampled colour frame); the full-res colour frame uses the resumable sendBufferedFrame.
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.
+ int stalls = 0;
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;
}
@@ -1181,4 +1188,96 @@ void HttpServerModule::pushBinaryFrame(const uint8_t* data, size_t len) {
bool HttpServerModule::endBinaryFrame() { return wsFrameAllSent_; }
+// Resumable full-frame send. One WS message = WS framing header + the caller's app header (both
+// copied into previewSend_.hdr) + the caller's `body` (a pointer, NOT copied). Each client's
+// cursor walks the logical stream [hdr ++ body], drained a chunk at a time in drainPreviewSend.
+bool HttpServerModule::sendBufferedFrame(const uint8_t* header, size_t headerLen,
+ const uint8_t* body, size_t bodyLen) {
+ // Newest-wins backpressure: one frame in flight at a time. A caller that asks while a send is
+ // active is told "busy" and drops this frame — the producer reads that as the link not keeping
+ // up (so it doesn't queue and doesn't stall).
+ if (previewSend_.active) return false;
+
+ const size_t totalLen = headerLen + bodyLen; // WS payload length = app header + body
+ // Build the WS frame header (FIN + binary opcode, unmasked; 7/16/64-bit length form) directly
+ // into previewSend_.hdr, followed by the app header — so the cursor streams them as one span.
+ uint8_t* h = previewSend_.hdr;
+ size_t wsLen;
+ h[0] = 0x82;
+ if (totalLen < 126) { h[1] = static_cast(totalLen); wsLen = 2; }
+ else if (totalLen < 65536) {
+ h[1] = 126; h[2] = static_cast((totalLen >> 8) & 0xFF);
+ h[3] = static_cast(totalLen & 0xFF); wsLen = 4;
+ } else {
+ h[1] = 127;
+ for (int i = 0; i < 8; i++)
+ h[2 + i] = static_cast((static_cast(totalLen) >> (56 - 8 * i)) & 0xFF);
+ wsLen = 10;
+ }
+ // The app header follows the WS header in the same buffer. sizeof(hdr)=16 holds the 10-byte WS
+ // form + the preview app headers (≤10 bytes); guard so a future larger header can't overrun.
+ if (wsLen + headerLen > sizeof(previewSend_.hdr)) return false;
+ for (size_t i = 0; i < headerLen; i++) previewSend_.hdr[wsLen + i] = header[i];
+
+ previewSend_.hdrLen = wsLen + headerLen;
+ previewSend_.body = body;
+ previewSend_.bodyLen = bodyLen;
+ for (int i = 0; i < MAX_WS_CLIENTS; i++) previewSend_.sent[i] = 0;
+ previewSend_.active = true;
+ // Deliberately do NOT drain here. sendBufferedFrame is called from PreviewDriver's loop() on the
+ // RENDER thread; a socket writeSome is variable-cost (0..~ms) and would land that cost — and its
+ // jitter — directly on the render tick, hitching the LEDs. So we only queue the frame (copy the
+ // header, point at the body) and let drainPreviewSend() push bytes purely on loop20ms, off the
+ // render hot path. The frame starts draining within one transport poll (≤20 ms).
+ return true;
+}
+
+// Per-client cursor over the logical [hdr ++ body] stream: write whatever the socket takes now (up
+// to one memory-adaptive chunk), advance the cursor, leave the rest for the next tick. A real
+// socket error closes that client (its WS message ends incomplete → the browser discards it). The
+// send completes when every live client has the whole frame, or when no client is left.
+void HttpServerModule::drainPreviewSend() {
+ if (!previewSend_.active) return;
+ const size_t total = previewSend_.hdrLen + previewSend_.bodyLen;
+ const size_t chunk = previewChunkBytes();
+ bool anyLiveClient = false;
+ bool allDone = true;
+ for (int i = 0; i < MAX_WS_CLIENTS; i++) {
+ auto& ws = wsClients_[i];
+ if (!ws.valid()) continue;
+ anyLiveClient = true;
+ size_t& cur = previewSend_.sent[i];
+ size_t budget = chunk; // bound bytes pushed to THIS client this tick → bounded tick cost
+ while (cur < total && budget > 0) {
+ // Source the next byte run from hdr (cursor < hdrLen) or body (cursor >= hdrLen).
+ const uint8_t* src;
+ size_t span;
+ if (cur < previewSend_.hdrLen) { src = previewSend_.hdr + cur; span = previewSend_.hdrLen - cur; }
+ else { src = previewSend_.body + (cur - previewSend_.hdrLen); span = total - cur; }
+ if (span > budget) span = budget;
+ int n = ws.writeSome(src, span);
+ if (n < 0) { ws.close(); break; } // real error — drop this client
+ if (n == 0) break; // WouldBlock — leave the rest for next tick (no spin)
+ cur += static_cast(n);
+ budget -= static_cast(n);
+ }
+ if (ws.valid() && cur < total) allDone = false;
+ }
+ // Done when every live client finished, or no client remains to send to.
+ if (!anyLiveClient || allDone) previewSend_.active = false;
+}
+
+// Per-tick per-client chunk cap, derived from free contiguous memory: a tight board takes small
+// bites (so one drain can't dominate the tick), a roomy board drains a big frame in a tick or two.
+// Bounded both ways — never below a floor (forward progress) nor above a ceiling (tick occupancy).
+size_t HttpServerModule::previewChunkBytes() const {
+ constexpr size_t kFloor = 2048; // always make real progress, even on a fragmented board
+ constexpr size_t kCeil = 65536; // cap tick occupancy regardless of how much RAM is free
+ const size_t block = platform::maxAllocBlock();
+ size_t chunk = block / 8; // a fraction of the largest contiguous block
+ if (chunk < kFloor) chunk = kFloor;
+ if (chunk > kCeil) chunk = kCeil;
+ return chunk;
+}
+
} // namespace mm
diff --git a/src/core/HttpServerModule.h b/src/core/HttpServerModule.h
index 444f615..990c505 100644
--- a/src/core/HttpServerModule.h
+++ b/src/core/HttpServerModule.h
@@ -40,6 +40,13 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
void beginBinaryFrame(size_t totalLen) override;
void pushBinaryFrame(const uint8_t* data, size_t len) override;
bool endBinaryFrame() override;
+
+ // Resumable one-frame send from a stable caller-owned buffer (no copy), drained across
+ // loop20ms ticks so a large frame never spins this module's loop. See BinaryBroadcaster.
+ bool sendBufferedFrame(const uint8_t* header, size_t headerLen,
+ const uint8_t* body, size_t bodyLen) override;
+ bool bufferedSendIdle() const override { return !previewSend_.active; }
+ void cancelBufferedSend() override { previewSend_.active = false; }
// 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.
@@ -102,12 +109,33 @@ class HttpServerModule : public MoonModule, public BinaryBroadcaster {
// 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.)
+ // Used by the begin/push/end stream (coord table + downsampled colour frame); the full-res
+ // colour frame goes through the resumable sendBufferedFrame instead, which never spins.
static constexpr int kDirectSendSpins = 2000;
+ // Resumable full-frame send (BinaryBroadcaster::sendBufferedFrame). One WS message = a copied
+ // header + a pointer into the caller's STABLE body buffer (the PreviewDriver producer buffer),
+ // drained a bounded chunk per client per loop20ms via writeSome — so a large frame is delivered
+ // over wall-clock ticks without spinning any loop, yet stays ONE atomic WS message to the
+ // browser. One in flight at a time (newest-wins drop). bodyGeneration_ invalidates a send whose
+ // body the caller is about to free (cancelBufferedSend bumps nothing — the caller cancels; this
+ // tag guards a stale drain if the body is swapped between cancel and the next begin).
+ struct PreviewSend {
+ uint8_t hdr[16] = {}; // WS + app header, copied (caller's may be a stack local)
+ size_t hdrLen = 0;
+ const uint8_t* body = nullptr; // caller-owned, stable until done/cancelled — NOT copied
+ size_t bodyLen = 0;
+ size_t sent[MAX_WS_CLIENTS] = {}; // per-client cursor over [hdr ++ body]; a slow client lags
+ bool active = false;
+ };
+ PreviewSend previewSend_;
+ // Drain one memory-adaptive chunk per client of the in-flight resumable send; mark it done when
+ // every live client has the whole frame. Called from loop20ms. No-op when none is active.
+ void drainPreviewSend();
+ // Largest chunk to push per client per drain tick, derived from free contiguous memory so a
+ // tight board takes small bites (bounded tick occupancy) and a roomy board drains fast.
+ size_t previewChunkBytes() const;
+
// All JSON API responses (/api/state, /api/types, /api/system) and the WS
// state push stream through a JsonSink — no shared fixed-size buffer.
diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h
index 8e44961..6438b0f 100644
--- a/src/light/drivers/PreviewDriver.h
+++ b/src/light/drivers/PreviewDriver.h
@@ -5,6 +5,8 @@
#include "core/BinaryBroadcaster.h"
#include "platform/platform.h"
+#include // numeric_limits for the memory-derived point cap
+
namespace mm {
// Streams a true-shape 3D preview to the web UI over the binary WebSocket.
@@ -58,6 +60,10 @@ class PreviewDriver : public DriverBase {
// light set / positions may have changed, so rebuild + broadcast the
// coordinate table. This is the MoonLight "positions once at mapping time".
void onBuildState() override {
+ // A resize frees+reallocs the producer buffer, so any in-flight resumable colour send holds
+ // a pointer that's about to dangle — cancel it BEFORE the rebuild (the browser discards the
+ // half-sent message and gets the fresh table + frame next tick). Guards a use-after-free.
+ if (broadcaster_) broadcaster_->cancelBufferedSend();
buildAndSendCoordTable();
MoonModule::onBuildState();
}
@@ -66,7 +72,7 @@ class PreviewDriver : public DriverBase {
if (fps == 0) return;
uint32_t now = platform::millis();
uint32_t interval = 1000 / fps;
- if (now - lastSendTime_ < interval) return; // rate-limit gate
+ if (now - lastSendTime_ < interval) return; // fps CEILING (max rate); link may be slower
lastSendTime_ = now;
// The coordinate table is (re)streamed only when the geometry changes (onBuildState — a
@@ -80,22 +86,42 @@ class PreviewDriver : public DriverBase {
buildAndSendCoordTable(); // streams positions; sets coordPending_ if not all clients got it
}
- // 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.
+ // ADAPTIVE FRAME RATE. The full-res colour frame streams resumably (sendBufferedFrame drains
+ // across transport ticks), so a frame only starts once the previous one fully drained. We
+ // gate on that: idle → send the next frame now; still draining → skip this slot. The
+ // EFFECTIVE fps therefore self-limits to what the link sustains — fast links hit the fps
+ // ceiling, slow links naturally drop to a few fps, with NO loop stall either way. The slot
+ // we skip is also the "link is slow" signal (framesWaiting_), so we shed frame rate FIRST.
bool frameOk = true;
- if (!coordPending_) frameOk = sendFrame();
+ bool sentThisSlot = false;
+ bool sentFrameWasSlow = false;
+ if (!coordPending_) {
+ const bool idle = !broadcaster_ || broadcaster_->bufferedSendIdle();
+ if (idle) {
+ // The previous frame finished draining. How many fps slots did it take? > a couple
+ // means the link can't sustain this resolution at the requested rate — that frame
+ // was "slow", the resolution signal below.
+ sentFrameWasSlow = framesWaiting_ >= kSlowFrames;
+ frameOk = sendFrame(); // false → a client couldn't take the frame (closed)
+ sentThisSlot = true;
+ framesWaiting_ = 0;
+ } else {
+ framesWaiting_++; // still draining the previous frame — link is behind
+ }
+ }
- // 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;
+ // ADAPTIVE RESOLUTION (the deeper fallback, after frame rate). The struggle signal is
+ // LATENCY: the just-completed frame took more than kSlowFrames slots to drain
+ // (sentFrameWasSlow), or a frame/coord table didn't reach a client. This fires even when
+ // frames eventually send (the slow-but-complete case a pure all-sent signal misses — a
+ // full-res 128² frame that delivers at ~2 fps). On a sustained run of slow frames, coarsen
+ // the lattice (downscale_++) so frames shrink and the rate climbs; a sustained run of
+ // prompt, fully-sent frames refines back toward full res (downscale_--). The streaks only
+ // advance on slots where a frame completed (sentThisSlot), so a long drain counts as ONE
+ // slow frame, not many — making kDownscaleAfterSlow a count of slow frames, not ticks.
+ // Hysteresis stops oscillation; the factor rides the wire stride field to the status line.
+ const bool linkStruggling =
+ coordPending_ || (sentThisSlot && (!frameOk || sentFrameWasSlow));
if (linkStruggling) {
cleanStreak_ = 0;
if (++slowStreak_ >= kDownscaleAfterSlow && downscale_ < 64) {
@@ -103,7 +129,7 @@ class PreviewDriver : public DriverBase {
downscale_++;
buildAndSendCoordTable();
}
- } else {
+ } else if (sentThisSlot) { // only count a clean run on slots where we actually sent
slowStreak_ = 0;
if (downscale_ > 1 && ++cleanStreak_ >= kUpscaleAfterFast) {
cleanStreak_ = 0;
@@ -122,20 +148,26 @@ class PreviewDriver : public DriverBase {
nrOfLightsType n = layouts->totalLightCount();
if (n == 0) return;
- // 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
- // same factor so the largest box edge maps to 255 — preserving aspect
- // ratio. For boxes ≤255/axis the factor is 1 (exact integer positions).
- // The header carries the SCALED box extents, so the browser's centring
- // (divide by max axis) stays consistent with the packed coordinates.
- lengthType maxEdge = layer_->physicalWidth();
- if (layer_->physicalHeight() > maxEdge) maxEdge = layer_->physicalHeight();
- if (layer_->physicalDepth() > maxEdge) maxEdge = layer_->physicalDepth();
+ // Box EXTENT = the maximum coordinate the positions reach, which is (size − 1): forEachCoord
+ // emits x in [0, width−1], so an 8-wide grid spans 0..7 and its extent is 7, NOT 8. The
+ // header carries these extents and the browser centres the cloud by dividing by the largest,
+ // so they must match the packed coordinates' span exactly — using the size (8) instead drew
+ // the wireframe box one cell too large and shifted the lights off-centre.
+ auto extent = [](lengthType size) -> lengthType { return size > 0 ? size - 1 : 0; };
+ const lengthType ex = extent(layer_->physicalWidth());
+ const lengthType ey = extent(layer_->physicalHeight());
+ const lengthType ez = extent(layer_->physicalDepth());
+ // Positions are 1 byte/axis. To support layouts whose extent exceeds 255 on an axis (a
+ // 512-wide grid, say), scale every axis by the same factor so the largest edge maps to 255 —
+ // preserving aspect ratio. For extents ≤255/axis the factor is 1 (exact integer positions).
+ lengthType maxEdge = ex;
+ if (ey > maxEdge) maxEdge = ey;
+ if (ez > maxEdge) maxEdge = ez;
if (maxEdge < 1) maxEdge = 1;
posScale_ = (maxEdge > 255) ? maxEdge : 0; // 0 = no scaling (1:1)
- bx_ = scaleAxis(layer_->physicalWidth());
- by_ = scaleAxis(layer_->physicalHeight());
- bz_ = scaleAxis(layer_->physicalDepth());
+ bx_ = scaleAxis(ex);
+ by_ = scaleAxis(ey);
+ bz_ = scaleAxis(ez);
// 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
@@ -146,32 +178,38 @@ class PreviewDriver : public DriverBase {
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) {
+ const nrOfLightsType cap = maxPreviewPoints(); // memory-derived this rebuild
+ if (n > cap) {
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++;
+ while (latticeCount(s) > cap) 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;
+ // Count the lights the lattice keeps. A dense grid in natural order (no LUT) is a regular
+ // box, so the kept count is closed-form: ceil(size/s) per axis — no walk. A sparse/mapped
+ // layout (LUT) has an arbitrary index↔position map, so it's counted by one forEachCoord
+ // pass applying the same lattice predicate the colour/coord passes use (colour[k] ↔ coord[k]
+ // line up by shared order, no stored index map).
+ if (denseGrid()) {
+ const nrOfLightsType cx = (ax + s - 1) / s, cy = (ay + s - 1) / s, cz = (az + s - 1) / s;
+ coordCount_ = static_cast(static_cast(cx) * cy * cz);
+ } else {
+ 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.)
+ // 0x03 app header: [type][count:u32 LE][bx][by][bz][stride:u16 LE] (10 bytes).
uint8_t h[10];
h[0] = 0x03;
h[1] = static_cast(coordCount_ & 0xFF);
@@ -185,24 +223,37 @@ class PreviewDriver : public DriverBase {
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 {
+ // Push the kept lights' scaled positions in small slices through a stack scratch. A dense
+ // grid strides its box directly (closed-form, no walk over skipped cells); a sparse/mapped
+ // layout walks forEachCoord with the lattice predicate. BOTH visit the kept lights in the
+ // SAME order the colour pass uses, so colour[k] ↔ coord[k] line up. The C callback can't
+ // capture, so it shares PosCtx (used by both the dense loop and the sparse callback).
+ struct PosCtx {
PreviewDriver* self; mm::BinaryBroadcaster* bc; nrOfLightsType s;
uint8_t buf[1536]; uint16_t fill;
+ void emit(lengthType x, lengthType y, lengthType z) {
+ buf[fill++] = self->scaleAxis(x);
+ buf[fill++] = self->scaleAxis(y);
+ buf[fill++] = self->scaleAxis(z);
+ if (fill > sizeof(buf) - 3) { bc->pushBinaryFrame(buf, fill); fill = 0; }
+ }
};
- 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.
+ PosCtx pc{this, broadcaster_, s, {}, 0};
+ if (denseGrid()) {
+ for (lengthType z = 0; z < az; z += s)
+ for (lengthType y = 0; y < ay; y += s)
+ for (lengthType x = 0; x < ax; x += s) pc.emit(x, y, z);
+ } else {
+ 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->emit(x, y, z);
+ }, &pc);
+ }
+ if (pc.fill) broadcaster_->pushBinaryFrame(pc.buf, pc.fill);
+ // The coord table must reach the browser before colour frames carrying the new count (the
+ // browser skips a count-mismatched 0x02). endBinaryFrame() reports whether every client got
+ // it; loop() retries while coordPending_ and withholds colour frames until it lands.
coordPending_ = !broadcaster_->endBinaryFrame();
}
@@ -226,55 +277,111 @@ class PreviewDriver : public DriverBase {
header[5] = static_cast(s & 0xFF);
header[6] = static_cast(s >> 8);
+ if (s == 1 && cpl == 3 && coordCount_ <= n) {
+ // FULL RES, RGB: the producer buffer IS the payload. Hand it to the RESUMABLE buffered
+ // send (header copied, body = the producer buffer, a stable pointer) — it drains across
+ // transport ticks without a copy and without spinning this loop, the fix for the
+ // large-frame stall. The common case (any grid ≤ cap, incl. 16K on a no-PSRAM classic).
+ // onBuildState cancels it before a resize frees the buffer (use-after-free guard).
+ return broadcaster_->sendBufferedFrame(header, sizeof(header),
+ src, static_cast(coordCount_) * 3);
+ }
+
+ // Downsampled (s>1) or non-RGB (cpl≠3): build the kept lights' colours into the synchronous
+ // begin/push/end stream (no stable contiguous body for the resumable path). The kept subset
+ // + order MUST match the coord table's, so colour[k] ↔ coord[k] line up (the browser drops a
+ // count/stride-mismatched frame). A dense grid strides its box directly — light (x,y,z) is at
+ // buffer index z·H·W + y·W + x, closed-form, no walk over skipped cells (this is the cost the
+ // forEachCoord walk used to pay every frame). A sparse/mapped layout walks forEachCoord with
+ // the same lattice predicate (its index↔position map is arbitrary — no formula).
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);
+ struct ColCtx {
+ mm::BinaryBroadcaster* bc; const uint8_t* src; nrOfLightsType n; uint8_t cpl;
+ uint8_t buf[1536]; uint16_t fill;
+ void emit(nrOfLightsType idx) {
+ const uint8_t* px = (idx < n) ? src + static_cast(idx) * cpl : nullptr;
+ buf[fill++] = px ? px[0] : 0;
+ buf[fill++] = (px && cpl >= 2) ? px[1] : 0;
+ buf[fill++] = (px && cpl >= 3) ? px[2] : 0;
+ if (fill > sizeof(buf) - 3) { bc->pushBinaryFrame(buf, fill); fill = 0; }
+ }
+ };
+ ColCtx col{broadcaster_, src, n, cpl, {}, 0};
+ if (denseGrid()) {
+ const lengthType W = layer_->physicalWidth(), H = layer_->physicalHeight();
+ const lengthType az = layer_->physicalDepth() > 0 ? layer_->physicalDepth() : 1;
+ const lengthType ay = H > 0 ? H : 1, ax = W > 0 ? W : 1;
+ for (lengthType z = 0; z < az; z += s)
+ for (lengthType y = 0; y < ay; y += s)
+ for (lengthType x = 0; x < ax; x += s)
+ col.emit(static_cast(static_cast(z) * H * W
+ + static_cast(y) * W + x));
} 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};
+ // s as the FULL lattice stride (not clamped) — must match buildAndSendCoordTable's.
+ struct Skip { ColCtx* col; nrOfLightsType s; } sk{&col, s};
layer_->layouts()->forEachCoord([](void* c, nrOfLightsType idx, lengthType x, lengthType y, lengthType z) {
- auto* p = static_cast(c);
+ 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);
+ p->col->emit(idx);
+ }, &sk);
}
+ if (col.fill) broadcaster_->pushBinaryFrame(col.buf, col.fill);
return broadcaster_->endBinaryFrame();
}
private:
- // 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
+ // Frame cap: the most points one preview frame carries before the spatial-lattice downsample
+ // engages — derived at runtime from free contiguous memory, not a fixed per-board constant
+ // (architecture.md § Scaling to available memory: "sizes determined at runtime based on
+ // available memory"). There is no per-frame buffer; the cap bounds the transient work the coord
+ // table build (3 bytes/point in flight to the socket) and the resumable colour send impose. So
+ // a fragmented classic downscales SOONER (less contiguous RAM) while a roomy PSRAM board goes
+ // far higher — one rule, every board, measured not assumed. The spatial-lattice downsample is
+ // the graceful fallback above the cap.
+ // True when the source is a dense grid in natural box order (no mapping LUT): driver index i is
+ // exactly box cell i, so the kept-light set + each light's buffer index are CLOSED-FORM from the
+ // box dimensions and the stride — no forEachCoord walk needed (the count, the coord positions,
+ // and the downsampled colours all stride the box directly). A LUT means a sparse / serpentine /
+ // modified layout whose index↔position map is arbitrary, so those paths must walk forEachCoord.
+ // Mirrors the Layer's own dense-vs-LUT decision (Layer::isNaturalOrder gates lut_.setIdentity),
+ // so the two agree: no LUT ⇔ Drivers passed the dense box buffer ⇔ closed-form is valid here.
+ bool denseGrid() const { return layer_ && !layer_->lut().hasLUT(); }
+
+ nrOfLightsType maxPreviewPoints() const {
+ // TWO independent bounds, take the smaller:
+ // (1) DISPLAY cap — a preview is a browser canvas a few hundred px wide; beyond ~4096
+ // points the lights are sub-pixel and indistinguishable, so MORE points only cost link
+ // bandwidth (a 16K-point 49 KB frame streams at <1 fps even on Ethernet). Capping to a
+ // display-sensible count is what makes a big-RAM board (P4) downsample to a frame the
+ // LINK can actually push fast — the bottleneck here is throughput, not memory. WLED-MM
+ // caps its live preview the same way. The lattice downsample (and the browser's status)
+ // handle anything larger gracefully.
+ // (2) MEMORY cap — derived from maxAllocBlock() so a tight/fragmented board downsamples even
+ // SOONER than the display cap (architecture.md § Scaling to available memory).
+ // min(display, memory): the display cap normally wins (it's the smaller); the memory cap
+ // only bites on a board too tight to stream even 4096 points.
+ constexpr uint32_t kDisplayCap = 4096; // visual-resolution ceiling for ANY board
+ constexpr size_t kReserve = 32u * 1024u; // leave this much contiguous headroom
+ constexpr size_t kBytesPerPoint = 3u; // RGB on the wire / position bytes in the table
+ constexpr nrOfLightsType kFloor = 1024; // always previewable (hard-downsampled) on any board
+ const size_t block = platform::maxAllocBlock();
+ // maxAllocBlock() returns 0 = "unlimited / not reported" (desktop, test default): memory is
+ // not the limit there, so the display cap governs.
+ uint32_t memPts;
+ if (block == 0) {
+ memPts = kDisplayCap;
+ } else {
+ const size_t usable = block > kReserve ? block - kReserve : 0;
+ memPts = static_cast(usable / kBytesPerPoint);
+ if (memPts < kFloor) memPts = kFloor;
+ }
+ uint32_t pts = memPts < kDisplayCap ? memPts : kDisplayCap;
+ // Clamp into the board's nrOfLightsType range (u16 on a no-PSRAM classic).
+ constexpr uint32_t kTypeMax = static_cast(std::numeric_limits::max());
+ if (pts > kTypeMax) pts = kTypeMax;
+ return static_cast(pts);
+ }
// 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
@@ -305,10 +412,15 @@ class PreviewDriver : public DriverBase {
// 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
+ uint8_t slowStreak_ = 0; // consecutive struggling frames (latency or not-all-sent)
+ uint8_t cleanStreak_ = 0; // consecutive prompt, fully-sent frames
+ uint8_t framesWaiting_ = 0; // fps slots skipped because the previous frame is still draining
+ static constexpr uint8_t kDownscaleAfterSlow = 2; // coarsen after this many slow frames (fast react)
static constexpr uint8_t kUpscaleAfterFast = 20; // refine after this many clean frames
+ // A frame still draining after this many fps slots means the link can't sustain even one frame
+ // at this resolution at the slowest useful rate → resolution must drop (not just the rate). Set
+ // above 1 so a normal multi-tick drain on a healthy link isn't mistaken for struggle.
+ static constexpr uint8_t kSlowFrames = 3;
};
} // namespace mm
diff --git a/src/light/effects/LinesEffect.h b/src/light/effects/LinesEffect.h
index 6b5e38f..8833e4b 100644
--- a/src/light/effects/LinesEffect.h
+++ b/src/light/effects/LinesEffect.h
@@ -47,10 +47,19 @@ class LinesEffect : public EffectBase {
if (cpl >= 3) buf[off + 2] = b;
};
+ // Sweep position: map the 0–65535 beat into N equal buckets, beat * N / 65536, giving each
+ // index 0..N-1 an equal 1/N slice of the cycle. Crucially this REACHES N-1: `beat` tops out
+ // below 65535 (it is (elapsed % period) * 65535 / period, and elapsed % period maxes at
+ // period-1), so the textbook `beat * (N-1) / 65535` truncates to N-2 at the top and the
+ // sweep never lights the last row/column — the off-by-one this fixes. `beat * N / 65536`
+ // maps that same sub-full-scale top to N-1 (e.g. 65502*8/65536 = 7 for N=8).
+ auto sweepIndex = [&](lengthType n) {
+ return static_cast(static_cast