Skip to content

feat: Add screen-follow ambient light from USB video capture - #114

Open
Gohnnyman wants to merge 29 commits into
MoonModules:mainfrom
Gohnnyman:feat/screen-follow-ambilight
Open

Gohnnyman wants to merge 29 commits into
MoonModules:mainfrom
Gohnnyman:feat/screen-follow-ambilight

Conversation

@Gohnnyman

Copy link
Copy Markdown

Lights around a display follow what is on it: a Video service publishes frames, an Ambilight effect gives each light the mean of the picture nearest it, and a Rectangle layout puts a strip around the screen.

New modules

  • VideoService — three sources: a synthesised test pattern, a binary PPM off the filesystem, and USB capture. hdr declares the source curve (off / HDR10 PQ / HLG), since MJPEG carries no metadata.
  • AmbilightEffect — a per-light box filter, averaged in linear light rather than in the encoding. Optional smoothing, edge depth, and letterbox detection.
  • RectangleLayout — a hollow perimeter, with four start corners, a direction, shared or split corners, and a wiring offset.

Capture is ESP32-P4 only. MJPEG off a UVC grabber, decoded by the P4's JPEG hardware. No other ESP32 has a High-Speed USB host, and at Full Speed MJPEG starves. Every other target links a stub whose init fails, which the service reports as a status rather than an error.

Two things ride along that are not about video: per-channel white balance plus a whiteLevel for the white die, and a cap on the current a frame may draw.

Verified — 2071 unit cases, 12/12 scenarios, and on an ESP32-P4 rev3 running this branch: tick 6,832 µs, FPS 146, of which Ambilight is 114 µs.

Gohnnyman and others added 29 commits August 26, 2026 17:40
Lights around a screen can now follow what is on it. A Video service publishes a
frame, an Ambilight effect gives each light the mean of the picture nearest it,
and a Rectangle layout puts a strip around the display.

Performance: not collected (no board attached this cycle).

**Core**
- VideoService publishes one frame per tick through a static seat, the same
  one-active-source election AudioService uses for its mic. Two sources: a
  synthesised test pattern that needs no hardware, and a binary PPM off the
  filesystem.
- VideoFrame is a POD with a borrowed pointer and a sequence number. `seq` is
  compared for inequality only, never ordering, so a consumer can tell "this is
  the frame I already have" without a wraparound rule.

**Light domain**
- AmbilightEffect averages a rectangle of the source per light position. The mean
  rather than a sampled pixel, because a single pixel flickers on grain and moving
  edges. `saturation` pushes each channel back out from its zone's luma, since
  averaging mixes hues toward grey.
- RectangleLayout emits a hollow perimeter, 2(width+height)-4 lights, with
  startCorner and clockwise describing the wiring rather than the shape.
- Drivers gain gamma and per-channel white balance. Both fold into the brightness
  LUT the driver already builds, so the hot path stays one lookup per channel and
  neither costs anything per light. Gamma is applied before the linear scales, or
  a colour would shift as the brightness slider moved.

**Tests**
- The frame-to-light mapping end to end through the real static seam, the PPM
  header grammar, and the eight wiring permutations of the rectangle.

**Docs**
- Video service, Ambilight effect, Rectangle layout, and the two new driver
  controls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An HDMI grabber presents itself as a UVC webcam, so the lights can follow a games
console or a set-top box rather than a file. The Video service's third source.

Performance: not collected (no board attached this cycle).

**Core**
- The device's own format list drives a dropdown. It is read when a device
  enumerates, which happens before any stream opens, so it survives a request the
  device refuses: the case where knowing what it does offer matters most. Filtered
  to MJPEG, since nothing else is decodable here.

**Light domain**
- Nothing. The effect reads frames through the same seam whatever produced them.

**Platform**
- MJPEG off the wire, decoded by the P4's JPEG hardware. Uncompressed does not
  fit: 640x480 YUY2 at 60 fps is 37 MB/s against a USB 2.0 host's 24.6.
- Decoding runs on a task of its own. jpeg_decoder_process() blocks and the render
  tick is MM_NONBLOCKING, so videoCaptureFrame is a triple-buffered index load and
  can carry the annotation honestly rather than suppressing the warning.
- hasUsbVideo needs both a High-Speed USB PHY and a JPEG decoder. "Has USB" is not
  enough: the S3 has USB but only the slow kind. The source is not offered where it
  cannot work, and the component is not pulled into those builds.

**Tests**
- The desktop platform advertises no formats and does not offer the source, so a
  config restored from a capture-capable board falls back rather than selecting a
  dead option.

**Docs**
- The USB source, and how to choose a format from what the device lists.

Untested on hardware: written from the component and IDF sources.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A white screen on 300 RGBW lights asks for about 12 A. Nothing stopped it, so a
supply sized for the average could brown out on a menu, and the failure looks like
a data problem rather than a power one.

Performance: not collected (no board attached this cycle).

**Light domain**
- Correction::measure() prices the frame before the emit loop and sets a scale
  apply() folds into its existing lookup. Off unless maxCurrentMa is set, and the
  scale is 256 at unity so an unlimited frame is bit-exact.
- Milliamps are configured per CHANNEL, not per light. A white die draws about
  twice a colour one, so a single per-light figure under-reports white-heavy
  frames, which is the direction that browns out a supply. This is the bug WLED
  carries as #3707. Defaults are measured on a 5 m SK6812 RGBW strip.
- measure() reuses apply()'s own arithmetic, the same LUT and the same white
  derivation, so the estimate cannot drift from what is emitted. The same white
  frame costs 40 mA a light under Min and 16 under Accurate, and a limiter that
  assumed the worst would dim the cheaper mode for nothing.
- Wired into RmtLedDriver. limitsCurrent() hides the controls on drivers that do
  not measure, rather than offering settings they ignore.

**Tests**
- The numbers, not just that something got smaller: off is bit-exact, a frame
  inside budget is untouched, an over-budget one halves, and the estimate follows
  whiteMode rather than a per-light constant.

**Docs**
- The three controls, and why the milliamps are per channel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three gaps that only show up once the thing runs unattended behind a television.

Performance: not collected (no board attached this cycle).

**Core**
- A stream that stopped sending left its last frame published for ever, so a
  console going to sleep lit the room with a frozen picture. VideoService drops it
  after staleMs and every effect falls back to black. A control rather than a
  constant, and 0 keeps the last picture for a source you would rather hold than
  blank.

**Platform**
- A disconnect logged and did nothing: recovery meant toggling the source by hand.
  The frame callback flags it and the decode task reopens, because
  uvc_host_stream_open blocks for up to its timeout and so belongs on neither the
  event callback nor the render tick. The decode task's existing wait doubles as
  the retry heartbeat.

**Light domain**
- PanelCardDriver joins RmtLedDriver in pricing its frame against the budget: its
  window is flat, so it is the same call. ParallelLedDriver still has none. Its
  encode forks across both cores, runs from two dispatch sites, and reads either
  the live buffer or a windowed snapshot. An under-counting limiter reports safe
  while the supply sags, which is worse than an absent one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Border averages went straight to the strip, so film grain and compression noise
arrived as visible jitter. Lights now ease toward their new colour, and come up
from black rather than snapping on.

Performance: not collected (no board attached this cycle).

**Light domain**
- Each light walks a fraction of the way toward its new colour per frame.
  snapAbove lands anything big enough to be a scene cut at once, because smoothing
  a cut reads as the lights lagging the picture, which is the one moment you would
  notice.
- The accumulators are 8.8, not bytes. That is the whole mechanism: a slow setting
  moves a channel a fraction of a count per frame, and in whole bytes every step
  rounds to zero and the light never arrives. Allocated only while smoothing is on,
  so off is the untouched path with nothing per pixel.
- fadeInMs ramps the level up from black when a picture arrives after a gap: boot,
  a console waking, a grabber replugged. Its own control rather than a reuse of
  smoothing, which lags the colour and not the level.

**Tests**
- Convergence in both directions. A shift floors, so a falling channel's last steps
  round away from zero and a rising one's round to it: they arrive by different
  routes and only one of them for free.
- The rig gained a tick that advances the source, because a test measuring
  per-frame behaviour against a frozen service measures nothing.

**Docs**
- The three controls, with the setting that matches Hyperion's default feel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A border light's zone is 1/height of the frame, a sliver at the very edge where
compression is worst and thin dark borders live. It can now reach further in
without changing how many lights there are.

Performance: not collected (no board attached this cycle).

**Light domain**
- edgeDepth is a percentage of height for the top and bottom, of width for the
  sides, which is what Hyperion's two depth parameters mean between them. It
  samples about 8%.
- It SETS the depth rather than raising a floor, so a value below a position's own
  share makes its zone thinner instead: useful when the strip sits against the
  bezel and should track the extreme edge.
- The depth is rounded up, so a small percentage on a small frame cannot land on 0
  and silently turn the control off.
- Interior positions are on no edge, so a video wall is untouched at any setting,
  and 0 keeps the plain division.

**Tests**
- Off is the plain division exactly, the outer row demonstrably samples deeper, a
  value below the natural share makes it thinner, and every interior position is
  byte-identical.

**Docs**
- The control, and that it sets rather than floors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The driver current limiting matters most for was the one without it: many strands
means more lights and more amps.

Performance: not collected (no board attached this cycle).

**Light domain**
- measureFrame() runs before encodeRows forks across both cores, so both halves
  read a limit that is already settled and no atomics are needed. The fork was the
  objection to doing this at all, and it dissolves once the measure sits outside
  the parallel section rather than inside it.
- The source resolves exactly as encodeRows does. A snapshot is pre-biased by
  -winStart_, so one base serves either.
- laneStart_ is a running sum of laneCounts_, so the lanes tile from winStart_ and
  one flat walk of their total covers precisely what the encode will touch.

**Tests**
- Three uneven lanes: sizing the pass by the longest instead of the sum
  under-counts, which reports a frame safe while the supply sags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A 2.35:1 film puts black bars exactly where the top and bottom lights look, so they
go dark while the screen is bright. The lights now map across the picture found
inside the frame.

Performance: not collected (no board attached this cycle).

**Light domain**
- edgeDepth cannot fix this, which is why detection is a separate thing: it widens
  a zone from the edge, so the bar stays inside it however deep it reaches.
- With no bars found the picture is the frame, so off is the untouched path.
- barLevel because bars are not black after compression. 12, about 5%, matches
  Hyperion.
- A reading is refused past 40% of an axis, so a dark SCENE cannot blank the strip,
  and adopted only after 30 agreeing frames, so bars appearing at a cut do not
  twitch the mapping.
- Eight probes per scanned line rather than every pixel. A bar is uniform, and the
  whole scan costs a fraction of one averaging pass.

**Tests**
- A letterboxed PPM: the top light reads black on the first frame and the picture
  after the hysteresis window. A frame that fills the picture reports no bars, so a
  full-frame source is never cropped.

**Docs**
- The two controls, and what to suspect when bars are never detected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old 640x480 is 4:3, so a 16:9 source letterboxes into it and the top and bottom
lights average bars on everything, menus included.

Performance: not collected (no board attached this cycle).

**Core**
- 848x480 is the opening bid only. The device's own list still drives the dropdown,
  and it is read when one enumerates, so a wrong guess costs a single failed open
  rather than leaving the user with nothing to pick from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A border layout maps about 9% of its logical box to an LED, and the effect averaged
all of it: the other 91% was work the mapping then discarded.

Performance: not collected (no board attached this cycle).

**Core**
- MappingLUT::hasDestination answers it in O(1). An empty CSR run is two equal
  offsets, and an identity mapping short-circuits, so a video wall is unaffected.

**Light domain**
- The box is cleared first. Skipped positions never reach an LED, but PreviewDriver
  reads the raw buffer and would otherwise show a ghost image where none is lit.

**Tests**
- A real border layout, since every existing case used a grid where nothing is ever
  skipped and would have passed against a broken implementation. The perimeter
  carries the picture and all 36 interior positions are exactly black.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asking the mapping per position still walked a box that is mostly unlit, and
cleared it every frame. On a 200x200 border layout that was 1.74 ms a frame of
work whose result nothing reads.

Performance: not collected (no board attached this cycle).

**Light domain**
- prepare() packs the positions that reach an LED into y<<16|x, and tick() walks
  those: no per-frame clear, no walk over positions the mapping discards. A one-off
  3.1 KB at that size.
- The unlit ones keep the black Layer::prepare() left on the same rebuild, which
  nothing but the preview reads.
- A table-free mapping lights everything, so it keeps the plain loop rather than a
  list that would be 0,1,2,3... the size of the box.
- The fallback when the list cannot be allocated still asks the mapping per
  position. Painting an unlit one leaves a ghost image in the preview, and a clear
  alone does not prevent that: the loop would overwrite it.

**Tests**
- The interior stays black across further frames, not only the first, so the
  dependency on Layer::prepare() clearing the buffer fails here rather than showing
  as a ghost in the preview.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two builds the layout could not describe: four separate strips rather than one bent
around a frame, and a run that starts partway along an edge.

Performance: not collected (no board attached this cycle).

**Light domain**
- sharedCorners off gives 2(w+h) instead of 2(w+h)-4. Each edge keeps its own end,
  so two lights land on every corner coordinate. A 20x10 box is 60 lights rather
  than 56.
- offset slides where index 0 sits. It rides walkIndex's existing modular walk, so
  it composes with startCorner and a full lap wraps to nothing.
- Both are wiring rather than shape, except that sharedCorners changes the light
  count.

**Tests**
- The count, that the extra lights land ON the corners rather than past them, and
  that offset rotates the order while leaving the coordinate set identical.

**Docs**
- Both controls, with the light count for a worked example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The render loop can outrun the source, 60 Hz against 30 fps video, and re-averaging
a frame it already painted produces the same pixels.

Performance: not collected (no board attached this cycle).

**Core**
- The file source re-presents its picture per tick instead of publishing once and
  going quiet, the way a camera pointed at a still object does. Without that it was
  the one source whose seq never moved again, and the effect would have needed its
  own machinery to cope, a forced-render flag and a bar-hysteresis guard, to work
  around a source behaving unlike the others.

**Light domain**
- What the effect advances then runs at the source's rate rather than the loop's,
  which is the rate it should run at: the fade is wall-clock so it only samples
  less often, and the smoother has nothing to move toward while its target stands
  still.

**Tests**
- A repeated frame leaves the strip untouched rather than half-painted or cleared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
105 commits upstream. This branch is video IN, UVC capture and screen-follow
lighting; that work is video OUT, HLS and NDI drivers and the P4's H.264 encoder.
No overlap in platform.h, and none of this branch's new files exist upstream.

Performance: not collected (no board attached this cycle).

**Light domain**
- Correction::apply() changed on both sides for different reasons and they compose.
  Upstream adds the srcChannels parameter, the master dimmer and the motion remap;
  this branch keeps the per-channel briLut and the current-limit scale.

**Core**
- addUint8 / addUint16 / addBool are now one overloaded addControl. Renamed at this
  branch's call sites in VideoService, DriverBase, AmbilightEffect and
  RectangleLayout.

**Platform**
- Dropped this branch's desktop audioMic stubs, superseded by
  platform_desktop_audio.cpp: keeping both gave duplicate symbols.

**Scripts/MoonDeck**
- Removed moondeck/event, deleted upstream.

Resolved by taking upstream's generated files (repo-health, scenario observations)
and keeping both sides everywhere the two had added in the same place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The current limiter missed a channel that draws at full every frame, and the
prose this branch added did not meet the writing rules that arrived with v4.0.0.

Performance: not collected (no board attached this cycle).

**Light domain**
- Correction::measure() counts a master dimmer. It is held at 255 every frame, and
  on an addressable strip every byte is a die: the IRGB preset puts a Dimmer on one
  of them, and 300 lights of that is amps the budget never saw. Outside the loop,
  since the value is a constant. On a fixture with its own supply this
  over-reports, which is the safe direction, and the drivers that feed one do not
  price frames at all.

**Docs**
- Rewrote this branch's four sections as bullets. The Video entry was stale: it
  still described only the test-pattern and file sources, with no usb, offered or
  staleMs. Added the format-choice guidance, the current-limit controls, and the
  two new Rectangle controls.

**Tests**
- A dimmer draws on a black frame, so the limiter sees it.

Every line this branch adds is now free of em-dashes. Upstream's own prose is left
alone: an earlier pass rewrote 281 lines of a file this branch had added 25 to,
which was reverted.
Co-authored-by: Gohnnyman <57104366+Gohnnyman@users.noreply.github.com>
Fourteen findings from the PR review. Twelve fixed, one resolved by removing the
feature it was about, one accepted with a reason.

Performance: not collected (no board attached this cycle).

**Light domain**
- The current estimate summed into a `uint32_t`, which a 500x500 grid at the top
  of the milliamp range overflows three times over. A wrapped total reads as a
  small demand, so the limiter switched itself off exactly where it was needed.
  64-bit throughout.
- A master dimmer was priced as though the scale would shrink it, but apply()
  holds it at 255 whatever the limit says. It now comes off the budget first and
  the colours scale into what is left, with limit 0 when the fixed draw alone is
  over.
- Yellow and UV are emitted from the same corrected RGB and were unpriced, so an
  RGBWYP fixture could exceed its cap. Each has its own milliamp figure, since
  amber sits near red while a UV die usually draws more, and both are shown only
  where the fixture carries them.
- PanelCardDriver no longer claims to limit current. It streams over raw Ethernet
  to receiver cards with their own power, so there was nothing on this board's
  rail to cap. That also removes the reported hole in its raw fallback: the fix
  was to delete the feature rather than patch it, and it now matches
  NetworkSendDriver and Hue.
- Turning black-bar detection off cleared the adopted bars but left `candidate_`
  and a saturated `stable_`, so re-enabling agreed with itself and never adopted
  again. All three are cleared.

**Core**
- The PPM parser checked that a byte followed maxval but not that it was
  whitespace, so "P6\n2 2\n255X" was accepted with the X eaten, shifting every
  channel one place.

**Platform**
- The advertised-format list used its count as a lock, which it is not: a reader
  can load a nonzero count in the instant before the writer clears it. Double
  buffered, so a rewrite cannot touch what a reader is copying.
- Teardown waited 500 ms for the decode task, which may be inside
  uvc_host_stream_open's own 3 s. It then freed the semaphores and the JPEG
  engine underneath it. It now clears `lost` so no new reopen starts, wakes the
  task, and waits without a deadline.
- A first open that found no device tore everything down, so plugging a grabber
  in after boot did nothing until the source was re-selected. It now keeps the
  decode task alive with `lost` set, which is the same retry a replug uses;
  reopen() sizes the buffers if the first open never got that far.
- reopen() ignored a failed uvc_host_stream_start and cleared `lost` anyway, so
  retries stopped permanently while no frames could arrive.

**Tests**
- The letterbox fixture wrote to a hard-coded `build/` path while ctest roots the
  filesystem in the build tree, so the file landed where the service never looked.
  The test passed only when the binary ran from a checkout, and failed under
  ctest. It now asks for the resolved root.
- Cases for the dimmer's fixed cost, the per-emitter figures, the whitespace
  separator, and detection turned off and on again.

**Docs**
- The two new milliamp controls, which drivers offer them and why the others do
  not, and how a dimmer is counted without being scaled.

Not changed: the file source bumping `seq` per tick. The alternative asks the
consumer to carry a forced-render flag and a hysteresis guard to accommodate one
source that behaves unlike the others; re-presenting a still picture the way a
camera aimed at a still object does removes both. VideoFrame's comment now states
that contract rather than leaving it to be inferred.
Nine more findings, six of them defects the first round introduced or missed.
Two were holes in fixes made an hour earlier.

Performance: not collected (no board attached this cycle).

**Platform**
- The slot handoff read `published` and claimed it in two separate atomics. Between
  them the decoder could publish, then pick the slot just read as free and decode
  into a buffer being displayed. Both indices live in one word now, claimed with a
  compare-exchange; a lost race hands back the newer frame instead.
- Two banks were not enough for the advertised-format list: a reader loads bank 0,
  one connect event publishes bank 1, and a second overwrites bank 0 underneath it.
  A seqlock, so a reader that sees the generation move across its copy retries.
- A reopen only sized the decode buffers when there were none. Width and height are
  requests, so a replacement device can negotiate larger and every frame would then
  be dropped as oversized while the stream looked healthy. Sized on every reopen,
  grow-only.
- videoCaptureFormatGeneration() so a consumer can notice a rewrite without copying
  the list.

**Core**
- A grabber plugged in after prepare() enumerates on its own, but nothing read the
  new list, so `offered` kept its "no device" placeholder until an unrelated
  rebuild. tick1s compares the generation and asks for a prepare, which keeps the
  reading and the control rebuild on the cold path where they belong.

**Light domain**
- The modelled draw was floored, so one channel at 254 cost 7.97 mA and read as 7:
  a 7 mA budget applied no limit. Rounded up, because a cap that understates is not
  a cap.
- The lit-position list was sized by the box rather than by what goes in it, which
  is 156 KB on a 200x200 rectangle to hold 3 KB. Counted first, then sized.
- The repeated-frame skip ran after a prepare() that had just cleared the layer, so
  a rebuild against a source holding a frozen frame stayed black for ever. Gated on
  `primed_` again, the term dropped as unnecessary in the first round.
- Motion channels are still not priced, and the reason is now written where the
  dimmer is priced: on every fixture that really has pan and tilt those bytes are
  DMX control values drawing nothing from this rail.

**Tests**
- "Every light is written" asserted only that one was, so it passed while every
  non-corner position could have been skipped. It fills the buffer with a value the
  effect cannot produce and requires that none survives, which is 143 assertions
  the old form was not making.
- The rounded estimate, and the per-emitter milliamp figures.
USB capture no longer chases a device that goes away. One open is one device at
one negotiated format, buffers live from init to deinit, and a replug is picked
up by re-opening rather than by patching the stream in place. The frame-gap
timeout also loses its "hold forever" setting, which never matched how a UVC
device behaves.

Performance: not collected, no board attached (collect_kpi.py --commit needs one).

Core
- videoCaptureFrame's buffers are allocated in videoCaptureInit and freed in
  videoCaptureDeinit, never in between, so no task can reallocate while another
  reads. videoCaptureBufferGeneration() existed only to observe that race and is
  gone from the seam.
- The UVC driver installs once, like the host library. Installing per open
  re-enumerated the attached device and bumped the format generation, which is
  the very signal the caller re-inits on.
- Replug recovery moves out of the platform: a return re-enumerates, bumps the
  format generation, and VideoService re-opens on its own thread. Removing
  reopen() takes the stream handle out of the decode task, which is what forced
  the teardown ordering and the reallocation in the first place.
- videoCaptureDeinit stops the stream, then joins the decode task, then frees.
  The join is unbounded on purpose: the 40 ms decode timeout bounds it.
- VideoService closes the device through one door. The published frame borrows a
  platform buffer, so closeCapture() drops it before deinit; release() freed
  first and cleared after.
- staleMs no longer treats 0 as "hold the last frame forever". A UVC device
  streams continuously whatever is on the wire, so a gap means the grabber
  stopped, not that the content paused. Floored above a frame interval: the
  render loop outruns the capture, so ordinary gaps would read as loss.
- The negotiated-size status compares against what was last shown, not against
  the frame, which a stale drop resets to zero and made it re-fire on recovery.
- PPM header limit raised to 256 bytes, with its own error.
- hasUsbVideo requires CONFIG_IDF_TARGET_ESP32P4, so the capability flag agrees
  with the implementation gate.

Light domain
- presetHasRole() drives Yellow/UV control visibility from the selected preset
  rather than a correction_ that prepare() has not rebuilt yet.

Tests
- Source selection uses the named kSource* constants instead of bare indices.

Docs/CI
- services.md and effects.md follow the code; American spelling across our lines.
- The graphify ignore rule drops its root anchor: output is written beside
  whatever path was scanned, so a nested one is output too, not a source dir.

Reviews
- 👾 Buffer lease ends at the next call: partly wrong, inUse holds the slot
  indefinitely. Fixed the real half by removing the mid-flight reallocation.
- 👾 Seqlock payload must be atomic to be race-free in the C++ model: done.
- 👾 Yellow/UV current not gated on white mode: kept ours, apply() already zeroes
  both when the mode is None.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A UVC capture now survives unrelated UI changes: adding an effect no longer
tears the device down and renegotiates. The stream-open timeout was ten times
longer than intended.

Performance: not collected, no board attached (collect_kpi.py --commit needs one).

Core
- prepare() reopens only when the source, the selected format, or the device
  generation changed. Every tree-wide rebuild ran through it before, dropping
  the published frame and blocking on negotiation.
- uvc_host_stream_open takes FreeRTOS ticks, not milliseconds: a raw 3000 was
  30 s at the default 100 Hz tick, not 3 s.
- The open targets the device the format list came from, and its first
  streaming function only. "Any device" could open one the list did not
  describe.

Light domain
- The sparse lit-list checks every z plane. A D2 effect's front slice is
  extruded across the depth, so a column whose LEDs sit only at z > 0 was
  skipped and got black.

Tests
- The fade-in test drives the test clock. It ran on the real one against a
  4000 ms ramp, and both its bounds held on a fade stuck at black.
- The wiring test compares every position, not the set of them. A set has no
  order, so six of the eight walks could run backwards and still pass.
- scenario_Video_mutation: the producer/consumer pair through the Scheduler,
  including removing the service while the consumer renders.
- The scenario runner learns Services, RectangleLayout, VideoService and
  AmbilightEffect, plus RectangleLayout's width/height props (ignored before,
  so a fixture silently measured the default). Registering Services also
  un-skips scenario_Audio_mutation's remove step, which had never run.

Reviews
- 👾 prepare() tears down a working stream: fixed.
- 👾 sparse list only checks z = 0: fixed.
- 👾 open discards the enumerated device address: fixed.

Checks: spec drift, prose, platform boundary, hot-path, desktop build, unit
tests (1698), scenarios and firmware freshness (P4 rev3 + classic ESP32) all
pass. Three MoonLive scenarios fail identically on a clean tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dark scene is no longer cropped to its middle. The current cap no longer
charges for a dimmer channel that draws nothing.

Performance: not collected, no board attached.

Light domain
- barFrom reaching its 40% ceiling reports no bar. It returned the ceiling, so
  an all-dark frame took bars on all four edges and kStableFrames held that
  crop past the scene.
- measure() prices neither the dimmer nor motion: on the fixtures declaring
  them those bytes are DMX control values drawing nothing from this rail. The
  fixed-cost path also let an over-budget frame through with limit at 0.

Tests
- A dark-but-uneven frame pins the bar ceiling; a dimmer costs the estimate
  nothing.

Docs/CI
- drivers.md follows measure(); American spelling in our .clangd and comments.

Reviews
- 🐇 dark scene adopts 40% bars: done.
- 🐇 fixed dimmer budget exceeds the cap: done, by not pricing it.
- 🐇 RMT measure has no test: deferred. tick() opens with
  `if constexpr (rmtTxChannels == 0) return`, so it cannot run on the host; a
  test needs an RMT peripheral mock. measure and apply take the same winStart_
  and n, so a mistake lights the wrong LEDs rather than only mispricing.
- 🐇 hardware capture unverified: deferred to bench, no board yet.
- 🐇 British spelling in three files: done.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Video bytes are gamma-encoded, so their arithmetic mean is not the mean of
the picture: half black and half white averaged to 127 where the light is
188, and a lit scene rendered as a dim mush. VideoFrame::tone now carries
the source's curve to LINEAR light (12-bit, since linear has no headroom at
the dark end); meanOf averages there and encodes once per light. SDR stops
being the no-curve special case: sRGB is a curve like any other, and HDR had
the same defect, re-encoding per pixel before averaging.

whiteLevel: the W phosphor is separate hardware the RGB trims cannot reach,
and it is often brighter than the trio, so whites blow out while colours look
right. A fifth briLut row, pre-scaled like the balance trims so the curve
still lands last and the hot path is unchanged. Priced by the limiter, or a
trimmed white would be charged for current the strip never draws.

Radio telemetry off the render tick. wifiStaRssi/TxPower were synchronous
co-processor RPCs, 55-90 ms each on a P4, called once a second from inside
the tick: measured 10% of wall clock lost to a periodic freeze. A poller task
refreshes, the connect event carries BSSID and channel, the getters read a
cache.

Decode drops were silent. Four paths returned with no counter and no log, so
a stuttering picture had nothing to look at. Counted per kind, warned once,
and shown in the Video card as drop bad/noSlot/busy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every comment this branch touched now follows the one-line rule check_docgen enforces, with the depth moved into `@moreinfo` appendices. No behavior change: the diff is comments, docs and generated assets only.

check_docgen: 510 errors to 0, warnings 3211 to 3090, which is the committed baseline, with no rule risen.

**Core**
- VideoService: a one-line class lead, the three sources and pixel ownership in an appendix, `///` on every public control and constant
- VideoFrame: `///` on every member, the tone-curve rationale moved to the appendix

**Light domain**
- AmbilightEffect: file lead folded into the class comment, the source/destination table redrawn as markdown
- RectangleLayout: the perimeter and wiring rules moved to an appendix, `///` on the six controls
- Correction: `///` on briLut, the white-mode options and the per-channel current figures
- ParallelLedDriver, RmtLedDriver: `///` on limitsCurrent

**Platform**
- platform.h: the UVC block documented member by member, each VideoCaptureStats counter named
- platform_esp32_usbvideo: the file lead carries the three-thread and buffer-ownership appendices

**Tests**
- unit_VideoService, unit_AmbilightEffect, unit_RectangleLayout: leads folded into their `/// @module` block

**Docs/CI**
- screenshot_modules: RectangleLayout, AmbilightEffect and VideoService entries, plus the five card assets they generate
- repo-health refreshed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Upstream moved the module registry out of main.cpp and the scenario runner into the shared src/module_types.cpp, so the ambilight types move with it.

**Conflicts**
- src/main.cpp, test/scenario_runner.cpp: took upstream's structure, which no longer registers types in either file
- src/module_types.cpp: RectangleLayout, AmbilightEffect and VideoService registered there instead, each in its alphabetical slot with its spec page
- test/scenario_runner.cpp: kept the RectangleLayout construct-time width/height apply, rewritten to upstream's @Xref comment style beside the GridLayout pair it mirrors
- docs/moonmodules/light/effects.md: took upstream's projectMM to MoonLight rename
- docs/reference/metrics/repo-health: took upstream's snapshot, ours being the older reading

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The VideoService card's image was one directory level short, so it 404'd on the site. Two origin lines still named the project by its old name.

KPI: 5184lights | Desktop:1985KB | tick:28/6us(FPS:35714/166666) | tick:6832us(FPS:146) | heap:32989KB | src:282(72569) | test:212(47359) | lizard:283w

**Docs/CI**
- services.md: the card image resolves from the page, which is what test_catalog_card_images_resolve_on_disk caught
- layouts.md, effects.md: `Origin: projectMM` becomes `Origin: MoonLight`, matching upstream's rename
- repo-health: measured on an ESP32-P4 rev3 running this branch, so the ESP32 tick is a live reading rather than a carried one

**Tests**
- the two scenarios the runner re-measured, scenario_Video_mutation gaining its desktop-macos observations

Deltas: ESP32 tick 6,832 us (-1,522), FPS 146 (+27), heap 32,989 KB. esp32p4rev3-eth-wifi 2,440 KB, 60% of its partition. Unit cases 2,171 (+71), scenarios 12 (+1).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository: MoonModules/projectMM/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 1a6ee7bb-436f-40d4-acfd-7ca319e66002

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants