Skip to content

Backlog — light domain

Forward-looking to-build items for the light domain (src/light/: drivers, effects, layouts, modifiers, preview) and its sensors. The core/infrastructure counterpart is backlog-core.md; cross-domain items are in backlog-mixed.md. Index + overview: README.md. Completed items are removed.

Drivers

MoonI80 streaming ring — 48×256 shipped; open instruments and cleanups

The ring's two regimes ship and are wall-verified through 48 strands × 256 (12,288 lights): prime-only when the frame fits the pool, the clock-oracle lapping ring above it (the near-prime pool — the ISR encodes only nSlices − ringBufs slices per frame), with ringAuto deriving the geometry per config and shiftOverclock trading the fps ceiling against '595 shift margin. The mechanism lives in the code + the technical page; the design arc in docs/history/plans/ (the MoonI80 plans, all marked). Open items:

  • Last-8-panels white flash — the "44-46 flash" (OPEN, cause not yet found; 6 theories ruled out). On the 48×256 wall, brief WHITE/bright flashes (not wrong colors) over an otherwise-correct image, confined to strands 40-47 (the last 8 panels = the last physical data pin, GPIO 17 = bus bit 5 = the 6th and final 74HC595 in the daisy chain), mostly panels 44-46, wandering within the last 8. Brightness-gated: clean below 5, flashes at ≥5 — since brightness scales pixel values through a LUT, below 5 the frame collapses to near-all-zero bits, so the gate really means the flash needs SET (one) data bits on that pin. The same physical wall runs clean on hpwit's driver, so it is OUR driver, not the hardware. Ruled out by live hardware tests (do NOT re-chase): (1) the encode source (PSRAM vs internal — the ISR-source staging fix did nothing, reverted); (2) the ISR/lapping regime (the flash is on prime-encoded rows too); (3) it being a tail-rows phenomenon (it is per-strand-group); (4) shift-clock margin via shiftOverclock (already at the 20 MHz OFF setting, still flashes); (5) the encoder itself (ParallelSlots.h proven byte-for-byte correct for this geometry by a host compile, incl. a pin-5 walking-one test — no all-HIGH "white" value ever appears in a pin-5 data word); (6) bus-bit latch adjacency (a latchBitHigh diagnostic moved the latch off bit-5's neighbour to bit 7 — still flashes). Reopened clue: the shift-register analysis doc records hpwit running the '595 SRCLK at 19.2 MHz, slower than our shiftOverclock-OFF 20 MHz — we treated 20 MHz as "the slow floor" but it is above his proven-good rate; the ~4% could be the deepest chip's margin. Untested angles to try next: driving the '595 clock below 20 MHz (needs a new divider — 16 MHz is all-white, so the window is narrow); GPIO-17 drive-strength / edge-rate specifically; whether it follows bit-5-position or GPIO-17 (swap which strands ride bit 5 via the pin order); the ringPad inter-slice settle window; and a direct A/B of our per-slice frame timing vs hpwit's for the last chip. All diagnostics from this investigation (the latchBitHigh toggle, the ISR-staging code) are reverted — the tree is clean.
  • Ring bus init hard-fails instead of stepping down when ringBufs is raised past what RAM allocates (wall went dark until the control was lowered again; the pool alloc steps down but a later allocation, likely the descriptor link list, does not). A control change must degrade, never dark the output.
  • ~1-frame white/colored flash every ~5 s seen at some configs — plausibly fixed by the frame-close latch word (a strand whose last data bit ended HIGH missed its reset that frame); soak-observe on the wall before closing.
  • Prime barrier fps cost. The ring's prime holds a busy-wait until the previous frame's deterministic wire end (waitWireDrained in primeRingRange — the barrier that keeps the next prime off buffers the DMA is still draining; the frame's last slices lap into the FIRST buffers, so the prime hits them first and no counter sees the repaint). The wait is ~0 when the snapshot + render gap already span the wire, but on fast frames it serializes wire → prime and caps fps at 1/(wire + snapshot + prime). If the fps work wants that overlap back, the barrier can go finer-grained (per-buffer: buffer b is safe once the drain passes slice b + nSlices − ringBufs) — measure first. Do NOT drain-gate done in the ISR instead (deadlocks; wall-measured as flicker-then-"no LED output").
  • Multi-strand loopback: the instrument drives one loopbackStrand, so it is structurally blind to a multi-strand fault (it passed while the wall was visibly corrupt).
  • Shift-mode loopback host coverage: nothing in the suite drives shift-mode loopback end-to-end through the mock bus — the detection gap that let two loopback bugs live unnoticed. (The stall bug itself is fixed: capture-first alloc + pool step-down; verified on two boards.)
  • Loopback teardown leak + heap fragmentation: cycling the private-bus loopback drops ~80 KB internal and the heap stays fragmented (measured maxBlock 13–44 KB with 240–310 KB free). Capture-first + step-down made runs reliable despite it, but the leak itself stands — chase when the loopback is next touched.
  • Ride-the-live-ring loopback — parked: built (loopbackIntrusive + the snapshot pattern hold) but an RMT-RX cannot capture on a GPIO the LCD_CAM is actively driving (reads 0 sym). Revival path: briefly output-detach just the RX pin for the capture window.
  • Diagnostic surface (ringDbg incl. tw/ts/tp + sg/se, the dbg statics, the kCyPerUs = 240 hardcode): kept deliberately through the tuning era; gate/remove when the ring fps work closes.
  • fps header reports the module TICK rate, not the frame rate (frameTime is the real one); any fps claim predating 2026-07-17 reads with that in mind.
  • ringAuto "just works" — verify the geometry pick on smaller configs. ringAuto (default on) derives ringRows/ringBufs/ringPadUs, and those manual knobs are already dev-only (setAdvanced, expert mode). The pick is wall-verified at 48×256; the smaller common configs (16×256 and below) still need a bench pass. If any has a strictly better geometry ringAuto misses, teach it that ONE measured rule (no per-effect/per-density heuristics — the flicker was never geometry). ringAuto itself stays visible as the recourse until this proves it always picks right, then it can go expert-only too. (The fork-join flicker that once blocked this shipped fixed — snapshot fork removed.)

PSRAM-at-shift-clock: verified NOT a viable lever for more lights/strand (research, 2026-07-16)

A recurring idea is to "borrow from direct mode": direct mode streams a huge frame straight from PSRAM (2048 lights, clean; SE16 drives 8192 lights direct/whole-frame at ~19.5 ms), so could shift mode run a lower pclk and stream its whole frame from PSRAM too, trading fps for unlimited length? Verified answer: no. The shift pclk is bounded by the WS2812 waveform, not by the '595 and not by divider elegance: slot = 8 bus words / pclk, and lowering the clock lengthens T0H toward the max-white washout. The practical floor is the shiftOverclock-OFF rate, 20 MHz (T0H 400 ns — wall-verified; 16 MHz is already all-white), which barely dents the PSRAM demand. There is no shift pclk that is both slow enough to stream from contended PSRAM and fast enough to keep T0H under the 0-vs-1 threshold.

The bandwidth arithmetic (datasheet-derived): DMA demand = bus-bytes × pclk. Direct 8/16-bit = 2.67/5.33 MB/s; shift 8/16-bit = 26.7 / 53.3 MB/s. S3 OPI PSRAM (octal, 80 MHz DDR) is 160 MB/s theoretical but only ~40–84 MB/s sustained/contended in practice (Espressif's external-RAM guide: DMA-to-PSRAM bandwidth "is very limited, especially when the core is trying to access external RAM at the same time"; PSRAM shares the flash cache region). So direct demand sits far under the floor (streams fine — proven), while shift 16-bit demand exceeds the ~40 MB/s contended floor and shift 8-bit sits inside the underrun zone once WiFi/HTTP/CPU cache traffic competes. Because WS2812 is one unbroken self-clocked stream, one FIFO underrun garbles the rest of the frame. This is datasheet-consistent with, and MEASURED to match, ADR-0014's controlled A/B (board B, same PSRAM/chain, only the clock varied: 2.67 MHz PSRAM drives, 26.67 MHz PSRAM never completes at any size) and the 2026-07-16 forceRing re-confirmation (whole-frame at 2880 stalls). Proven: the effect (PSRAM stalls at the shift clock, drives at the direct clock). Not instrumented (needs a bench measurement if ever doubted): the exact mechanism — contended-sustained-rate FIFO underrun vs PSRAM read latency vs cache/MMU contention — was inferred from the clock being the sole variable, never isolated with underrun/bandwidth counters.

Conclusion — this does not open a new path; the proper ring fix already is the path. The internal-RAM footprint of the ring is NOT set by light count: the ring transposes from a PSRAM-resident source into a small fixed internal buffer pool, so PSRAM is never on the DMA's read path at all. The 240-light wall is the kRingBufs=16 no-reuse stopgap (the wrap read-while-write race), NOT the ring's design — and "more buffers" is a confirmed dead end. The shipped ring (above) holds internal RAM constant at arbitrary light count, which is exactly the "unlimited lights/strand" the PSRAM-hybrid idea was reaching for — obtained the correct way, at the mandatory shift clock, without PSRAM on the read path. Action: none — the ring shipped; the "lower shift pclk + PSRAM whole-frame" hybrid is closed as physically blocked and should not be re-attempted. (If the mechanism is ever contested, the one bench measurement worth doing is registering GDMA underrun/FIFO-empty counters at 26.67 MHz whole-frame-PSRAM to distinguish underrun from latency — but it would not change the conclusion.)

MoonI80 ring — boot / first-frame-after-rebuild trips a transient give-up status (2026-07-16)

A cosmetic residual left after the rebuild-wedge fix (below): on boot, and for a beat after any shift-ring rebuild, the driver shows "output stalled" even though wireUs reports live completions — the first frame after a fresh ring build occasionally misses its completion window and trips the dead-frame give-up before the ring settles, so the stale error latches until the next interaction clears it. It is NOT the old wedge (that stayed dead until reboot; this self-clears on any control edit and the ring is genuinely driving underneath). Two clean fixes to weigh: (a) don't count the very first frame after a rebuild toward deadFrames_ (give the fresh ring one grace frame), or (b) have the give-up retry re-derive status the moment wireUs shows a real completion. Low priority — the LEDs drive correctly; only the status string is briefly wrong.

Graceful blank-on-stall — a stalled bus should DARKEN the strip, not leave it lit with garbage (2026-07-15)

When the bus stalls mid-frame the WS2812 strip is left holding random / max-brightness lights (often all-white — the all-ones failure pattern) that only a power cycle clears. That is a robustness gap: WS2812s latch their last received color and hold it until re-clocked or power-cycled, so a frame that dies mid-stream leaves every light past the failure point stuck bright. The give-up guard today stops spending the render thread on a dead bus (correct) but does nothing about the strip's state, so the user sees a wall of garbage LEDs and reaches for the plug.

The fix: on give-up (and on a rebuild that SHRINKS the reachable range), clock out ONE clean all-black frame — every lane LOW → every light receives 0,0,0 → the strip goes dark. This turns "stall = a wall of random bright LEDs until power-cycle" into "stall = strip cleanly dark," which is the honest degraded, not crashed state the Robustness rule asks for. It also covers the PO's specific case (drop ledsPerPin 256→128 and the abandoned 128–256 range stays lit): a full-length black frame on the shrinking rebuild blacks the whole physical strip once, no boundary to compute.

The load-bearing caveat: if the bus is wedged because it cannot complete a transfer, a black frame may not clock out either — so this is a best-effort attempt, not a guarantee: try the black frame on give-up; if it clocks, the strip darkens; if the DMA is truly dead, we are no worse off than today (and the rebuild-wedge fix above is the real cure for that class). Note it must be a genuine transmitted frame (all lanes driven LOW through the normal encode+transmit), not merely zeroing the DMA buffer — the strip only changes on a clocked frame. Pin it with a test: after kDeadFramesBeforeGiveUp dead frames, the driver emits one all-zero frame through the transmit seam (the mock asserts a zero frame was handed to the bus), and a subsequent recovery resumes normal content.

Classic-ESP32 shift-register ring on raw I2S — the high-light-count classic driver (WANTED)

The PO wants shift-register output on classic ESP32 boards at the MAXIMUM light count, which the current whole-frame I80LedDriver (esp_lcd i80 → I2S on classic) cannot deliver: its frame buffer must be internal RAM (the classic I2S DMA cannot read PSRAM — IDF rejects it outright), so it scales against a ~76 KB wall and caps at ~2048 lights. Above that, classic needs the hpwit refill-ring model: a handful of tiny internal DMA buffers (~1.2 KB total, light-count-independent) refilled by the I2S EOF ISR, which transposes the next pixel row out of a PSRAM framebuffer as it goes. The DMA never touches the source array — only the CPU/ISR does — so the source lives in PSRAM and internal RAM stays constant. This is how hpwit (and WLED-MM/MoonLight on his driver) reach 48×256 ≈ 12K lights at ~100 fps on classic silicon with WiFi up, with the same '595 expander.

Why it must be a SEPARATE driver, not a flag on I80LedDriver: esp_lcd owns the classic I2S DMA and only does whole-frame, so the ring cannot be bolted onto the esp_lcd path — it needs a second classic driver written on the raw I2S registers (i2s_ll / the LCD-mode register file directly, below esp_lcd, the way the S3/P4 MoonI80 backend sits below esp_lcd on LCD_CAM). Its ISR is small enough to fit the classic's ~70 KB IRAM (hpwit's does), and — the key move — it registers the interrupt without ESP_INTR_FLAG_IRAM (his source comment: removed "to avoid Cache Disabled but Cached Memory Region Accessed") so the refill ISR is legally permitted to read the PSRAM framebuffer. The trade it accepts vs. our whole-frame path: it gives up the whole-frame path's WiFi-underrun immunity (a WiFi burst that starves the refill ISR can glitch a frame), which the ring mitigates with a tunable buffer-count cushion (nbDmaBuffer, hpwit's default 6). For a ≤2K-light WiFi-busy install the whole-frame i80 is still the better choice; the ring is for the >2K-light case classic cannot otherwise reach.

Reference (study, don't copy — write fresh against our architecture): the line-by-line source read is in led-driver-psram-ring-analysis.md; the ADR framing is ADR-0014 (which calls the internal-RAM-ring-with-CPU-refill "the only thing that can ever work on the classic ESP32," deferred to a phase 2). The S3/P4 MoonI80 ring is the closest in-tree prior art for the ring mechanics (linear self-terminating chain, per-drain refill, drain-count termination) — but its refill is a task and its buffers are internal-only because the LCD_CAM GDMA can't sustain a PSRAM read at the shift clock; the classic I2S ring is the inverse (PSRAM framebuffer legal, ISR refill mandatory), so it borrows the shape but not the constraints. Do the S3/P4 ISR-refill + MM_HOT work first (it proves the ISR-refill pattern in-tree on the friendlier unified-DIRAM chips); the classic raw-I2S ring is the next tier up, reusing that pattern where IRAM is genuinely tight.

P4 Parlio streaming ring — lift the P4 Parlio ceiling past ~21K to light-count-independent (WANTED)

Port the ring concept to the P4 Parlio path, to drive far more than its current whole-frame ceiling. troyhacks' MoonLight Parlio driver reaches ~21K LEDs RGB (~16K RGBW) at 16 lanes — but NOT by materialising the whole encoded frame: he stages into a fixed ~512 KB PSRAM buffer and DMAs it out in 64 KB chunks (max_transfer_size = 65535), so the DMA never needs the whole frame contiguous. That is the SAME idea as our MoonI80 ring, applied to Parlio on the P4 (where — unlike the S3 shift clock — the DMA can sustain PSRAM reads at the WS2812 rate). His ceiling is a chosen buffer size, not a hardware wall, so it caps at ~21K.

Two tiers, in order: 1. Match him (moderate): chunk our Parlio DMA the way he does (bounded PSRAM staging + 64 KB bursts) instead of one contiguous encoded frame — this alone lifts our P4 Parlio ceiling to his ~16–21K. Our Parlio path already uses PSRAM; the change is the chunked-DMA transfer shape, not a new architecture. 2. Beat him (the ring): run our MoonI80 streaming ring on P4 Parlio — small internal buffers, refilled per drain from a PSRAM source, holding internal RAM constant regardless of light count (the hpwit model our ring already implements). troyhacks' 512 KB buffer is still bounded; the ring is not, so this goes past ~21K to light-count-independent. It reuses the ring mechanics we already have (linear self-terminating chain, per-drain refill, drain-count termination); the P4 Parlio backend gets a ring variant beside its whole-frame path, the way MoonI80 has both.

Prerequisite / sequencing: tier 2 wants the ISR-refill + MM_HOT work done first (same as the classic ring above), since a high-rate Parlio refill benefits from the ISR-grade determinism. Confirm the CURRENT P4 Parlio tested ceiling first (re-measure on the P4 bench) before claiming a head-to-head — the ~21K figure is troyhacks' buffer-fit limit, not a verified-on-wire number, and his code steps the clock down at 256/512 LEDs per lane (signal integrity at long strands), which our own measurement must account for. Reference: the parlio source is src/MoonLight/Nodes/Drivers/parlio.cpp in MoonLight; our Parlio backend is platform_esp32_parlio.cpp + ParlioLedDriver.h.

Extract shared lane-driver scaffolding when the 3rd parallel backend lands (deferred)

The I80LedDriver (classic-ESP32 I2S + S3/P4 LCD_CAM, both via the i80 bus) and ParlioLedDriver (P4 Parlio) share ~245 of 362 lines, and their platform-side loopback capture+verify is ~100 lines byte-for-byte identical (platform_esp32_parlio.cpp even notes "The RX capture half is byte-for-byte identical" to the i80 one). The status-string lifecycle (failBuf_ / configErr_ / clearFailBuf / clearConfigErr) is triplicated across all three LED drivers (RMT/i80/Parlio), ~60 lines. The branch deliberately extracted the encoders (ParallelSlots.h shared by i80+Parlio, RmtSymbol.h, PinList.h) on the "extract when the second user lands" rule, but stopped at the lifecycle/loopback scaffolding. Accepted for this merge (the reviewer agreed driver-level extraction can wait): the duplication is in mechanical lifecycle/test scaffolding, not domain logic, and a DriverBase-level refactor touching three drivers is riskier than the duplication it removes. Do it when the third parallel backend arrives (16-lane widening, or Teensy FlexIO), at which point the pattern is proven three ways: (a) a detail:: platform helper for capture+verify (the only per-peripheral difference is the transmit call, pass a callback, beside the already-shared loopbackJumperOk), and (b) a small owned-status helper or DriverBase members for the fail/config strings. Until then the cost is line count, not correctness.

Multicore Step 2b — ping-pong second output buffer (deferred, workload-gated)

The shipped render↔encode split (Step 2a, multicore control) uses one Drivers::outputBuffer_, so the composite is serialized at the boundary. A second output buffer would overlap even that (core 0 composites into B while core 1 encodes A), at the cost of one full frame buffer (~48 KB at 16K lights). The read-only stall KPI was built as the trigger metric, and it splits cleanly: a heavy effect (render-bound) shows stall ~1 µs (S3) / ~15 µs (classic) — 2b recovers nothing, core 0 already fills the encode window; a light effect on many lights (output-bound — a solid colour or slow gradient across 16K lights) shows stall 6–11 ms — 2b recovers that whole wait. So it's a real but narrow win: gated on the product owner naming that light-effect/many-lights workload as worth the 48 KB, not on a generic fps gain. (Design context in the shipped Plan-20260713 - Multicore Step 2.)

Frame pacing — decided against (record)

MoonLight targets a fixed 60 fps; projectMM deliberately does not (settled with the PO 2026-07-12). The architecture is render-uncapped + time-aware effects (beatsin8/millis()-driven, a CLAUDE.md hard rule), so a whole-engine fps cap is redundant with that rule and would only reduce quality below the hardware ceiling; the LED wire rate already paces render physically (30 µs/light), and UI/WiFi responsiveness comes from the per-tick vTaskDelay(1) yield, not frame-rate control. Parked as a ~15-line opt-in (targetFps=0 = unlimited default) only if a genuinely CPU-starved device ever appears.

ArtPoll discovery — know which tubes are alive (next increment on NetworkSendDriver)

NetworkSendDriver now unicasts to a list of receivers (ips + lightsPerIp), which is the Art-Net-4-conformant model. What it cannot do is tell whether a receiver is actually there: UDP is fire-and-forget, so a dead tube is invisible to the sender. The spec's own answer is discovery — "The transmitting device must regularly ArtPoll the network to detect any change in devices which are subscribed" — and it is the natural next increment.

Why it earns its place (product-owner experience, 2026-07-13): a dead tube in a prior setup made the other tubes hiccup. The mechanism is that a send to an unresolvable address stalls or errors inside the frame loop, delaying the packets for every destination after it — so one dark tube degrades the live ones, every frame. The send loop is now written to tolerate that (a failed sendToAddr drops that packet and moves on, and under multicore the whole send is off the render core), but tolerating is not the same as knowing: with discovery we can simply skip destinations that haven't answered, which removes the stall at its source rather than absorbing it.

Scope (~130–150 lines + tests; the receive half already exists). ArtNetPacket.h already builds+parses, UdpSocket already binds and reports the sender's IP: - ArtPoll send — broadcast a 14-byte poll every ~3 s (this is the one packet Art-Net does broadcast, by design). - ArtPollReply parse — fixed-layout 239-byte reply: node name, IP, and its subscribed universes. - A node table — IP → last-seen; mark offline after ~9 s of silence (3 missed polls, the spec's own cadence). - Use it: skip offline destinations in the send loop; surface the live/dead list as a read-only status. Bonus, and arguably the real prize: auto-populate ips — the user stops typing addresses at all, which is how a professional controller behaves.

Do it as its own increment. The multi-destination unicast it builds on has shipped.

RS-485 / DMX-512 wired output (future) — the physical-DMX driver

projectMM already speaks DMX over the network (Art-Net / sACN via NetworkReceiveEffect). The missing half is wired DMX-512 out: driving DMX fixtures (moving heads, par cans, wired pixel controllers) directly over an RS-485 differential pair, which is what the RS-485 hardware on carrier boards like the MHC-WLED ESP32-P4 shield is for. DMX-512 is a 250 kbps async serial frame (a break + mark-after-break + 513 bytes: start code + 512 channels) shipped over RS-485 — the textbook fixture-control transport. A DMX driver would map the light buffer (or a fixture/attribute model — see the Fixture model — moving heads, beams item below) to DMX channels and clock the frame out a UART in RS-485 mode.

What it needs that we don't have yet: - A platform:: UART-RS485 seam. The ESP32 UART has a hardware RS-485 half-duplex mode (uart_set_mode(UART_MODE_RS485_HALF_DUPLEX)) that auto-drives the transceiver's DE/RE (driver-enable / receiver-enable) line — the thing our current pin handling has no concept of (we drive pins as plain GPIO). A DMX driver is where DE/RE control first earns its place. The bench insight that surfaced this: the P4-shield loopback couldn't work partly because we have no DE/RE handling to flip its RS-485 transceivers Tx↔Rx. - The DMX frame timing — the break/MAB is generated by a baud-rate switch or a GPIO toggle around the UART frame; standard, host-testable as an encoder. - A fixture/channel-mapping model — trivial for a dumb pixel-per-channel strip, real work for typed fixtures (pairs with the moving-head fixture-model item; a wired-DMX driver and a network-DMX(Art-Net) input would share that fixture model).

The channel-mapping half is now unblocked. The per-light encode path handles an arbitrary channel count as of 2026-07-13 (the WS2812 drivers' per-light scratch is heap-sized to outChannels, no fixed cap — the fix from the multi-channel-preset bootloop, see lessons.md). A DMX universe is exactly that model: a light with channelsPerLight = <fixture footprint> (16-ch moving head, 7-ch par, …), and the buffer's bytes ARE the DMX channel values. So a DMX driver's "map the buffer to channels" step is now the trivial part — it ships the light buffer's bytes straight into the 512-channel frame. What remains genuinely new is the transport (the RS-485 UART seam + break/MAB timing) and the typed-fixture model (naming which channel is Pan vs Dimmer — the moving-head fixture item), not the encode.

Can a board drive XLR fixtures directly? Yes, with an RS-485 transceiver — that's the one required part. DMX-512 is RS-485: a differential pair (D+/D−, ±2–6 V), not the 3.3 V single-ended UART the MCU emits, so an MCU TX pin can NOT wire straight to XLR. A transceiver chip (MAX485 / SN75176 / THVD-class, ~$0.50) sits between the UART and the connector and drives the differential pair; the DE/RE line (the UART-RS485 seam above) flips it Tx↔Rx. 3-pin XLR carries it: pin 1 = ground, pin 2 = D−, pin 3 = D+. With a transceiver present, daisy-chaining ~10 moving heads (10 × 16 ch = 160, inside one 512-channel universe) over standard DMX in→out is well within the RS-485 limits (32 unit loads / 1200 m); the last fixture wants a 120 Ω terminator (a fixture/cable concern, not the MCU). So whether a catalog board can drive XLR directly hinges on one schematic question: does it carry an RS-485 transceiver + XLR/terminal (then yes, direct), or only the WS2812 level-shifted outputs (then a ~$0.50 breakout is needed). Confirm against the MHC-WLED ESP32-P4 shield schematic before treating direct-XLR as a shipping capability.

Sequencing: it's a driver (src/light/drivers/) + a platform UART-RS485 seam + a fixture model shared with the Art-Net path — the buffer→channel encode is already done. Plan when a DMX fixture is actually on the bench and a catalog board's supported/planned list points at wired DMX. The PinsModule pin-assignment work covers the RS485/DMX TX/RX/DE slot; this is the driver that consumes it.

Sensors and audio-reactive input

Audio-reactive follow-ups

The manual level + 16-band FFT spectrum has shipped (AudioService; what landed and why is in lessons.md). These are the deferred follow-ups, each its own increment:

  • Per-band noise-floor (kill a steady single-frequency hum) — the bench mic picks up a constant ~258 Hz tone (a mains harmonic via the mic/supply) that lights one band even in silence. A high-pass can't remove it (it's well above the ~40 Hz DC-blocker cutoff) without also killing real bass; the clean fix is a per-band adaptive floor that learns each band's idle baseline and subtracts it, so a constant tone in one band gates to dark while the others stay sensitive. Minimal version ≈ 16 floats of state + ~16 ops/frame. This is the next concrete audio step.
  • Adaptive conditioning — auto noise-floor / auto-gain / smoothing so the display self-calibrates to a room ("sound off → dark, sound on → vivid") instead of being tuned by hand. A self-calibrating version was prototyped and removed; the manual floor/gain is the shipped baseline. Reinvent from scratch when wanted, and tune it in a quiet room — a noisy environment (a strong, varying low-frequency ambient) is the adversarial case that made the prototype hard to settle. (The per-band floor above is the first piece of this.)
  • Adaptive noise gate — replace the borrowed squelch/floor-as-gate with a real noise gate: asymmetric bang-bang timing (open fast, close slow), a relative "detect silence" test (thresholds as factors of a learned floor, not absolute sample counts), keying off the RMS envelope we already compute, GEQ/FFT bands left untouched. A softhack007 concept; analysed and judged in full (good idea, industry-standard, but tight on the <30ms budget; decompose into steps rather than overhaul) in AudioService.md § Adaptive noise gate. The recommended sequencing: the per-band floor above is step 1 (its complementary frequency-domain half), the relative-threshold-over-RMS is the cheap high-value cherry-pick as step 2, hysteresis/timing step 3, log-domain + soft-gate optional. Eventually retires the manual squelch.
  • Pin auto-scan — detect the mic's sdPin with wsPin/sckPin fixed (a noise-prompt + confirm convenience); ships today with explicit pin controls.
  • Beat / onset detection beyond the raw peak; more audio effects (2D / palette-driven frequency-reactive).

GyroDriver → core Service move + AudioService-consistency pass (branched, not merged)

A working GyroDriver (MPU6050 IMU over I²C) exists on an unmerged branch (commit 11f8eb7, "Add GyroDriver (MPU6050) + generic platform I2C layer"); it is not in this branch's tree. This entry reverse-engineers that commit so the move is tracked now. Verify against the real implementation when the branch merges, then delete this entry.

What the commit contains (reverse-engineered):

  • src/light/drivers/GyroDriver.h — reads an MPU6050 over I²C and surfaces five read-only telemetry controls (gyroX/gyroY/gyroZ rates in °/s, pitch/roll tilt angles). Polls the sensor in loop20ms() (50 Hz), formats the display strings in loop1s(). WHO_AM_I probe + wake on setup(), big-endian 14-byte burst parse, atan2-based tilt (no fusion filter).
  • A generic, domain-neutral platform I²C master (platform::i2cInit/i2cWriteReg/i2cReadRegs, 7-bit addressing) so future sensors reuse it; ESP32 impl on the IDF v6 i2c_master driver in a new platform_esp32_i2c.cpp, plus an MPU6050-shaped desktop simulation so the UI and host tests see live values without hardware.
  • unit_GyroDriver.cpp — WHO_AM_I probe, simulated burst parse, control formatting, time-ramp tracking.

The move: it currently masquerades as an input-only driver under the Drivers container (a no-op setSourceBuffer(Buffer*) override {} is the tell). It belongs as a Service — a user-added child of the Services container, exactly like AudioService — both are sensor services that poll hardware and publish read-only telemetry. On the move, make it consistent with AudioService (the established sibling pattern):

  • Relocate src/light/drivers/GyroDriver.hsrc/core/ and its spec docs/moonmodules/light/drivers/GyroDriver.mddocs/moonmodules/core/; change role() to Service; delete the setSourceBuffer no-op; rewrite the doc's "input-only driver under the Drivers container" framing. (Rename to GyroService too, to match the AudioService/IrService convention — a Service names itself by its subcategory.)
  • Pin controls + rebuild path. GyroDriver hardcodes SDA/SCL (static constexpr 21/22, with its own "Hardcoded until BoardModule exposes I2C pin mapping" comment). AudioService already shows the pattern: editable uint16 pin controls + controlChangeTriggersBuildState + a reinit() on onBuildState. Adopting it retires the hardcoded-pins TODO and satisfies the robustness rule (reconfigure in any order).
  • Lifecycle. GyroDriver has setup() only — no teardown(). Add teardown for symmetry with AudioService's setup/teardown/reinit (the shared I²C bus has little per-instance state to free, so this is consistency, not a leak fix).
  • Document the cadence difference. GyroDriver polls in loop20ms() (50 Hz is plenty for tilt); AudioService reads in loop() every tick because I²S DMA must be drained promptly or it overflows. Both are correct; add a one-line "why this cadence" comment at each so the two siblings aren't "harmonised" into a bug.
  • Add it to the deviceModel catalog under parent_id: "Services" (user-added per board), the same shape as AudioService — a gyro sensor is optional per board, so it's a Service, not a wired-by-code System child.

Already done on this branch (the reverse direction): AudioService's two live read-outs were switched from addText+setReadOnly to addReadOnly (the display-only type, matching SystemModule and the way GyroDriver already does it correctly) — so the telemetry idiom is consistent before the gyro branch even lands.

Sensor input on Raspberry Pi 5 — microphone, IMU, line-in (post-1.0, multi-commit)

Audio-reactive lighting (and motion-reactive) is core to what WLED-MM / MoonLight are known for. The Pi 5 is the right host for it: it has the CPU and RAM for real FFT-based audio analysis that the Xtensa ESP32 struggles with, and a full Linux audio + I²C stack. None of this exists today — the codebase has no sensor, audio, or IMU concept, and the Pi currently runs the desktop platform backend (there is no src/platform/rpi/), which has no hardware access. So this is a domain expansion built on a real platform-backend prerequisite, not a small add.

Target sensors and their Pi 5 interfaces:

  • Microphone — I²S MEMS mic, or a USB audio device read via ALSA. The high-value one: FFT → frequency bands + beat detection drive audio-reactive effects.
  • Line-in — the Pi 5 has no native analog input, so this is a USB audio interface / DAC HAT feeding the same audio pipeline as the mic; only the source differs.
  • IMU / gyro — an I²C device (MPU-6050 / 9250-class) on the Pi's I²C bus; tilt / motion → effect parameters.

How it fits the architecture (the load-bearing part):

  1. The module category exists — ModuleRole::Service. Services are user-add/deletable children of the Services container (a gyro Service lands there via the GyroDriver→core move). What's missing for audio-reactive is the consumption side: a sensor reads hardware and produces values (audio bands, IMU axes) that effects consume — the producer side of the producer/consumer data-exchange model (a sensor produces an AudioFrame / ImuState the way effects produce a buffer that drivers consume). Define the producer struct domain-neutrally so it isn't audio-specific. Today's services are display-only; wiring them into effects is the new work.
  2. All hardware access stays behind the platform boundary. New platform:: APIs (e.g. readAudio() returning PCM/FFT, readImu() returning axes) with the ALSA / I²S / I²C implementation in a real src/platform/rpi/ backend — which is itself the prerequisite that doesn't exist yet (the Pi uses the desktop backend today). No ALSA/I²C include or call outside src/platform/.
  3. Effects consume sensor data the same way they read the layer. An audio-reactive effect reads the current AudioFrame (bands/level/beat) the way PreviewDriver reads what Layer produces — through a plain data structure wired in main.cpp, not a direct hardware call.

Increments (each a normal domain addition, picked up one at a time):

  1. A real src/platform/rpi/ hardware backend (GPIO/I²C/I²S/ALSA) — the prerequisite; until it lands, the Pi runs the desktop backend with no sensors.
  2. The producer struct(s) (AudioFrame / ImuState) + the platform::read* APIs. (The Service role + Services-container add/delete already exist.)
  3. The first audio service — MicrophoneModule (canonical, highest value: FFT bands + beat).
  4. The first audio-reactive effect(s) consuming it.
  5. IMU and line-in slot into the same source-module + platform-API shape afterwards.

Study the proven audio pipeline in MoonLight / WLED-MM (FFT band layout, AGC, beat detection) to inform our own — reference the approach, don't port their code, per history practice. Specs before code: a MicrophoneModule.md (and the source-category contract) get written and reviewed before implementation.

Effects and preview

DemoReel: extrude hosted lower-D effects (pending)

DemoReel hosts one effect at a time and drives its loop() directly. A hosted D1/D2 effect only writes its own slice (D1 → the x=0 column; D2 → the z=0 plane), and — unlike a normal Layer child — it does NOT get Layer::extrude() applied, so on a 2D/3D grid its output stays on one column/slice instead of spreading. A first fix (call layer()->extrude(child->dimensions()) after the child's loop(), mirroring Layer::loop) crashed the test suite with a heap/vtable smashLayer::extrude copies within buffer_ using the Layer's width_/height_/depth_, and something in the reel's host path leaves those out of sync with the allocated buffer (root-cause not yet pinned). Redo it carefully: verify the Layer's dims match its buffer at the extrude call, add a bounds guard in extrude (or a reel-local extrude that reads the child's real dims/buffer), and pin it with a DemoReel test that hosts a D1 and a D2 effect on a 3D grid and checks the spread. Until then the reel renders D1/D2 hosts on a single slice (visible but not full-grid) — acceptable, not a crash.

A real 2D/3D PacMan (pending)

The migrated 1D PacMan and 1D Ant effects were removed — a chase rendered on a single strip reads as a blob of moving dots, not a game, so they weren't worth keeping. A proper 2D (or 3D) PacMan — an actual maze, Pac-Man and ghosts navigating it, power dots, the blue-ghost flee state — would be a genuinely fun signature effect. Build it fresh as a grid game (not a strip port); the WLED/MoonLight 1D versions are reference for the state machine only, not the rendering.

Add real z-axis variation to 2D effects (pending)

Only NoiseEffect, PlasmaEffect and RipplesEffect have z-aware math. The other honest-D2 effects use Layer::extrude to duplicate the z=0 plane, so every z-slice is identical on 3D layers. Candidates for genuine D3 promotion: Metaballs/GlowParticles (add z to blob coordinates), Plasma palette/Spiral (add z-driven phase term), Fire (z-drift heat grid), Rings/LavaLamp/Checkerboard/Particles (add z to each element). Prioritise after seeing real 3D installations; each promoted effect also needs its dynamicBytes budget for the full 3D buffer.

Full-density interpolated preview for large layouts (backlog)

The preview index-downsamples a large layout to fit the WS send budget (e.g. 128×128 = 16384 lights → ~1639 sent at stride 10), so the UI shows a sparse sample, not every light. To show all lights at their real positions with interpolated colours for the unsent ones:

  • Decouple the 0x03 coordinate-table density from the per-frame 0x02 stride. Positions are static and sent once, so the table can carry all light coordinates (16384 × 3 = ~48 KB one-time — acceptable off the per-frame path, possibly chunked) while the per-frame RGB stays strided to protect ArtNet/the link.
  • The browser holds the full position set and, per frame, interpolates each unsent light's colour from its nearest sent neighbours (the sent indices are known from the stride). True positions, guessed colours — better than the removed dense-box block-replicate because positions are exact.
  • Open questions: 48 KB one-time table vs MAX_WRITE_CHUNKS / send-buffer (needs chunked send or a raised cap, with the same partial-write care as writeChunks' drain); interpolation cost on a 16384-point cloud each frame in JS; whether nearest-neighbour or weighted is worth it.

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.

Sub-item — per-emitter targets, and fixing the Yellow/UV RGB-synthesis placeholder. Correction::apply() today synthesizes every non-RGB emitter (White, WarmWhite, Yellow, UV) from RGB via the one whiteMode gate. That conflates two different physics: White/WarmWhite are broadband illumination with a real achromatic basis (min(R,G,B) is a sound approximation — warm vs cold differ only in phosphor CCT a byte value can't express), but Yellow/Amber (~590 nm) and UV (~400 nm) are saturated/out-of-gamut emitters with no honest RGB pre-image. Yellow's min(R,G) stand-in reads greener than a true amber die AND fires on far too much (any red+green content — yellows, whites, skin tones — muddies the fixture); UV's blue-excess is an eyeball hack. Amber is a real, common, targetable emitter (RGBA / RGBAW PARs ship a dedicated ~590 nm die), so the right model is an effect that targets amber DIRECTLY (a typed attribute), not RGB-and-synthesize. The fixture model is where that lands. Decision to make when built: whether Yellow/UV should default to off (0, like whiteMode::None) until an effect drives them — more honest than always firing a wrong approximation — vs. keeping the "light it up to eyeball the wiring" placeholder. Practical test to run then: on a real RGBA/RGBAW+UV PAR, compare the synthesized amber against a directly-driven amber die (does min(R,G) look acceptably yellow, or muddy?), and confirm the White subtraction under Accurate reads correct with all emitters present. Rationale + the current formulas live in Correction.h's emitter-field comment.

Mixing light types in one Layouts — open design question (undesigned)

Today each layout child describes one light type (all LED strips, or all par lights), and the current model is one Layouts container per light type. Whether a single Layouts should hold mixed types (LED strips + par lights together), and how the per-channel layout would reconcile across them, isn't designed. Deferred until a concrete need forces it; it's adjacent to the fixture model above (a real fixture/attribute model may reframe how mixed types are expressed). (Moved from architecture.md § What we leave undesigned; a deferred design decision, not a settled 🚧 one.)

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) — 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.

PreviewDriver resumableFrames default OFF: fix the tearing, then un-skip the dynamicBytes test

resumableFrames (the downsampled-frame transport A/B) now defaults OFF because the resumable path visibly tears the preview: it shares the single-occupancy WS send slot with the ~1 Hz full-state push and the next preview frame, so a preempted mid-drain frame reaches the browser spliced (top rows new, the rest stale — PO saw it on the wall). OFF uses the proven-correct synchronous transport. Two follow-ups, both small:

  1. The tear itself (the reason to eventually want ON again). Give the preview send its OWN send slot instead of sharing previewSend_ with the state push, OR make a preempted drain drop cleanly (versioned frame → the browser discards a spliced one) rather than splicing. Only then is ON safe to default. The off-thread send exists to avoid the ~17 ms render hitch at very large grids with the preview open, so this matters mainly for big walls; until fixed, synchronous is correct.
  2. The skipped test. unit_PreviewDriver.cpp's "reports its resumable-path buffers in dynamicBytes" is doctest::skip()'d: it was written when resumableFrames defaulted ON and its first assertion relied on the rig constructor's applyState() allocating the staging buffer via that default. With the flag OFF, toggling it ON post-construction + prepare() did not re-allocate the buffers in the test rig the way the constructor path did (the "ON" reads dropped to 0). The accounting (driverHeapBytes sums stageCap_ + keptIdxCap_) is unchanged and correct; only the test's default assumption broke. Un-skip by rebuilding the rig so it can deterministically allocate the resumable-path buffers with the flag OFF-by-default — likely wiring the flag ON into the rig BEFORE its first drivers.applyState() so the same acquire path production uses runs (the post-construction toggle route resisted several attempts; the constructor-time route is the one to nail). Small test-only work.

LCD / DMA driver work

Drop the i80 WR/DC sacrificial pins — done for MoonI80, open for I80

Shipped for MoonI80LedDriver (2026-07-14). Owning the GPIO matrix is what bought it: the matrix is a routing fabric, so a peripheral signal that is never connected to a pad simply stays inside the peripheral. dcPin is gone entirely (DC separates command from data bytes for an LCD panel; WS2812 has no such concept, and the peripheral holds it at a constant level), and WR is routed only when a '595 needs it as SRCLK — in direct mode WS2812 is self-clocked, nothing reads WR, and the pin stays free for a strand. Same trick frees the spare data lanes: they are simply not routed, rather than parked on a "ghost" GPIO. So a direct-mode MoonI80 board spends its GPIOs on strands alone.

Still open for I80LedDriver, and it cannot be fixed there. IDF's esp_lcd hard-requires both (esp_lcd_panel_io_i80.c: wr_gpio_num >= 0 && dc_gpio_num >= 0) and rejects an NC data pin, which is why it must park spare lanes on a real GPIO. Reclaiming the two pins on that driver means leaving esp_lcd — which is precisely what MoonI80 already is. So this is not a change to make to I80LedDriver; it is a reason to prefer MoonI80 once it is proven. Parlio (P4) never needed the pins (clk_out_gpio_num = GPIO_NUM_NC).

Parlio DMA frame buffer → PSRAM (free internal SRAM for big frames)

For driving lots of LEDs, internal SRAM is the scarce resource and the parallel-driver DMA frame buffer is the biggest consumer (8 lanes × lights × outCh × 24 slot-bytes + latch pad). The i80 driver already allocates PSRAM-first on the LCD_CAM chips (S3/P4) — platform_esp32_i80.cpp tries MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM under #if SOC_LCDCAM_I80_LCD_SUPPORTED, falling back to internal — which is why the SE16 reaches the full 16384-light frame (see performance.md § Multi-pin). The classic-ESP32 I2S i80 backend stays internal-only (its DMA can't reach PSRAM — a hardware limit, not a TODO). Parlio still allocates internal-only (platform_esp32_parlio.cpp), so a large Parlio frame can exhaust DRAM while PSRAM sits unused; the IDF confirms Parlio's GDMA can burst from PSRAM (esp_driver_parlio/src/parlio_tx.c sets access_ext_mem = true // support transmit PSRAM buffer). (RMT already does the right thing — its symbol buffer goes through platform::alloc, PSRAM-first with an internal fallback.)

The change (Parlio only): allocate the Parlio buffer MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM first, falling back to internal when PSRAM is absent/full, using the external-memory alignment the IDF requires (gdma_get_alignment_constraintsext_mem_align, typically the cache line) and keeping the buffer cache-aligned + its size a multiple of that alignment. Why its own increment: it changes the proven hot DMA path, PSRAM DMA has real caveats (cache-line alignment, write-back/coherence on the encode→DMA handoff, and lower PSRAM bandwidth that the IDF guards with a CPU-MAX DFS lock during transmit), and it must be re-proven on P4 hardware (the loopback self-test bit-verifies it, then a real strip). It also raises the Parlio ceiling toward the chunked-transfer goal. Measure the bandwidth headroom too: a very wide, long frame at speed may want internal SRAM regardless.

LED drivers — deferred

The LED-driver increments shipped: increment 1 (RMT/WS2812B single-strand on classic ESP32 — RmtLedDriver.h, RmtSymbol.h, platform_esp32_rmt.cpp) and increment 2 (2a multi-pin RMT, 2b parallel LCD_CAM on the S3 — LcdLedDriver.h via ParallelLedDriver.h, platform_esp32_lcd.cpp), all with host + on-board-loopback tests, hardware-proven. The locked decisions, file-by-file phases, the WiFi-flicker test-rig analysis, and the bench deviations (8-GPIO i80 bus, 2.67 MHz slot clock, SOC-macro gate, real-frame loopback) are in lessons.md, the driver docs, and the analysis docs. What remains here is only the work that has not shipped and is tracked nowhere else.

  • sigrok/fx2lafw cross-check + MoonDeck "LED driver test" Python script — the independent-clock proof and the run-from-MoonDeck flow (analysis §5.3). The on-board RMT-RX loopback (shipped) is the cheap CI correctness gate but a compromised witness for WiFi-induced flicker — the RX capture runs on the same ESP32 whose WiFi causes the glitch. The real flicker test is a sustained capture (seconds) with WiFi associated + a packet flood, decoding every frame for a byte-slip or reset-gap deviation; it validates the SHIPPED render↔encode split's WiFi isolation (drivers tick on core 1; WiFi lives on core 0). A DSLogic Plus (100 MS/s) upgrade is reactive — only if a flicker reproduces that 24 MS/s can't resolve.
  • Chunked transfer (Step 4) — the 16K lever, and now the ONE mechanism behind three separate ceilings. Split a frame into transactions the DMA can actually swallow, feeding them back-to-back. It was scoped as a Parlio fix; it is really a core-path fix, and the shift-register expander is only its third beneficiary.

The three ceilings it lifts, all unshifted-first: 1. P4 Parlio: ~4,096 lights. The 2026-07-12 16-lane sweep found the single-DMA ceiling at 256/lane × 16, reproduced within 0.3% on a second P4 — and the cause is not the 65,535-byte cap (256/lane is far under the 897/lane limit). The P4 has 33 MB free heap but the largest contiguous internal block is ~368 KB, and a single-shot 16-bit DMA buffer needs one contiguous block: at 512/lane init fails outright. So chunking is the only path to the 16×1024 = 16,384 lights the 16-lane widening promised. This is the headline win and has nothing to do with shift registers. 2. Classic ESP32: 2,048 lights. Its I2S DMA cannot reach PSRAM at all, so the frame must fit internal RAM. (This entry previously said chunking "would not lift that" — that assumed chunking a PSRAM frame the DMA still had to read. It does not hold for the staged form below: if the DMA only ever reads small INTERNAL chunks that the CPU fills from a PSRAM frame, the classic chip is lifted too.) 3. The 74HCT595 expander. Currently capped at ~96 lights/strand because its ×8 frame only renders correctly from internal RAM (§ 7.5). An add-on, and explicitly not the reason to build this.

Two distinct limits, one idea — keep them straight. For Parlio the constraint is transaction size (contiguous block + 65,535 bytes), so chunking means smaller transactions. For i80 there is no single-shot cap at all (it chains DMA descriptors) — there the constraint is where the DMA reads from, so the win comes from staging: keep the frame in PSRAM, but have the CPU copy it a chunk at a time into small internal-RAM buffers that the DMA reads. Same mechanism, different reason, and conflating the two is what muddled the shift-register investigation.

Prior art, and why staging is the shape. hpwit's drivers never DMA from PSRAM: the pixel data may live there, but an EOF ISR transposes it into small internal bounce buffers that the DMA clocks out (owner_check = false, a fixed ring, built once). Espressif do the same thing under a different name — the RGB-LCD driver's bounce buffers. The one place hpwit does DMA straight from PSRAM is the P4 Parlio path, and he chunks it (≤64 KB via IDF's parlio driver) — which is consistent with our own finding that the P4 handles a PSRAM whole-frame shift transfer fine while the S3 does not. The staged form gets hpwit's underrun immunity without leaving esp_lcd (still IDF-maintained, still one code path across classic/S3/P4, no raw-register driver) and without putting the CPU back on a per-LED ISR deadline — a bulk sequential PSRAM→internal copy per chunk is what PSRAM is good at, unlike a real-time streaming read.

The two mechanisms do DIFFERENT jobs, and the 100 fps target needs both. The WS2812 wire time is a physical constant — 30 µs per light, serial down each strand — so the only lever on frame rate is lights per strand, never the chip:

strands × lights total wire time fps ceiling
16 × 1024 (direct, 16 GPIOs) 16,384 30.7 ms 33 fps
48 × 256 (6 pins × '595) 12,288 7.7 ms 130 fps(hpwit + PO proved this in practice — StarLight)
64 × 256 (8 pins × '595) 16,384 7.7 ms 130 fps

So: chunking/staging buys LIGHTS (memory); the expander buys FRAMES PER SECOND (short strands). Neither substitutes for the other, and 12–16K at 100+ fps needs both — the expander's own ~145 KB frame is exactly the one that does not work from PSRAM today, so it depends on the staging fix. (A third piece is needed too: at 130 fps the CPU encode must finish in <7.7 ms, and it measures ~24 ms at 16K — that is what the multicore render↔encode split exists for.)

The classic ESP32 is a target, not a write-off. It is routinely dismissed for work like this, and the dismissal is wrong: hpwit and the PO have run 48 strands × 256 at ~100 fps on classic silicon (StarLight), with the same '595 expander, while WiFi was up. 240 MHz, two cores, and a 30 µs/light budget is a lot of headroom. The thing that would stop us is our own encode cost, which is software we control, not a property of the chip — and the ~24 ms/16K figure quoted in the multicore analysis is (a) measured on a P4, not a classic, and (b) pre-dates the SWAR transpose that shipped since. Do not carry that number into a classic-ESP32 feasibility argument; measure the real one on the real chip. What the classic genuinely needs is the staged form of chunking (its I2S DMA cannot reach PSRAM at all), which this item provides.

Build and prove chunking on the unshifted path first (Parlio 4,096 → 16,384 is the measurable win, on proven code), then let shift mode inherit it — that is a sequencing rule about where to de-risk the mechanism, not a claim that the expander is optional. It is not: it is the only route to 100 fps at this scale without spending 48+ GPIOs. Correct WS2812 inter-chunk timing is the one hard constraint: the lines must idle LOW for < 300 µs between chunks or the strand latches mid-frame. The driver already rejects an over-limit frame with a loud status. Measured detail: performance.md § Multi-pin. - rmtWs2812Show fuller error handling (deferred from PR #17 / 🐇 CodeRabbit). The shipped path has a finite rmt_tx_wait_all_done timeout (1 s) so a wedged DMA can't hang the render tick forever, and a dropped frame self-heals (the driver re-encodes the whole frame next tick). The fuller version — rmt_transmit return check, rmt_tx_stop to cancel an in-flight transfer on timeout, show() returning failure so loop() won't reuse symbols_ mid-transmit — belongs with the core-1 driver-task work, since that task owns the buffer lifetime and in-flight state the cancel logic needs. - Surface RMT symbol-buffer alloc failure as a status (bench-found 2026-07-12, multi-pin driving results). resizeSymbols() sizes for the driver's count window, so on a classic ESP32 (~90 KB heap) a whole-grid window (count=0 on a 128×128 grid ≈ 1.5 MB) fails to allocate: symbols_ stays null, tick() bails at its !symbols_ guard, and the strip goes dark with no status — the user sees nothing lit and no error. The fix mirrors the Parlio over-limit guard (already loud): when the symbol alloc returns null, set a clear "not enough memory — reduce lights or use start/count" status instead of silently idling. Small, robustness-principle work; pairs with the fuller RMT error handling above. - Auto-derived DMA buffer count (7 / 30 / 75 per analysis §7.4), 16-bit pipeline + dither (§7.3), shift-register expander stubs (§7.5). - IR RX live-reconfigure recovery — unconfirmed, park until it recurs (bench 2026-07-13, SE16). IR reception on the SE16 (IrService pin 5) went dead mid-session and only a hard reset brought it back; a warm/API path did not. Ruled out: not hardware (hard reset fixed it, receiver+switch+wiring fine), not LED-count (IR survives the full 16384-light / 8 fps load — a received code still toggled a control at max load), not a regression from the i80 commit (platform_esp32_ir.cpp untouched, the 1250 ns glitch-filter fix intact). Prime suspect (unproven): the session's live pin churn — including transiently setting the i80 clockPin to 5, which IS the IR pin — left GPIO 5 routed to the wrong peripheral, and the RMT-RX channel (a pin-keyed static behind platform::irStop/ensureChannel) didn't re-acquire cleanly on the next irRead; only a full GPIO re-init (hard reset) cleared it. This may be pure test artifact (nothing in a normal user flow points two live modules at GPIO 5). To conclude: from a fresh hard reset (IR working), in isolation set i80 clockPin=5 then restore clockPin=8 and check whether IR dies and whether it self-recovers without a hard reset — self-recovers → no bug (test artifact); stays dead → a real live-reconfigure gap in the IR channel re-acquire worth fixing (per No reboot to apply a configuration change). Small robustness/repro work; do it only if IR breaks again in real use. - Moving-head preview = peer interpreter. When moving heads land, the previewer must interpret channel semantics (pan/tilt/RGBW-at-arbitrary-indices) to render a moving fixture — the same light-preset model physical drivers use, interpreted to screen. This is why the increments named the abstraction "interpret the preset" rather than "apply correction / opt out": so Preview becomes a full peer here without a rename. Its own design plan when moving-head support starts. - Sparse light-preset editor. A LightPresets row currently shows one role Select per channel across the whole channels width — including the unmapped gaps a wide moving head has between its functions. For a fixture you usually only care about the few channels you drive (rgb, pan, tilt). The refinement: show only the mapped channels + an "add channel" affordance (pick a role → fills the first gap or grows the fixture), over the unchanged dense roles[] storage. A first attempt shipped and was reverted for edit bugs; redo it cleanly (the dense editor is the reliable interim). Prior art: GDTF / QLC+ fixture profiles (a fixture is a sparse {channel → function} map, not a dense per-channel array).

(The shared lane-driver scaffolding extraction — when a 3rd parallel backend lands — is tracked separately under § Extract shared lane-driver scaffolding above.)