Skip to content

Drive LED panel cards over raw Ethernet; free 13.7 KB of static RAM - #59

Merged
ewowi merged 5 commits into
mainfrom
next-iteration
Jul 30, 2026
Merged

Drive LED panel cards over raw Ethernet; free 13.7 KB of static RAM#59
ewowi merged 5 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What this lands

A board can drive LED panel cards directly, over raw Ethernet. PanelCardDriver streams the render buffer to ColorLight 5A-75 receiver cards as L2 frames — no IP, no port, no DHCP lease — so effects, layers and MoonLive running on the device replace the Linux host that normally feeds those panels. Verified on hardware: an ESP32-S31 lighting two 128×64 HUB75 panels over a gigabit link.

In vendor terms (ColorLight, NovaStar, Linsn) these are receiving cards, and the driver takes the place of the sending card. The category is wider than one vendor, so format is a selector and the driver is named for the hardware rather than for the first format it speaks.

Three things made this more than a packet builder:

The link, not the address, is what matters. platform::ethSendRaw is gated on ethLinkUp() rather than ethConnected(). A driver claims the interface at prepare time (ethClaimRawL2), and NetworkModule then reports a leaseless link as Ethernet: link up, no IP (L2 in use) instead of a fault — so the DHCP cascade still moves to WiFi for the UI while the wire carries panels. Stated by the driver rather than inferred from traffic, so it cannot race the cascade's own 15 s timeout.

Geometry has one home. The driver has no width/height controls: a PanelsLayout already states how many panels there are, their size, wiring order and snaking. The driver reads the finished picture and cuts it into card rows.

The host is a target too. ethBindRawInterface opens AF_PACKET on Linux and BPF on macOS, so a Raspberry Pi or mini-PC running projectMM drives the same cards. Unprivileged runs record frames instead of sending them, which is what lets 23 unit tests pin the wire format with no hardware and no root.

Gated on MM_PANEL_CARDS (S31 + P4), so boards that cannot use it do not carry the ~2.6 KB.

Also

  • 13.7 KB of static RAM freed. The FFT scratch (8 KB), OTA chunk buffer (4 KB) and task snapshot (1440 B) now allocate on first use. Held-from-boot cost tree-wide: 14548 B → 828 B. All three are opt-in diagnostics that most boards never enable.
  • check_footprint — per-file flash and static RAM from the linked ELF. Its STATIC column is what surfaced all three of the buffers above.
  • A COND column on the hot-path report, via clang-query: whether each blocking call is guarded, and by what (if / loop / switch / ?: / && / early return), ordered unconditional-first.
  • parseIntStr moved out of line: +1712 → +136 bytes.
  • LED peripheral labels name the siliconI2S-IDF, LCD-IDF, LCD-MM — since "i80" described neither what the user sees nor what the chip does. MIGRATING.md has the entry.
  • Everything in the repo runs on the desktop build: the host links all four LED drivers against emulated peripherals, recorded as a hard rule in architecture.md with its limit stated — timing, wire protocol and pin state are not emulated.
  • A comments rule for clang-query: doc coverage per declaration, split by scope × kind × visibility, sized in words.

Reviewed

The Reviewer raised 9 findings on the branch; CodeRabbit 10. Two were real defects, both in code added during review:

  • A sync frame could latch a wall where every send failed — the flag tracked the loop reaching a row, not a frame reaching the wire, so a link dropping mid-frame would blank a good image. Introduced while renaming a variable to fix an earlier finding. Pinned by two regression tests.
  • The desktop raw-send path never counted failures, so the wedge diagnostic was dead on the one platform that sends for real.

Two were declined with reasons: the "broadcasting idiom" wording is the NumPy/CSS shorthand sense that ledsPerPin already establishes, and moving the lazy allocations back to setup would undo the 14 KB this branch just recovered — one allocation at module-add time, never at steady state, is the trade and it is documented at each site.

The rest were drift the branch created: a catalog heading still reading "Panel Send", controls documented after being deleted, a --strings flag that never existed, and the module card running four of the six tools its own docs described.

Verification

  • 7/7 mechanical gates green: specs, desktop build (zero warnings), unit tests, scenarios, platform boundary, hot-path discipline, firmware freshness.
  • 23 unit tests for the wire format, chunking at 497 pixels, ordering, the rate limiter, a failing link, the raw-L2 claim, and geometry derived from a real PanelsLayout.
  • Four ESP32 variants build: S31, S3, P4, classic (including the MM_NO_ETH stub path).
  • Bench: panels lit and judged by the PO. check_footprint --module PanelCardDriver --firmware esp32s31 reports 3270 B of flash and 0 B static RAM.

Known, and deliberately open

The S31 occasionally stops sending until a restart. The cause is not established, so this branch ships the diagnostic that tells ordinary back-pressure apart from a wedged transmit path, rather than a fix for an inferred cause. A recovery path was written and then removed — an unreachable stop/start of a live interface is worse than not having one.

~19 FPS at 128×128 is the ceiling for this path: 130 packets per frame sent synchronously from tick(). The render dominates anyway (17.6 ms for GameOfLife against 2.6 ms for the driver); numbers are in performance.md.

🤖 Generated with Claude Code

The host now links and runs all four LED drivers against emulated peripherals
instead of declaring itself incapable, which makes ~2500 lines of driver body
testable and visible to the analysis stack for the first time. clang-query gains
a comments rule reporting doc coverage per declaration, and HueDriver is the
pilot: 100% documented on every scope.

KPI: 16384lights | Desktop:920KB | ESP32:1678KB | tick:7248us(FPS:137) | heap:8273KB | lizard:136w

**Everything in the repo runs on the desktop build.**
Code excluded from the host binary cannot be unit-tested, cannot be seen by any
AST-based check, and only ever runs where it is hardest to debug — which is what
the LED drivers were. The platform layer simply has no silicon behind the call,
so where a peripheral is absent the host EMULATES it: lcdLanes/parlioLanes report
16, rmtTxChannels 4, hasLcdCam true, and the parallel buses are backed by heap
memory rather than returning false. Recorded as a hard rule in architecture.md,
with its limit — timing, wire protocol and pin state are NOT emulated, because
faking them would let a self-test report on hardware it never touched.

The three real backends already routed everything through platform.h and already
had desktop stubs; only the constants said "not my chip". So this links the REAL
drivers rather than a host-only stand-in — an earlier attempt built a parallel
DesktopPeripheral and was discarded as duplicating what it was meant to test.

**Core**
- platform_config.h (desktop): non-zero lane counts + hasLcdCam. The capability is
  separate from the lane COUNT: conflating them made the host claim S3-only
  pin-expander support, so hasLcdCam is its own flag and the drivers key off it.
- platform_desktop.cpp: one HostBus behind the i80/MoonI80/Parlio seams, and the
  RMT seam accepts symbols instead of refusing. The MoonI80 ring stays inert — a
  GDMA construct with no host equivalent, so the driver runs whole-frame here.
- main.cpp links every driver via MM_LINKS_ALL_LED_DRIVERS, a capability macro in
  platform_config.h. Not an OS #ifdef: the boundary rule forbids those in main.cpp,
  and an #include cannot be gated by `if constexpr`.

**Light domain**
- HueDriver: the documentation pilot. ~60 declarations documented or promoted from
  `//`, four over-long comments compacted. The report found a real defect — a
  four-line /// block sat on hasCorrectionControls while describing
  defineDriverControls, so the published page showed one method's text under
  another's name. Public surface 40% -> 100%.

**Scripts/MoonDeck**
- clang-query gains a `comments` rule: doc coverage per declaration, split by
  scope x kind x visibility, sized in WORDS (a line is a formatting accident; a
  comment line carries a median of 13 words here). DOC DEVIATION is the signed %
  against 130/40/40-word ideals; DEV WORDS has no ideal because zero IS the ideal.
  `//` reaches the AST via a shadow copy of src/ under build/, where a leading
  `//` becomes `/// MMDEV:` — the marker survives into the comment text, so both
  kinds stay separable and the real tree is never touched.
- The heap rule now names platform::alloc/allocInternal/allocExec/freeExec. Only
  `free` was listed, so HueDriver read as "8 frees, 0 allocations" — an implied
  leak that was not there. 80 -> 114 sites tree-wide.
- check_module no longer forces --max-rows=0; one module's report is capped at 60
  like every other table.

**Tests**
- The host-bus contract is one shared helper (test/unit/light/host_bus.h) rather
  than byte-identical cases per peripheral — three peripherals, one job.

**Reviews**
- Reviewer (Fable, 17 files): 8 findings, all fixed. The blocker invalidated
  numbers already published: `--extra-arg` APPENDS, so the compile database's own
  -I won and every transitive header resolved to the real, unmarked file. `//`
  counts were roughly half — 479 -> 950 declarations after switching to
  --extra-arg-before. A silent-zero guard now refuses to report when nothing in
  the tree carries a dev comment, which is what should have caught it.
  Also fixed: cross-TU merge overwrote where its docstring claimed max; two stub
  banners still said "no-op, driver idles" above code that allocates; a comment
  claimed colorCount_ can be -1 (it cannot); "4 is the classic ESP32's TX channel
  count" (it is 8; 4 is the S3's); lost bench knowledge about the bridge's 400 ms
  fade default, restored.

Gates: 9 passed, 0 failed, 3 skipped. Desktop 0 warnings, three ESP32 firmwares
0 warnings and byte-identical, 876 unit tests, 19 scenarios.

Not included: scenario JSON timing envelopes the runner rewrites per machine —
measurement noise that would misrepresent performance in the diff.

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

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 56.61% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main changes: raw-Ethernet panel card support and reduced static RAM usage.

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.

Comment thread test/unit/light/unit_MultiPinLedDriver.cpp Fixed
Comment thread test/unit/light/unit_MultiPinLedDriver.cpp Fixed
Comment thread test/unit/light/unit_ParlioLedDriver.cpp Fixed
Comment thread test/unit/light/unit_ParlioLedDriver.cpp Fixed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@moondeck/check/check_clang_query.py`:
- Line 630: Rename the comprehension loop variable l to ln in both occurrences
of the head assignment, including the corresponding expression references, so
the code remains behaviorally identical and passes Ruff E741.
- Around line 786-788: Update the four report-header list literals in the
surrounding output-building code, including the sites near lines 786, 798, 808,
and 814, to wrap adjacent f-string expressions in explicit parentheses before
placing each resulting string in the list. Preserve the existing formatting and
row structure while eliminating implicit string concatenation.
- Around line 29-35: Update the module docstring in check_clang_query.py to
remove or correct the claim that plain // comments never reach the AST,
reflecting that the comments rule reports them through the shadow tree and DEV
column. Keep the surrounding documentation of /// scope reporting and thresholds
unchanged.

In `@moondeck/check/check_module.py`:
- Around line 42-48: Update the relevant paragraph in MoonDeck.md to document
clang-query’s default 60-row cap for every table, including arrays, and explain
that truncated output is announced; remove the outdated no-cap claim and the
71-array rationale while keeping the per-module card behavior accurate.

In `@moondeck/MoonDeck.md`:
- Line 323: Tag both newly added fenced sample-output blocks in MoonDeck.md with
the text language identifier, including the blocks containing “DECL DOC DEV NONE
%DOC” and “DOC DEVIATION DOC WORDS DEV WORDS DECL NAME FILE:LINE”.
- Around line 340-344: Update the sample detail table in MoonDeck.md to include
the VIS column between DEV WORDS and DECL, matching the documented
render_comments output and the VIS description near line 356.
- Around line 392-394: Update the documentation text in MoonDeck.md to refer to
the per-member scope as “attribute” instead of “field,” matching the matrix and
the _SCOPE/render_comments labels. Do not change the surrounding explanation
about function parameters.

In `@src/light/drivers/HueDriver.h`:
- Around line 230-246: Correct the comments above lightNames_ and roomNames_ to
state that ensureNameBuffers() allocates fixed-capacity blocks sized to
kMaxLights × kNameLen and kMaxRooms × kNameLen, rather than the actual bridge
counts. Remove the inaccurate claims about allocation to the actual count and
memory scaling to the live bridge size, while preserving the explanation that
the buffers are heap-allocated to keep sizeof(HueDriver) small.

In `@src/platform/desktop/platform_desktop.cpp`:
- Around line 1362-1370: Guard moonI80Ws2812Init and parlioWs2812Init against
zero bufferBytes before calling hostBus(h.impl), returning false immediately for
zero-size initialization. Preserve the existing hostBus initialization path for
nonzero buffer sizes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1723b3c0-52c9-4dbe-82bf-f17c240fa833

📥 Commits

Reviewing files that changed from the base of the PR and between 1af1be6 and 8d491cb.

📒 Files selected for processing (20)
  • CLAUDE.md
  • docs/architecture.md
  • docs/history/plans/Plan-20260728 - Doc-comment size reporting via clang-query.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • moondeck/MoonDeck.md
  • moondeck/check/check_clang_query.py
  • moondeck/check/check_module.py
  • moondeck/moondeck_config.json
  • src/light/drivers/HueDriver.h
  • src/light/drivers/MoonLedDriver.h
  • src/light/drivers/MultiPinLedDriver.h
  • src/main.cpp
  • src/platform/desktop/platform_config.h
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_config.h
  • test/unit/light/host_bus.h
  • test/unit/light/unit_MoonLedDriver.cpp
  • test/unit/light/unit_MultiPinLedDriver.cpp
  • test/unit/light/unit_ParlioLedDriver.cpp

Comment thread moondeck/check/check_clang_query.py Outdated
Comment thread moondeck/check/check_clang_query.py Outdated
Comment on lines +786 to +788
L += ["", f" {'DECL':<10} {'DOC':>7} {'DEV':>7} {'NONE':>7} {'%DOC':>5}"
f" (ideal doc words: {ideals}; ideal dev words: 0)",
f" {'-' * 10} {'-' * 7} {'-' * 7} {'-' * 7} {'-' * 5}"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Parenthesize the implicit string concatenations inside these list literals (ISC004).

Each of these L += [...] literals relies on adjacent f-strings concatenating across lines; a stray comma silently turns one row into two. Wrapping in parentheses makes the intent explicit and clears the linter.

♻️ Proposed fix (pattern, apply to all four sites)
-    L += ["", f"  {'DECL':<10}  {'DOC':>7}  {'DEV':>7}  {'NONE':>7}  {'%DOC':>5}"
-              f"    (ideal doc words: {ideals}; ideal dev words: 0)",
+    L += ["", (f"  {'DECL':<10}  {'DOC':>7}  {'DEV':>7}  {'NONE':>7}  {'%DOC':>5}"
+               f"    (ideal doc words: {ideals}; ideal dev words: 0)"),
           f"  {'-' * 10}  {'-' * 7}  {'-' * 7}  {'-' * 7}  {'-' * 5}"]

Also applies to: 798-799, 808-809, 814-815

🧰 Tools
🪛 Ruff (0.16.0)

[warning] 786-787: Unparenthesized implicit string concatenation in collection

Did you forget a comma?

(ISC004)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_clang_query.py` around lines 786 - 788, Update the four
report-header list literals in the surrounding output-building code, including
the sites near lines 786, 798, 808, and 814, to wrap adjacent f-string
expressions in explicit parentheses before placing each resulting string in the
list. Preserve the existing formatting and row structure while eliminating
implicit string concatenation.

Source: Linters/SAST tools

Comment thread moondeck/check/check_module.py Outdated
Comment thread moondeck/MoonDeck.md
Comment thread moondeck/MoonDeck.md Outdated
Comment thread moondeck/MoonDeck.md
Comment thread src/light/drivers/HueDriver.h
Comment thread src/platform/desktop/platform_desktop.cpp
The hot-path report now says whether each blocking call actually runs every
tick, or sits behind a branch — unconditional calls sort first, so the ones
that cost every frame are at the top. A new CodeQL card brings the Security
tab's findings into MoonDeck, and every `atoi` in shared code is replaced by
a conversion that cannot silently truncate an out-of-range value.

Performance: desktop tick 126 µs / 7936 fps; esp32 tick 4164 µs. Flash:
esp32 1678368, esp32s3-n16r8 1668336 (+1744), esp32p4-eth 1498160 (+704).

Core
- json::parseIntStr(): one strtol-based string→int conversion, replacing bare
  `atoi` at 5 sites (HueDriver ×3, HttpServerModule palette, MqttModule
  brightness). atoi cannot report failure and is undefined behaviour on
  overflow, so a value too large silently narrowed into a DIFFERENT valid
  number — a Hue id of "65537" becoming light 1.
- Overflow needs BOTH an ERANGE check and an INT_MAX compare: `long` is
  64-bit on the desktop but 32-bit on ESP32/Windows, where LONG_MAX ==
  INT_MAX makes the compare dead code. Verified against the Xtensa toolchain.
- The two `atoi` sites in src/platform/ are deliberately left: the platform
  layer must not include core/, and both are already length-validated.

Light domain
- NetworkSendDriver: every attribute and method documented; the class comment
  split with @moreinfo so the unicast rationale renders below the member
  lists rather than ahead of them. Documentation only — verified by a
  comment-stripped hash comparison.

Scripts/MoonDeck
- clang-hotpath gains a COND column (—/if/loop/switch/?:/&&/ret, plus a ·rate
  hint for throttles and once-only latches), derived from clang-query and
  joined on file:line. Matchers target only the guarded region: a call in an
  `if`'s CONDITION always runs, and labelling it "guarded" ranked it below
  the unconditional rows it belongs beside.
- A refused matcher now reports `?` rather than `—`: zero matches and "no
  site is guarded" are indistinguishable, and the comfortable reading was the
  wrong one.
- New CodeQL card fetches the Security tab's alerts via `gh` — no local
  install, and it keeps GitHub's open/fixed/dismissed lifecycle. Split into
  src/ and test/, because 783 of 855 alerts sat in test files and buried the
  three that ship.
- Card footers now carry elapsed time and a per-script run count, so two
  identical reports can be told apart.
- Doc-comment sizing stops at @moreinfo: measuring the relocated tail
  punished the very structure the directive exists to encourage
  (NetworkSendDriver read +407%, reads +46%).
- update_module_docs: assets resolved through the directory listing, so a
  link carries the name as spelled on disk. macOS matched `layouts.png`
  against `Layouts.png` and emitted a link that 404s on the Linux runner.

Tests
- unit_JsonUtil_parseint.cpp: not-a-number vs a real zero, and out-of-range
  reading as the fallback instead of wrapping.
- unit_SingleColumnLayout.cpp: index order, spatial offset, reversed wiring,
  and agreement with the equivalent 1×N grid. The layout had no coverage.

Docs/CI
- CodeQL narrowed to security-extended. The quality pack duplicated
  clang-tidy and lizard, and misjudged this codebase: it flagged the layouts'
  overflow clamps as always-false, correct for the desktop build it analyzes
  and wrong for the ESP32 where nrOfLightsType is uint16_t.
- codeql-config.yml records that paths-ignore does NOT suppress findings
  inside doctest.h — 143 test files include it, so it is extracted through
  them. The comment previously claimed the opposite.
- MoonDeck.md: each tool's section now says what the underlying tool is and
  what it does for us, with a table of what each layer can and cannot see.
- mkdocs builds clean: repo-root links rebased, two dotfile links and two
  directory links fixed, one stale anchor corrected.

Reviews
- 👾 parseIntStr range check dead on 32-bit long → fixed with ERANGE.
- 👾 COND matchers claimed calls in always-evaluated positions → constrained
  to the guarded region.
- 👾 continue/break counted as function exits → returnStmt only.
- 👾 broken clang-query rendered as "unconditional" → reports `?`.
- 👾 ·rate window shifted one line → scans the call's own block.
- 👾 missing `gh` raised a traceback → OSError handled.
- 👾 British spellings and a "four tools" count drift → fixed.
- Found while verifying: switchStmt(hasBody) is invalid (now switchCase);
  the rejection detector matched "error:" inside a dumped string literal;
  _LATCH matched `open` inside `online`.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 21

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
moondeck/check/check_clang_query.py (2)

414-426: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Use a complete source fingerprint for the shadow-tree cache.

.built-from stores only the newest source mtime. Deleting a non-newest file, adding a file without changing that maximum, or restoring an edit’s timestamp can leave stale files in build/comment-shadow/src, causing incorrect comment reports.

Use a manifest/content digest including relative paths, or rebuild per invocation, and add add/edit/delete regression coverage. As per coding guidelines, every behavior change and discovered failure must have meaningful tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_clang_query.py` around lines 414 - 426, Replace the
newest-mtime-only cache key in the shadow-tree construction flow with a complete
source fingerprint covering relative paths and file contents, so additions,
edits, deletions, and restored timestamps invalidate the cache. Update the
`.built-from` comparison and writing to use this digest, and add regression
tests covering source add, edit, and delete cases.

Source: Coding guidelines


979-989: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail closed when comment collection is empty.

if rows and ... skips the safeguard when rows == [], so a shadow/include/matcher failure can print 0 found. and exit successfully—the false-zero case this guard is intended to prevent.

-            if rows and not any(r["dev_words"] for r in rows):
+            if not rows:
+                print("[comments] no declarations matched; refusing to report a false zero.",
+                      file=sys.stderr)
+                return 2
+            if not any(r["dev_words"] for r in rows):

Add a regression test for an empty matcher result. As per coding guidelines, every behavior change and discovered failure must have meaningful tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_clang_query.py` around lines 979 - 989, Update the
comments handling branch around collect_comments and the rows guard so an empty
rows result fails closed with the same diagnostic and nonzero return as rows
containing no dev_words; retain successful reporting only when at least one
collected row has dev_words. Add a regression test covering an empty matcher
result and asserting the failure status and diagnostic.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/codeql.yml:
- Around line 37-44: Add the test source path to the CodeQL workflow’s paths
trigger alongside the existing src and configuration entries, so changes under
test/** initiate analysis and refresh test alerts. Preserve the current
exclusions and trigger behavior for the existing paths.

In `@docs/history/README.md`:
- Line 36: Update the documentation sentence describing the current plan
lifecycle near the plans/ reference: replace the automatic “file is deleted once
the plan ships” wording with language stating that the product owner may delete
the file once the plan is realized, while preserving the surrounding
explanation.

In `@moondeck/check/check_codeql.py`:
- Around line 48-247: Add behavior-level tests for the CLI flows centered on
_repo, fetch, collect, render, and main, mocking subprocess.run and module-file
lookup as needed. Cover repository/API failures, pagination across JSON arrays,
--all state queries, module filtering, severity/file sorting, malformed output
with exit status 2, and successful output; assert user-facing messages and
return codes, and ensure the suite runs through uv run.
- Around line 95-108: Update the CodeQL response parsing flow around
json.JSONDecoder and raw_decode to fail closed when stdout is blank, truncated,
contains malformed JSON, or yields no array response: return the existing “could
not look” error result with a nonzero exit status instead of returning partial
or empty alerts as success. Preserve successful parsing of valid array responses
and ensure trailing whitespace remains accepted.

In `@moondeck/check/check_nonblocking.py`:
- Around line 216-229: Extract the duplicated file-reading and memoization logic
into a shared _src_lines(rel_path) helper that returns cached lines or [] for
missing files. Update _rate_on_line, rate_hint, and _enclosing_def to obtain
source lines exclusively through _src_lines, removing their independent
cache/read blocks while preserving existing line-processing behavior.
- Around line 195-213: Add focused unit tests for the _RATE and _LATCH regex
heuristics and _bound_sites parsing. Cover documented negative cases such as > 0
not matching, open not matching online, and col:-only dump lines being skipped,
along with representative valid matches and parsing behavior. Keep the tests
targeted to these pure text-processing symbols and make discovered crash cases
regression tests.
- Around line 254-270: Update guard_forms to return None immediately when rows
is empty, before calling check_clang_query.including_tus. Preserve the existing
tool check and TU-resolution behavior for non-empty row sets.
- Around line 154-171: Parenthesize the multi-line matcher string expressions in
_GUARD_MATCHERS, especially the "if", "?:" and "&&" entries, so adjacent string
literals are explicitly grouped and Ruff ISC004 is cleared. Preserve the
existing matcher contents and tuple form/matcher pairs.

In `@moondeck/MoonDeck.md`:
- Around line 489-493: Update the COND entry in the documentation table to
define `?` as guard analysis unavailable or failed, covering clang-query
unavailability, rejected matchers, and execution errors returned by
guard_forms().

In `@moondeck/moondeck.py`:
- Around line 116-127: Add a return type annotation of str to the _duration
helper, preserving its existing formatting logic and return values.
- Around line 122-127: Update the duration-formatting logic around the seconds
conversion so values that round to 60 seconds, such as 59.96, render as 1m00s
rather than 60.0s. Base the initial boundary decision on the same rounded value
used for display, preserve existing formatting for other durations, and add a
regression test covering this boundary.
- Around line 137-149: The run-count read/modify/write sequence around RUNS_FILE
is not serialized, allowing concurrent handlers to lose increments. Add a
dedicated process lock covering loading counts, incrementing script_id, and
writing the file, and use an atomic temporary-file replacement for RUNS_FILE
when multiple MoonDeck processes may share LOG_DIR.
- Around line 1846-1849: Move the time.monotonic() assignment to immediately
after the Popen call succeeds, retain it as the process-start timestamp, and
pass or reuse that value in _handle_stream instead of recording a new timestamp
at SSE attachment. Preserve the existing duration calculation and monotonic
clock behavior.
- Around line 1888-1890: Update the successful launch flow around Popen and the
exit_msg construction: immediately after Popen succeeds, call
bump_run_count(script_id), persist that value on the process, and use the stored
run number in exit_msg. Remove the completion-path-only dependency so runs
remain numbered even when the SSE client disconnects or never connects.
- Around line 142-146: Update the persisted count validation in the script count
increment logic around counts.get and the n assignment to accept only values
whose exact type is int and that are non-negative. Treat booleans, negative
integers, and other invalid values as zero before incrementing, while preserving
the existing counts[script_id] update.
- Around line 116-150: Add a deterministic Python task/gate under moondeck/
covering _duration() boundary formatting and bump_run_count() behavior for
missing/corrupt files, malformed, boolean, and negative values, plus concurrent
increments; isolate RUNS_FILE with temporary test data and assert
user-understandable outcomes. Register the task in the project’s task
configuration and ensure it runs through uv run.

In `@src/light/drivers/HueDriver.h`:
- Around line 417-424: Update HueDriver::parseId to validate that the quoted ID
content consists exclusively of decimal digits, with no leading whitespace or
other characters, before converting it. Preserve the existing positive uint16_t
range check and return 0 for invalid, zero, or out-of-range values so all light,
group, and room-member scan sites reject malformed IDs.

In `@src/light/drivers/NetworkSendDriver.h`:
- Around line 26-27: Update the synchronous-send documentation near the driver’s
tick() behavior to state that it sends the configured start/count window, not
the whole frame; alternatively, explicitly qualify the timing benchmark as
applying to a full-window configuration. Preserve the existing
destination-configuration caveat.

In `@test/unit/core/unit_JsonUtil_parseint.cpp`:
- Around line 36-55: Extend the integer-parsing tests around json::parseIntStr
to assert that 2147483648 and -2147483649 use the fallback rather than being
accepted outside the int range. Add coverage in the parseInt key-value tests for
a null key, verifying the documented fallback behavior while preserving the
existing absent-key and non-numeric cases.

In `@test/unit/light/unit_SingleColumnLayout.cpp`:
- Around line 95-108: Expand the test case around SingleColumnLayout and
coordsOf to assert every entry in the reversed four-element sequence, including
each contiguous index and its corresponding descending coordinate. Set a
non-zero start_y value and validate the expected offset-plus-reversal results so
the interior ordering and reversal interaction are covered.
- Around line 37-53: Update the SingleColumnLayout test around column.height and
coordinate assertions to assign a non-zero xposition before collecting
coordinates, then assert every emitted coordinate uses that configured xposition
instead of the default zero. Preserve the existing contiguous index, y, z, and
light-count checks.

---

Outside diff comments:
In `@moondeck/check/check_clang_query.py`:
- Around line 414-426: Replace the newest-mtime-only cache key in the
shadow-tree construction flow with a complete source fingerprint covering
relative paths and file contents, so additions, edits, deletions, and restored
timestamps invalidate the cache. Update the `.built-from` comparison and writing
to use this digest, and add regression tests covering source add, edit, and
delete cases.
- Around line 979-989: Update the comments handling branch around
collect_comments and the rows guard so an empty rows result fails closed with
the same diagnostic and nonzero return as rows containing no dev_words; retain
successful reporting only when at least one collected row has dev_words. Add a
regression test covering an empty matcher result and asserting the failure
status and diagnostic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6987402a-824a-4641-aded-c2226bb7b03d

📥 Commits

Reviewing files that changed from the base of the PR and between 8d491cb and a1ef9ec.

📒 Files selected for processing (28)
  • .github/codeql-config.yml
  • .github/workflows/codeql.yml
  • CLAUDE.md
  • docs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md
  • docs/history/README.md
  • docs/history/lessons.md
  • docs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/layouts.md
  • moondeck/MoonDeck.md
  • moondeck/check/check_clang_query.py
  • moondeck/check/check_codeql.py
  • moondeck/check/check_nonblocking.py
  • moondeck/docs/mkdocs_hooks.py
  • moondeck/docs/update_module_docs.py
  • moondeck/moondeck.py
  • moondeck/moondeck_config.json
  • src/core/HttpServerModule.cpp
  • src/core/JsonUtil.h
  • src/core/MqttModule.cpp
  • src/light/drivers/HueDriver.h
  • src/light/drivers/NetworkSendDriver.h
  • src/platform/desktop/platform_desktop.cpp
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_peripheral_grid_sweep.json
  • test/unit/core/unit_JsonUtil_parseint.cpp
  • test/unit/light/unit_SingleColumnLayout.cpp

Comment on lines +37 to +44
# Only when C/C++ or the analysis setup itself changes — a docs commit has nothing new to
# analyze and would just burn a runner. Both setup files are listed: a change to the query
# set or the ignore list changes what a scan FINDS, so it has to trigger one. Listing only
# the workflow left an edit to codeql-config.yml silently unscanned until something under
# src/ happened to change.
- 'src/**'
- '.github/workflows/codeql.yml'
- '.github/codeql-config.yml'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Trigger CodeQL when test C/C++ changes.

The comment says C/C++ changes trigger analysis, but test/** is absent. Test alerts remain stale until an unrelated src/** or configuration change triggers a scan, despite the reporter explicitly surfacing them.

       - 'src/**'
+      - 'test/**'
       - '.github/workflows/codeql.yml'
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Only when C/C++ or the analysis setup itself changes — a docs commit has nothing new to
# analyze and would just burn a runner. Both setup files are listed: a change to the query
# set or the ignore list changes what a scan FINDS, so it has to trigger one. Listing only
# the workflow left an edit to codeql-config.yml silently unscanned until something under
# src/ happened to change.
- 'src/**'
- '.github/workflows/codeql.yml'
- '.github/codeql-config.yml'
# Only when C/C++ or the analysis setup itself changes — a docs commit has nothing new to
# analyze and would just burn a runner. Both setup files are listed: a change to the query
# set or the ignore list changes what a scan FINDS, so it has to trigger one. Listing only
# the workflow left an edit to codeql-config.yml silently unscanned until something under
# src/ happened to change.
- 'src/**'
- 'test/**'
- '.github/workflows/codeql.yml'
- '.github/codeql-config.yml'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/codeql.yml around lines 37 - 44, Add the test source path
to the CodeQL workflow’s paths trigger alongside the existing src and
configuration entries, so changes under test/** initiate analysis and refresh
test alerts. Preserve the current exclusions and trigger behavior for the
existing paths.

Comment thread docs/history/README.md Outdated
Comment on lines +48 to +247
def _repo():
"""`owner/name` for the current checkout, or None when it cannot be determined."""
# `check=False` does not cover the binary being ABSENT — that raises FileNotFoundError before
# any exit code exists, which is the commonest cold-start failure and would print a traceback
# instead of the install guidance the caller has ready.
try:
p = subprocess.run(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
cwd=ROOT, capture_output=True, text=True, check=False)
except OSError:
return None
return p.stdout.strip() or None


def fetch(state):
"""Alerts for the repo, or `(None, reason)` when the answer could not be obtained.

A failure is returned rather than raised so the caller can say WHY the list is empty. An
empty list and an unreachable API look identical in a table, and only one of them means
the code is clean.
"""
repo = _repo()
if not repo:
return None, ("`gh` could not identify the repository. Install the GitHub CLI and run "
"`gh auth login`, or check that this is a GitHub checkout.")
# `state` must be passed EXPLICITLY for every query. Omitting it does not mean "any state" —
# the endpoint defaults to `open`, so an "all states" run that simply dropped the parameter
# returned the open alerts and called them all of them. Verified against the API: with no
# `state`, every returned alert has `"state": "open"`.
states = ("open", "fixed", "dismissed") if state == "all" else (state,)
out = []
for st in states:
query = f"per_page=100&state={st}"
try:
p = subprocess.run(["gh", "api", "--paginate",
f"repos/{repo}/code-scanning/alerts?{query}"],
cwd=ROOT, capture_output=True, text=True, check=False)
except OSError as exc:
return None, (f"Could not run `gh` ({exc}). Install the GitHub CLI and run "
f"`gh auth login`.")
if p.returncode != 0:
err = (p.stderr or "").strip().splitlines()
detail = err[-1] if err else f"exit {p.returncode}"
# 404 is the common one and does not mean "clean": code scanning may be off, or the
# token may lack the security_events scope.
return None, (f"Could not read the alerts ({detail}). Code scanning may be disabled "
f"for {repo}, or `gh auth` may lack the `security_events` scope.")
# --paginate concatenates one JSON array per page; load them all.
dec = json.JSONDecoder()
text = p.stdout.strip()
i = 0
while i < len(text):
try:
val, end = dec.raw_decode(text, i)
except ValueError:
break
if isinstance(val, list):
out.extend(val)
i = end
while i < len(text) and text[i] in " \t\r\n":
i += 1
return out, None


def collect(alerts, only=None):
"""One row per alert: severity, rule, where, and the message that says what is wrong."""
rows = []
for a in alerts:
rule = a.get("rule") or {}
inst = a.get("most_recent_instance") or {}
loc = inst.get("location") or {}
path = loc.get("path") or "?"
if only is not None and path not in only:
continue
sev = rule.get("security_severity_level") or rule.get("severity") or "note"
rows.append({
"sev": sev.lower(),
"rule": rule.get("id") or "?",
"file": path,
"line": loc.get("start_line") or 0,
"msg": " ".join((inst.get("message") or {}).get("text", "").split()),
"state": a.get("state") or "?",
"ref": (inst.get("ref") or "").replace("refs/heads/", ""),
"url": a.get("html_url") or "",
})
# Severity, then file/line so a re-run diffs cleanly and one file's findings stay together.
return sorted(rows, key=lambda r: (_ORDER.get(r["sev"], 99), r["file"], r["line"]))


def _table(rows, title, show_state=False):
"""One severity-ordered table. Widths come from the data, capped, like the other reports.

`show_state` adds the STATE column, which only earns its width when the listing mixes states
(an `--all` run) — on a single-state run the header already said which one.
"""
# Never narrower than the header itself, or the dashes stop lining up under it (a `note`-only
# table gave a 4-wide rule under an 8-wide "SEVERITY").
sev_w = max(min(max(len(r["sev"]) for r in rows), 8), len("SEVERITY"))
rule_w = max(min(max(len(r["rule"]) for r in rows), 34), len("RULE"))
loc_w = max(min(max(len(f"{r['file']}:{r['line']}") for r in rows), 46), len("FILE:LINE"))
st_w = max(min(max(len(r["state"]) for r in rows), 9), len("STATE")) if show_state else 0

def clip(s, w):
return s if len(s) <= w else s[: w - 1] + "…"

st_head = f"{'STATE':<{st_w}} " if show_state else ""
st_rule = f"{'-' * st_w} " if show_state else ""
L = ["", f"{title} — {len(rows)}",
f" {st_head}{'SEVERITY':<{sev_w}} {'RULE':<{rule_w}} {'FILE:LINE':<{loc_w}} WHAT",
f" {st_rule}{'-' * sev_w} {'-' * rule_w} {'-' * loc_w} {'-' * 40}"]
for r in rows[:MAX_ROWS]:
loc = f"{r['file']}:{r['line']}"
st = f"{clip(r['state'], st_w):<{st_w}} " if show_state else ""
L.append(f" {st}{clip(r['sev'], sev_w):<{sev_w}} {clip(r['rule'], rule_w):<{rule_w}} "
f"{clip(loc, loc_w):<{loc_w}} {clip(r['msg'], 60)}")
if len(rows) > MAX_ROWS:
L.append(f" … {len(rows) - MAX_ROWS} more (severity order, so these are the least "
f"severe). Use --module <name> to scope.")
return L


def render(rows, state, refs, scoped=False):
"""The counts first, then shipping code, then tests.

Split because the two are read differently: a finding in `src/` ships to a device, one in
`test/` does not. Measured here, 783 of 855 alerts are in test files — pooling them buries
the three `high` findings in shipping code under a wall of doctest noise.

The severity breakdown is printed BEFORE any table, so the numbers survive truncation: a
reader must be able to see "3 high" even when the table showed only the first 60 rows.
"""
L = []
if not rows:
L.append(f"No {state} alerts. ✓")
if scoped:
L.append(" (Scoped to a module — run without one to see the whole repo.)")
L.append(" (CodeQL analyzes PUSHED commits — local edits are not included.)")
return L

branches = ", ".join(sorted(refs)) if refs else "?"
L.append(f"{len(rows)} {state} alert(s) — analyzed on {branches}, not on your working tree.")

src = [r for r in rows if r["file"].startswith("src/")]
other = [r for r in rows if not r["file"].startswith("src/")]

def tally(subset):
c = {}
for r in subset:
c[r["sev"]] = c.get(r["sev"], 0) + 1
return " · ".join(f"{k} {v}" for k, v in
sorted(c.items(), key=lambda kv: _ORDER.get(kv[0], 99))) or "none"

L.append(f" shipping code (src/): {tally(src)}")
L.append(f" tests + vendored: {tally(other)}")

# With more than one state in the list, say which — a `fixed` row rendered like an open one
# reads as a live finding. Absent on a single-state run, where the header already said it.
states = {}
for r in rows:
states[r["state"]] = states.get(r["state"], 0) + 1
if len(states) > 1:
L.append(" by state: "
+ " · ".join(f"{k} {v}" for k, v in sorted(states.items())))

mixed = len(states) > 1
if src:
L += _table(src, "src/ — ships to a device", mixed)
if other:
L += _table(other, "test/ + vendored — does not ship", mixed)
return L


def main():
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--state", default="open", choices=("open", "fixed", "dismissed"),
help="Alert state to list (default: open).")
ap.add_argument("--all", action="store_true", help="Every state, not just one.")
ap.add_argument("--module", help="Only alerts in this module's source files.")
args = ap.parse_args()

state = "all" if args.all else args.state
alerts, err = fetch(state)
if alerts is None:
# FAIL LOUD. A tool that could not look must not print a comfortable zero.
print(err, file=sys.stderr)
return 2

only = None
if args.module:
only = check_clang_query.module_files(args.module)
if not only:
print(f"No source files for module '{args.module}'.", file=sys.stderr)
return 2
print(f"Filtered to {args.module}: {', '.join(only)}\n")

rows = collect(alerts, only)
# The branch comes from the alerts themselves; when none matched a scope there is nothing to
# read it from, so fall back to every alert's ref rather than printing "?".
refs = {r["ref"] for r in (rows or collect(alerts)) if r["ref"]}
print("\n".join(render(rows, state, refs, scoped=only is not None)))
return 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add behavior-level tests for this new CLI.

No tests cover fetch failures, pagination, --all, module filtering, sorting, or exit status. Mock subprocess.run, including malformed output returning exit 2 rather than a clean report; describe each case in user-understandable terms and run it through uv run.

As per coding guidelines, “Every behavior must be covered by meaningful tests.”

🧰 Tools
🪛 ast-grep (0.45.0)

[error] 53-54: Command coming from incoming request
Context: subprocess.run(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
cwd=ROOT, capture_output=True, text=True, check=False)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)


[error] 80-82: Command coming from incoming request
Context: subprocess.run(["gh", "api", "--paginate",
f"repos/{repo}/code-scanning/alerts?{query}"],
cwd=ROOT, capture_output=True, text=True, check=False)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').

(subprocess-from-request)


[error] 245-245: Avoid HTML built in strings
Context: render(rows, state, refs, scoped=only is not None)
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting').

(html-string-from-parameters)

🪛 Ruff (0.16.0)

[warning] 48-48: Missing return type annotation for private function _repo

(ANN202)


[error] 54-54: Starting a process with a partial executable path

(S607)


[error] 81-81: subprocess call: check for execution of untrusted input

(S603)


[error] 81-82: Starting a process with a partial executable path

(S607)


[warning] 136-136: Missing return type annotation for private function _table

(ANN202)


[warning] 136-136: Boolean default positional argument in function definition

(FBT002)


[warning] 149-149: Missing return type annotation for private function clip

(ANN202)


[warning] 168-168: Boolean default positional argument in function definition

(FBT002)


[warning] 192-192: Missing return type annotation for private function tally

(ANN202)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_codeql.py` around lines 48 - 247, Add behavior-level
tests for the CLI flows centered on _repo, fetch, collect, render, and main,
mocking subprocess.run and module-file lookup as needed. Cover repository/API
failures, pagination across JSON arrays, --all state queries, module filtering,
severity/file sorting, malformed output with exit status 2, and successful
output; assert user-facing messages and return codes, and ensure the suite runs
through uv run.

Source: Coding guidelines

Comment thread moondeck/check/check_codeql.py
Comment on lines +154 to +171
_GUARD_MATCHERS = (
("loop", "forStmt(hasBody(forEachDescendant(callExpr().bind(\"c\"))))"),
("loop", "whileStmt(hasBody(forEachDescendant(callExpr().bind(\"c\"))))"),
("loop", "cxxForRangeStmt(hasBody(forEachDescendant(callExpr().bind(\"c\"))))"),
("if", "ifStmt(anyOf(hasThen(forEachDescendant(callExpr().bind(\"c\"))),"
" hasElse(forEachDescendant(callExpr().bind(\"c\")))))"),
# `switchCase`, not `switchStmt`: the latter has no `hasBody` (clang-query rejects it), and
# matching the whole statement would claim calls in the SUBJECT expression, which always runs.
# `switchCase` covers `case` and `default` alike — the bodies, which is the guarded region.
("switch", "switchCase(forEachDescendant(callExpr().bind(\"c\")))"),
# Only the branches, not the condition.
("?:", "conditionalOperator(anyOf("
" hasTrueExpression(forEachDescendant(callExpr().bind(\"c\"))),"
" hasFalseExpression(forEachDescendant(callExpr().bind(\"c\")))))"),
# `a && blocking()` — the RHS runs only if the left side allows it; the LHS always runs.
("&&", "binaryOperator(hasAnyOperatorName(\"&&\", \"||\"),"
" hasRHS(forEachDescendant(callExpr().bind(\"c\"))))"),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Parenthesize the multi-line matcher strings.

Ruff flags implicit concatenation inside the tuple (ISC004) at Lines 158-159, 165-167, 169-170 — the pattern is correct today, but a missing comma here would silently merge a form/matcher pair. Wrapping the concatenated string keeps intent explicit and clears the lint.

♻️ Suggested change
-    ("if", "ifStmt(anyOf(hasThen(forEachDescendant(callExpr().bind(\"c\"))),"
-           "            hasElse(forEachDescendant(callExpr().bind(\"c\")))))"),
+    ("if", ("ifStmt(anyOf(hasThen(forEachDescendant(callExpr().bind(\"c\"))),"
+            "            hasElse(forEachDescendant(callExpr().bind(\"c\")))))")),
🧰 Tools
🪛 Ruff (0.16.0)

[warning] 158-159: Unparenthesized implicit string concatenation in collection

Did you forget a comma?

(ISC004)


[warning] 165-167: Unparenthesized implicit string concatenation in collection

Did you forget a comma?

(ISC004)


[warning] 169-170: Unparenthesized implicit string concatenation in collection

Did you forget a comma?

(ISC004)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_nonblocking.py` around lines 154 - 171, Parenthesize the
multi-line matcher string expressions in _GUARD_MATCHERS, especially the "if",
"?:" and "&&" entries, so adjacent string literals are explicitly grouped and
Ruff ISC004 is cleared. Preserve the existing matcher contents and tuple
form/matcher pairs.

Source: Linters/SAST tools

Comment on lines +417 to +424
/// The bridge id a quoted JSON key opens with (`"7":{…}` → 7), or 0 when `s` does not start
/// with a positive integer that fits `hueId_` — the value every caller already treats as
/// "not an id". The range bound is the point: an id too large would otherwise narrow into a
/// DIFFERENT valid light (`"65537"` → light 1).
static uint16_t parseId(const char* s) {
const int v = json::parseIntStr(s);
return (v > 0 && v <= 0xFFFF) ? static_cast<uint16_t>(v) : 0;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)HueDriver\.h$|JsonUtil|json::|parseIntStr' || true

echo "== inspect HueDriver outline around IDs =="
wc -l src/light/drivers/HueDriver.h
sed -n '360,630p' src/light/drivers/HueDriver.h

echo "== locate parseIntStr definitions/usages =="
rg -n "parseIntStr|parseId|json::parseIntStr|class JsonUtil|struct JsonUtil" src/light src -g '*.{h,hpp,c,cpp,cc,py}' || true

Repository: MoonModules/projectMM

Length of output: 17060


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== JsonUtil parseIntStr implementation =="
sed -n '1,180p' src/core/JsonUtil.h

echo "== JsonUtil parse tests around parseIntStr =="
sed -n '1,260p' test/unit/core/unit_JsonUtil_parseint.cpp

echo "== deterministic prefix-trailing and boundary parsing probe from source logic =="
python3 - <<'PY'
def current_parseIntStr(s):
    """
    Reads the source-logic interpretation of JsonUtil::parseIntStr:
    skip leading ws, optionally read a sign, then parse digits until non-digit/overflow/end,
    using the first non-digit as the trim point.
    """
    import re, math
    # approximate source behavior from tests/docs: leading whitespace ok; sign optional;
    # parse contiguous digits; return full value unless overflow/non-digit encountered.
    m = re.match(r'\s*(?:([+-])\d+|\d+)\b', s)
    if not m:
        return None
    s2 = m.group(0).lstrip()
    try:
        return int(s2)
    except OverflowError:
        return None

def parseId(s):
    v = current_parseIntStr(s)
    return v if v and v > 0 and v <= 0xFFFF else 0

for s in ['1",]', '1junk"foo', '+1"', '-1"', ' 1"', '1', '0', '0', '65535', '65536', '65537']:
    print(f"{s!r} -> parseId={parseId(s)!r}")

print("prefix_tokens_acceptable_by_issue_logic")
for s in ['1junk"foo', '+1"', '-1"', ' 1"', '65537abc"']:
    print(s, "-> parsed_prefix_value:", current_parseIntStr(s), "-> parseId:", parseId(s))
PY

Repository: MoonModules/projectMM

Length of output: 13341


🌐 Web query:

Hue bridge lights endpoint id key format quoted integer spaces before ids rooms lights members quoted string

💡 Result:

In the Philips Hue API v2, resources such as lights, rooms, and zones are identified by a unique resource identifier [1][2]. This identifier is a quoted string containing a UUID (Universally Unique Identifier), for example: "d1ae958e-8908-449a-9897-7f10f9b8d4c2" [1][3]. Key details regarding this format include: Resource Identifier Type: The identifier is represented as a string (str) in API implementations [4][5][6]. It is distinct from the older V1 API, which used simple quoted integers (e.g., "1", "2") for light IDs [7]. API Endpoints: When interacting with the API, the identifier is used as part of the URL path. For example, to retrieve details for a specific light, the endpoint is /clip/v2/resource/light/{lightId}, where {lightId} is the UUID string [4]. Members and Grouping: Rooms and zones are treated as resources that contain members [3]. These members are referenced by their own unique UUID strings within the resource metadata [5]. Technical Implementation: When using HTTP requests, the ID is passed as a standard URL path parameter [4]. In programming models, the ID field is defined as a string [8][5]. When filtering via tools like jq, you select the ID string directly [9]. Example of a resource identifier usage: Request: GET /clip/v2/resource/light/d1ae958e-8908-449a-9897-7f10f9b8d4c2 [4][1]

Citations:


🌐 Web query:

site:developers.meethue.com v1 API lights endpoint /api/<username>/lights example response light ids quoted integer

💡 Result:

In the Philips Hue v1 API, the /api//lights endpoint returns a JSON object where the light IDs are the keys [1]. These keys are strings representing the integer identifier of the light (e.g., "1", "2") [1]. Below is an example of the response format: { "1": { "state": { "on": true, "bri": 254, "hue": 14956, "sat": 141, "effect": "none", "xy": [0.4573, 0.4099], "ct": 366, "alert": "none", "colormode": "ct", "mode": "homeautomation", "reachable": true }, "type": "Extended color light", "name": "Hue Lamp 1", "modelid": "LCT001", "manufacturername": "Signify Netherlands B.V.", "productname": "Hue color lamp", "uniqueid": "00:17:88:01:00:27:02:8a-0b", "swversion": "66012040" }, "2": { "state": { "on": false, "bri": 0, "reachable": false }, "type": "Dimmable light", "name": "Hue Lamp 2", "modelid": "LWB004", "manufacturername": "Signify Netherlands B.V.", "productname": "Hue white lamp", "uniqueid": "00:17:88:01:00:27:02:8b-0b", "swversion": "1.29.0_r21169" } } The light IDs are represented as quoted strings (e.g., "1") rather than raw integers to comply with JSON object key requirements, which must be strings [1].

Citations:


🌐 Web query:

site:developers.meethue.com v1 API groups endpoint rooms lights ids string example response

💡 Result:

In the Philips Hue v1 API, the groups endpoint provides information about groups (such as rooms or zones) within your Hue system [1]. The lights associated with a group are represented as an array of strings, where each string is the ID of a light [2]. Example API Response for a Group: When you perform a GET request to http:///api//groups/, you will receive a JSON response similar to the following: { "name": "Living Room", "lights": [ "1", "2", "5" ], "type": "Room", "action": { "on": true, "bri": 254, "hue": 10000, "sat": 254, "effect": "none", "xy": [0.5, 0.5], "ct": 300, "alert": "none", "colormode": "ct" } } Key details: - Lights IDs: The lights field contains an array of strings [2]. Each string corresponds to the numerical ID of a light registered on the bridge [2]. - Group Structure: Each group object includes the name, the type (e.g., Room, LightGroup, or Zone), and the list of lights assigned to that group [3]. - Interaction: You can use these light IDs to interact with individual lights via the /lights/ endpoint, or use the group ID to control all lights within the group simultaneously via the /groups//action endpoint [2][1].

Citations:


Require a strict quoted-integer parse for Hue IDs.

parseId() delegates to parseIntStr(), whose prefix-only parsing also accepts optional leading whitespace, so " 1" is accepted as ID 1 at the light, group, and room-member scan sites. Make the quoted ID parse require digits-only between quotes before accepting the value.

[low_effort_and_medium_reward]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/drivers/HueDriver.h` around lines 417 - 424, Update
HueDriver::parseId to validate that the quoted ID content consists exclusively
of decimal digits, with no leading whitespace or other characters, before
converting it. Preserve the existing positive uint16_t range check and return 0
for invalid, zero, or out-of-range values so all light, group, and room-member
scan sites reject malformed IDs.

Sources: Coding guidelines, Learnings

Comment thread src/light/drivers/NetworkSendDriver.h Outdated
Comment thread test/unit/core/unit_JsonUtil_parseint.cpp
Comment thread test/unit/light/unit_SingleColumnLayout.cpp
Comment thread test/unit/light/unit_SingleColumnLayout.cpp
The `peripheral` dropdown now says what your chip actually has — I2S-IDF,
LCD-IDF or LCD-MM — instead of `i80`, a bus protocol that names no ESP32
peripheral. The shared string-to-int conversion moved out of line, giving
back most of the flash the safe version cost. A small-strand flicker on
LCD-MM is documented as an open bug rather than patched around.

Performance: desktop tick 128 µs / 7812 fps. Flash: esp32s3-n16r8 1666768
(-1568), esp32p4-eth 1497488 (-672), esp32 1678528 (+160).

Core
- json::parseIntStr moved to a new JsonUtil.cpp. As an inline its three
  validity checks were duplicated into every caller — measured at 1712 bytes
  of S3 flash across ~10 call sites (parseLights +552, applyControlValue
  +560). Out of line the net cost of overflow-safe parsing is +136 bytes,
  and every caller is off the hot path so the call is free in practice.

Light domain
- The `peripheral` options are renamed to name the peripheral plus who
  drives it: `i80` becomes I2S-IDF on the classic ESP32 and LCD-IDF on
  S3/P4/S31; `MoonI80` becomes LCD-MM. "i80" is the Intel 8080 bus shape
  esp_lcd speaks and appears in no ESP32 datasheet — on the classic that
  backend IS the I2S peripheral, on LCD_CAM chips it is the LCD one. The
  chip split is constexpr, so the wrong label cannot be selected. Bus
  init-failure messages now match the dropdown.
- HueDriver::parseId requires a leading digit: strtol skips whitespace and
  accepts a sign, so `" 7"` and `"+7"` read as light 7 — a key shape the
  bridge never emits, and accepting it lets a malformed response address a
  real light.
- NetworkSendDriver's synchronous-send note said "the whole frame"; it
  sends this driver's start/count window.

Scripts/MoonDeck
- check_codeql fails closed on a response it could not parse. Blank,
  truncated or malformed stdout returned an empty list as SUCCESS, which
  renders "No open alerts. ✓" — the exact silent zero the script exists to
  prevent. Valid arrays, pagination and trailing whitespace unchanged.
- The clang-query comments rule had the same hole: its false-zero guard was
  skipped when the matcher returned NOTHING, which is the worse case.
- guard_forms returns before resolving TUs when there are no findings —
  including_tus falls back to every TU on an empty set, so it ran the full
  matcher sweep to annotate an empty table.
- One _src_lines reader replaces three identical read-and-memoize blocks.
- _duration(59.96) rendered "60.0s"; the sub-minute branch now decides on
  the value as displayed. bump_run_count rejects bools and negatives — True
  counted as run #2 and -5 became -4, since bool is an int in Python.

Tests
- unit_SingleColumnLayout.cpp: index order, spatial offset, reversed wiring,
  and agreement with the equivalent 1xN grid. The layout had no coverage.
  Non-zero xposition (x == 0 passed trivially on the default) and every
  entry of the reversed sequence asserted, not just the ends.
- parseIntStr gains the one-past-boundary cases (2147483648 / -2147483649)
  — where `long` is 64-bit only the INT_MAX compare rejects them, where it
  is 32-bit only ERANGE does.

Docs/CI
- MIGRATING.md documents the peripheral rename and its one action: re-set
  the control, and only on a device holding persisted state with a
  non-default backend.
- lessons.md + backlog-light.md record the LCD-MM small-strand flicker as
  OPEN with the cause unproven. Moving the frame buffer from PSRAM to
  internal RAM fixes it (bench-verified), but three explanations for the
  size dependence were checked and all fail, so the workaround was
  deliberately reverted: internal RAM is the scarce pool, LCD-MM targets
  large fixtures, and a short strand is the IDF backend's job.
- history/README.md said a plan "is deleted once the plan ships"; deleting
  a plan is the product owner's call.
- MoonDeck.md: COND's `?` covers a refused matcher and a failed query, not
  just a missing clang-query.

Reviews
- 🐇 CodeQL parse not fail-closed, comments-rule empty-rows guard,
  guard_forms early return, _src_lines duplication, _duration boundary,
  bump_run_count bool/negative, parseId digit check, COND doc, plans
  lifecycle, NetworkSendDriver window, test gaps → all fixed.
- 🐇 ISC004 parenthesisation → skipped, stale (ruff --select ISC passes).
- 🐇 test/** in the CodeQL trigger → skipped: it would scan on every test
  edit for the quality-pack tier this branch just removed.
- 🐇 five new Python test suites for MoonDeck tooling → skipped, deliberate
  infrastructure that wants its own shape rather than a mid-review bolt-on.
- 🐇 bump_run_count locking + run numbering on launch → not done; both are
  real, both need a design call on a report footer.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
moondeck/check/check_nonblocking.py (1)

585-589: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not sort unknown guards as unconditional.

? means guard analysis was unavailable/unknown, not unguarded, but this key places it with before verified conditional findings. Only NO_CAUSE should receive unconditional-first priority.

Proposed fix
-        subset = sorted(subset, key=lambda r: (r["cond"] not in (NO_CAUSE, "?"),
+        subset = sorted(subset, key=lambda r: (r["cond"] != NO_CAUSE,
                                                r["file"], r["line"]))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_nonblocking.py` around lines 585 - 589, Update the
sorting key in the subset ordering logic so only NO_CAUSE is treated as
unconditional-first; do not include "?" in that priority condition. Preserve the
existing file and line tie-breakers and stable ordering for unknown and verified
conditional findings.
src/light/drivers/MultiPinLedDriver.h (1)

210-217: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the capability comment to match the new predicate.

Line 217 checks platform::hasLcdCam, but Lines 214-215 still say the decision is keyed on lcdLanes. Keep the documentation aligned with the implementation.

Proposed correction
-    /// the refusal a compile-time property of the silicon rather than a runtime surprise, and the orchestrator then
+    /// the refusal a compile-time property of the silicon rather than a runtime surprise, and the orchestrator then
     /// reports it as a config error instead of letting the bus die at init with "check pins / memory".
-    bool supportsPinExpander() const override { return platform::lcdLanes > 0; }
+    bool supportsPinExpander() const override { return platform::hasLcdCam; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/drivers/MultiPinLedDriver.h` around lines 210 - 217, Update the
documentation above supportsPinExpander() to state that capability is keyed on
platform::hasLcdCam rather than lcdLanes, while preserving the existing
explanation of LCD_CAM support and classic ESP32 limitations.
moondeck/check/check_clang_query.py (3)

993-999: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Fail closed when the visibility query fails.

The primary query output is validated, but the _NONPUBLIC_MATCHER output is passed directly to collect_nonpublic(). If that query is rejected or errors, the empty set causes every row to be labeled pub, falsely inflating the public-surface percentage. Reuse the same matcher-output validation before applying visibility annotations.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_clang_query.py` around lines 993 - 999, Validate the
output of the `_NONPUBLIC_MATCHER` query before passing it to
`collect_nonpublic()` in the visibility pass. Reuse the existing primary-query
matcher-output validation, and fail closed on rejection or errors rather than
treating an empty result as successful and marking every row `pub`.

985-992: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not infer shadow failure from zero developer notes.

not any(r["dev_words"] for r in rows) is a data condition, not an execution check: a valid report can contain no declaration-attached // notes. With --module, this guard also runs before the later only filter, so another module can satisfy the check for the selected module. Validate shadow inclusion directly and apply module filtering before any scope-specific validation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_clang_query.py` around lines 985 - 992, The guard around
the `not any(r["dev_words"] for r in rows)` check incorrectly treats valid
zero-note data as shadow failure. Replace it with a direct
shadow-inclusion/execution validation, and apply the `--module`/`only` filtering
before scope-specific validation so only the selected module is evaluated.

414-417: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Use a complete source-state fingerprint for the shadow cache.

The stamp stores only the maximum source-file mtime. A change to any file whose mtime remains below that maximum leaves the stamp unchanged, so _shadow_tree can return stale rewritten sources and silently report outdated comments. Fingerprint all relevant paths and metadata/content, or rebuild whenever the source tree changes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_clang_query.py` around lines 414 - 417, The shadow cache
validation around _shadow_tree must detect changes to any relevant source file,
not only the maximum modification time. Replace the newest mtime stamp
calculation with a complete source-tree fingerprint covering each relevant path
and its metadata or content, store and compare that fingerprint through the
existing stamp file, and rebuild the shadow tree whenever it differs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/light/drivers/NetworkSendDriver.h`:
- Around line 26-29: Reconcile the full-buffer 128×128 WiFi ArtNet timing
documented in the driver comments near the synchronous-send description and the
control documentation around the corresponding timing reference. Use one
consistent estimate, or explicitly state the differing benchmark conditions so
the approximately 90 ms and 110 ms figures are not contradictory.

---

Outside diff comments:
In `@moondeck/check/check_clang_query.py`:
- Around line 993-999: Validate the output of the `_NONPUBLIC_MATCHER` query
before passing it to `collect_nonpublic()` in the visibility pass. Reuse the
existing primary-query matcher-output validation, and fail closed on rejection
or errors rather than treating an empty result as successful and marking every
row `pub`.
- Around line 985-992: The guard around the `not any(r["dev_words"] for r in
rows)` check incorrectly treats valid zero-note data as shadow failure. Replace
it with a direct shadow-inclusion/execution validation, and apply the
`--module`/`only` filtering before scope-specific validation so only the
selected module is evaluated.
- Around line 414-417: The shadow cache validation around _shadow_tree must
detect changes to any relevant source file, not only the maximum modification
time. Replace the newest mtime stamp calculation with a complete source-tree
fingerprint covering each relevant path and its metadata or content, store and
compare that fingerprint through the existing stamp file, and rebuild the shadow
tree whenever it differs.

In `@moondeck/check/check_nonblocking.py`:
- Around line 585-589: Update the sorting key in the subset ordering logic so
only NO_CAUSE is treated as unconditional-first; do not include "?" in that
priority condition. Preserve the existing file and line tie-breakers and stable
ordering for unknown and verified conditional findings.

In `@src/light/drivers/MultiPinLedDriver.h`:
- Around line 210-217: Update the documentation above supportsPinExpander() to
state that capability is keyed on platform::hasLcdCam rather than lcdLanes,
while preserving the existing explanation of LCD_CAM support and classic ESP32
limitations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9858fc65-9572-4d08-84f9-a189490dadc6

📥 Commits

Reviewing files that changed from the base of the PR and between a1ef9ec and 1aff3df.

📒 Files selected for processing (22)
  • CMakeLists.txt
  • docs/MIGRATING.md
  • docs/backlog/backlog-light.md
  • docs/history/README.md
  • docs/history/lessons.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • esp32/main/CMakeLists.txt
  • moondeck/MoonDeck.md
  • moondeck/check/check_clang_query.py
  • moondeck/check/check_codeql.py
  • moondeck/check/check_nonblocking.py
  • moondeck/moondeck.py
  • src/core/JsonUtil.cpp
  • src/core/JsonUtil.h
  • src/light/drivers/HueDriver.h
  • src/light/drivers/MoonLedDriver.h
  • src/light/drivers/MultiPinLedDriver.h
  • src/light/drivers/NetworkSendDriver.h
  • test/unit/core/unit_JsonUtil_parseint.cpp
  • test/unit/light/unit_SingleColumnLayout.cpp
  • web-installer/deviceModels.json

Comment thread src/light/drivers/NetworkSendDriver.h Outdated
A board can now drive ColorLight 5A-75 receiver cards directly, replacing an FPP
host in a panel installation: effects render on the device and go out as raw L2
frames, no IP and no DHCP lease involved. Verified on real hardware — an
ESP32-S31 lighting two 128x64 HUB75 panels over a gigabit link. Also frees
13.7 KB of static RAM by making three diagnostic buffers allocate on first use.

Performance: no measurable change to the render tick (PanelCardDriver costs
2.6 ms per frame on a 128x128 wall, only when added).

**Core**
- platform::ethSendRaw — one L2 frame to the MAC, gated on the LINK rather than
  on an IP, so a board whose DHCP never completes still drives panels.
- platform::ethClaimRawL2 / ethRawL2Claimed — a driver STATES that it owns the
  interface; NetworkModule then reports a leaseless link as normal instead of a
  fault. Stated at prepare, so it cannot race the cascade's DHCP timeout.
- platform::ethLinkSpeedMbps / ethSendFailStreak — the negotiated speed and
  consecutive send failures, so the driver can report a slow link and tell
  back-pressure apart from a wedged transmit path.
- platform::ethBindRawInterface — Linux AF_PACKET / macOS BPF, so a desktop or
  Raspberry Pi build drives panels too; unprivileged runs record frames instead.
- Three statics now allocate on first use: the 8 KB FFT scratch, the 4 KB OTA
  chunk buffer, and the 1440 B task snapshot. 14548 B -> 828 B tree-wide.

**Light domain**
- PanelCardDriver — window, correction and chunking over a shared packet buffer;
  the wall geometry comes from the Layout, so a PanelsLayout is the single place
  a panel arrangement is described.
- ColorLight5A75Packet.h — the wire format. Note the EtherType field is
  overloaded: byte 12 is the packet type and byte 13 is already payload.
- Gated on MM_PANEL_CARDS (S31 + P4), saving ~2.6 KB on boards without a use for it.
- MoonLedDriver comments rebalanced; peripheral labels name the silicon.

**UI**
- The driver card reports link speed, packet rate and cumulative drops.

**Scripts/MoonDeck**
- check_footprint — per-file flash/static bytes from the ELF, with a STATIC
  column that surfaced all three of the RAM statics above.
- check_nonblocking gains a COND column (clang-query) showing whether a blocking
  call is guarded, ordered unconditional-first.
- parseIntStr moved out of line: +1712 -> +136 bytes.

**Tests**
- 21 unit tests for the packet bytes, chunking at 497 pixels, sync-last ordering,
  the rate limiter, a failing link, the raw-L2 claim, and geometry derived from a
  real PanelsLayout.

**Docs/CI**
- Driver catalog card, and the vendor vocabulary (sending card / receiving card)
  so our name maps onto what the datasheets call it.

**Reviews**
- 👾 rowsSent misleading and the outer loop spun past the buffer -> fixed.
- 👾 a test name claimed a sync field that does not exist -> renamed.
- 👾 ethRelinkTx() dead across three implementations -> removed; it was an
  inferred recovery for a stall we have not yet diagnosed, and an unreachable
  stop/start of a live interface is worse than not having it.
- 👾 framesLastSecond_ write-only -> removed.
- 👾 docs described width/height controls that no longer exist -> fixed.
- 👾 #panelsend anchors left by the rename -> fixed, including the docPath.
- 👾 BPF BIOCSHDRCMPLT unchecked -> fixed. Without it BPF overwrites the source
  MAC, which is exactly what the cards filter on, so frames would go out
  well-formed and be silently ignored. macOS only, so the bench could not catch it.
- 👾 245 KB static capture buffer in the shipped binary -> now allocated on first
  capture; a deployment that binds a real interface pays nothing.
- 👾 the 0xFF card gain was a duplicated literal -> named kCardGain.

Known, and deliberately not addressed here: the S31 occasionally stops sending
until a restart. The cause is not yet established, so this commit adds the
diagnostic that distinguishes ordinary back-pressure from a wedged transmit path
rather than a fix for an inferred cause.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/platform/esp32/platform_esp32_i2s.cpp (1)

43-75: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not defer heap allocation into tick-reachable paths. Both changes trade boot-time static RAM for an unpredictable first-use allocation during periodic work; establish the storage during module/platform setup instead.

  • src/platform/esp32/platform_esp32_i2s.cpp#L43-L75: allocate FFT scratch before audio analysis begins, not from audioFft().
  • src/platform/esp32/platform_esp32_tasks.cpp#L45-L74: allocate task scratch before tick1s snapshots, preserving the allocation-free snapshot contract.

As per path instructions, “tick hot-path must not block/allocate.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/esp32/platform_esp32_i2s.cpp` around lines 43 - 75, Move FFT
scratch allocation out of the first-use path in ensureFftInit and perform it
during platform/module setup before audio analysis begins; retain the existing
initialization and failure handling while ensuring audioFft remains
allocation-free. In src/platform/esp32/platform_esp32_i2s.cpp lines 43-75,
update ensureFftInit or its setup caller accordingly; in
src/platform/esp32/platform_esp32_tasks.cpp lines 45-74, likewise allocate task
scratch before tick1s snapshots, with no direct allocation in the tick hot path.

Source: Path instructions

src/light/drivers/NetworkSendDriver.h (2)

361-362: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the actual socket lifecycle.

Line 361 says the socket is opened in setup(), but setup() only initializes cid_; prepare() calls socket_.open() and release() closes it.

Proposed comment fix
-/// The send socket, opened once in setup() and reused for every destination.
+/// The send socket, opened idempotently in prepare() and reused for every destination.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/drivers/NetworkSendDriver.h` around lines 361 - 362, Update the
documentation above NetworkSendDriver’s socket_ member to describe the actual
lifecycle: prepare() opens the socket, it is reused for destinations, and
release() closes it; do not claim setup() opens the socket.

20-24: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use “fan-out” instead of “broadcasting.”

ledsPerPin partitions the window across independent lanes/receivers; it does not broadcast one payload to all of them. This wording conflicts with the subsequent unicast explanation and makes the network behavior ambiguous.

Proposed wording fix
-/// (`lightsPerIp`, the same broadcasting idiom as an LED driver's `ledsPerPin`) and each addressed
+/// (`lightsPerIp`, the same fan-out idiom as an LED driver's `ledsPerPin`) and each addressed
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/drivers/NetworkSendDriver.h` around lines 20 - 24, Update the
addressing documentation in NetworkSendDriver to replace “broadcasting idiom”
with “fan-out” terminology, clarifying that ledsPerPin-style partitioning
assigns contiguous window segments to independent receivers rather than sending
one payload to all. Keep the existing unicast-default and legacy-broadcast
explanation unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@esp32/main/CMakeLists.txt`:
- Around line 109-114: Update the comment above the MM_PANEL_CARDS compile
definition to refer to PanelCardDriver instead of PanelSendDriver, leaving the
build logic unchanged.

In `@moondeck/check/check_clang_query.py`:
- Around line 925-938: The duplicate matcher-rejection detector should be
centralized in check_clang_query._matcher_rejected. Keep
moondeck/check/check_clang_query.py lines 925-938 as the canonical definition;
in moondeck/check/check_nonblocking.py lines 286-302, remove the local
definition and update its usage to call
check_clang_query._matcher_rejected(out), reusing the existing check_clang_query
import.

In `@moondeck/check/check_module.py`:
- Around line 47-56: The per-module documentation and scheduled tool list are
inconsistent. In moondeck/check/check_module.py, update the TOOLS configuration
to include check_nonblocking.py and check_codeql.py, then update
moondeck/MoonDeck.md lines 205-215 to accurately describe the resulting six-tool
stack and retain the -Wfunction-effects/CodeQL guidance.

In `@moondeck/MoonDeck.md`:
- Around line 604-608: Update the RODATA description in the memory-cost table to
remove the claim that “--strings” measures the string table wholesale or acts as
a flag; describe string-table accounting as unconditional, consistent with the
behavior documented near the string-table measurement section and companion
explanation.
- Around line 586-591: Remove the nonexistent --strings invocation from the
check_footprint.py usage examples, and revise the later --strings callout to
state that string-table reporting runs automatically in the same report when
objdump/c++filt are available. Keep the documented no-separate-flag behavior
consistent throughout MoonDeck.md.

In `@src/light/drivers/PanelCardDriver.h`:
- Around line 124-142: Update PanelCardDriver::prepare() so every invocation
synchronizes the raw interface binding: pass the configured interface when
non-empty and explicitly unbind it via platform::ethBindRawInterface(nullptr)
when interface is cleared. Preserve the existing bind-failure warning behavior
and ensure the live bound-to-blank transition returns to capture-only mode
without requiring release().

In `@src/platform/desktop/platform_desktop.cpp`:
- Around line 777-813: Update ethSendRaw’s real-interface branch to store the
Linux sendto/write result, increment ethSendFails_ when the syscall fails, and
reset it to zero on success before returning the success status. Preserve the
existing behavior for simulated failures and capture mode.

---

Outside diff comments:
In `@src/light/drivers/NetworkSendDriver.h`:
- Around line 361-362: Update the documentation above NetworkSendDriver’s
socket_ member to describe the actual lifecycle: prepare() opens the socket, it
is reused for destinations, and release() closes it; do not claim setup() opens
the socket.
- Around line 20-24: Update the addressing documentation in NetworkSendDriver to
replace “broadcasting idiom” with “fan-out” terminology, clarifying that
ledsPerPin-style partitioning assigns contiguous window segments to independent
receivers rather than sending one payload to all. Keep the existing
unicast-default and legacy-broadcast explanation unchanged.

In `@src/platform/esp32/platform_esp32_i2s.cpp`:
- Around line 43-75: Move FFT scratch allocation out of the first-use path in
ensureFftInit and perform it during platform/module setup before audio analysis
begins; retain the existing initialization and failure handling while ensuring
audioFft remains allocation-free. In src/platform/esp32/platform_esp32_i2s.cpp
lines 43-75, update ensureFftInit or its setup caller accordingly; in
src/platform/esp32/platform_esp32_tasks.cpp lines 45-74, likewise allocate task
scratch before tick1s snapshots, with no direct allocation in the tick hot path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e3224e9b-af3d-406b-a13a-4e40082d8710

📥 Commits

Reviewing files that changed from the base of the PR and between 1aff3df and 5c8b208.

⛔ Files ignored due to path filters (1)
  • moondeck/build/build_esp32.py is excluded by !**/build/**
📒 Files selected for processing (29)
  • docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/light/drivers.md
  • esp32/main/CMakeLists.txt
  • moondeck/MoonDeck.md
  • moondeck/check/check_clang_query.py
  • moondeck/check/check_footprint.py
  • moondeck/check/check_module.py
  • moondeck/check/check_nonblocking.py
  • moondeck/moondeck_config.json
  • src/core/NetworkModule.h
  • src/light/ColorLight5A75Packet.h
  • src/light/drivers/MoonLedDriver.h
  • src/light/drivers/MultiPinLedDriver.h
  • src/light/drivers/NetworkSendDriver.h
  • src/light/drivers/PanelCardDriver.h
  • src/main.cpp
  • src/platform/desktop/platform_config.h
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_config.h
  • src/platform/esp32/platform_esp32.cpp
  • src/platform/esp32/platform_esp32_i2s.cpp
  • src/platform/esp32/platform_esp32_ota.cpp
  • src/platform/esp32/platform_esp32_tasks.cpp
  • src/platform/platform.h
  • test/CMakeLists.txt
  • test/unit/light/unit_PanelCardDriver.cpp
  • test/unit/light/unit_PanelCardDriver_packet.cpp

Comment thread esp32/main/CMakeLists.txt
Comment on lines +109 to +114
if(MM_PANEL_CARDS)
# Links PanelSendDriver (panel receiver cards over raw L2). Opt-in per firmware because the
# cards want a gigabit link and no CONFIG_SOC_* macro distinguishes a gigabit board from a
# 100 Mbit one — see the gate in src/main.cpp.
target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_PANEL_CARDS)
endif()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Comment references the wrong driver class name.

The comment says this gate "Links PanelSendDriver", but the driver actually implemented/registered in this PR is PanelCardDriver (see src/light/drivers/PanelCardDriver.h). No PanelSendDriver exists anywhere in the codebase — this looks like a leftover from an earlier name.

✏️ Proposed fix
 if(MM_PANEL_CARDS)
-    # Links PanelSendDriver (panel receiver cards over raw L2). Opt-in per firmware because the
+    # Links PanelCardDriver (panel receiver cards over raw L2). Opt-in per firmware because the
     # cards want a gigabit link and no CONFIG_SOC_* macro distinguishes a gigabit board from a
     # 100 Mbit one — see the gate in src/main.cpp.
     target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_PANEL_CARDS)
 endif()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if(MM_PANEL_CARDS)
# Links PanelSendDriver (panel receiver cards over raw L2). Opt-in per firmware because the
# cards want a gigabit link and no CONFIG_SOC_* macro distinguishes a gigabit board from a
# 100 Mbit one — see the gate in src/main.cpp.
target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_PANEL_CARDS)
endif()
if(MM_PANEL_CARDS)
# Links PanelCardDriver (panel receiver cards over raw L2). Opt-in per firmware because the
# cards want a gigabit link and no CONFIG_SOC_* macro distinguishes a gigabit board from a
# 100 Mbit one — see the gate in src/main.cpp.
target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_PANEL_CARDS)
endif()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@esp32/main/CMakeLists.txt` around lines 109 - 114, Update the comment above
the MM_PANEL_CARDS compile definition to refer to PanelCardDriver instead of
PanelSendDriver, leaving the build logic unchanged.

Comment on lines +925 to +938
def _matcher_rejected(out):
"""True when clang-query refused the matcher rather than finding nothing.

A rejected matcher still exits 0 with no matches, so the two are indistinguishable by status —
and its complaints do not all say `error:` (an ambiguous top-level `anyOf()` reports "Input
value has unresolved overloaded type"). Shared by the primary rule and the visibility pass:
the second had no validation at all, so a refused matcher there silently marked every row
`pub` instead of failing.
"""
bad = [ln for ln in out.splitlines()
if "error: " in ln or "unresolved overloaded type" in ln or "not found" in ln]
return bool(bad) and "binds here" not in out and "Match #" not in out


Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

_matcher_rejected is duplicated verbatim across two tools. Both files independently implement the same "clang-query silently refused the matcher" detector (same docstring, same heuristics), rather than one file defining it and the other importing it.

  • moondeck/check/check_clang_query.py#L925-L938: keep this as the canonical definition (it's already the module other tools import from, e.g. check_footprint.py imports check_clang_query.module_files).
  • moondeck/check/check_nonblocking.py#L286-L302: replace the local _matcher_rejected definition/usage with check_clang_query._matcher_rejected(out), since this file already imports check_clang_query for other shared helpers.
🧰 Tools
🪛 Ruff (0.16.0)

[warning] 925-925: Missing return type annotation for private function _matcher_rejected

(ANN202)

📍 Affects 2 files
  • moondeck/check/check_clang_query.py#L925-L938 (this comment)
  • moondeck/check/check_nonblocking.py#L286-L302
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_clang_query.py` around lines 925 - 938, The duplicate
matcher-rejection detector should be centralized in
check_clang_query._matcher_rejected. Keep moondeck/check/check_clang_query.py
lines 925-938 as the canonical definition; in
moondeck/check/check_nonblocking.py lines 286-302, remove the local definition
and update its usage to call check_clang_query._matcher_rejected(out), reusing
the existing check_clang_query import.

Comment on lines +47 to 56
#
# `optional` marks a tool whose inputs may legitimately be absent — footprint reads an ESP32 ELF,
# and a desktop-only checkout has none. That is a SKIP, not a failure: failing the whole card
# because the firmware was not built would train people to ignore its exit code.
TOOLS = [
("clang-tidy", ["check_clang_tidy.py"]),
("clang-query", ["check_clang_query.py", "--max-rows=0"]),
("lizard", ["check_lizard.py", "--all"]),
("clang-tidy", ["check_clang_tidy.py"], False),
("clang-query", ["check_clang_query.py"], False),
("lizard", ["check_lizard.py", "--all"], False),
("footprint", ["check_footprint.py"], True),
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## files"
git ls-files | rg 'moondeck/(check/check_module\.py|MoonDeck\.md|check/_?nonblocking|check/_?codeql|check/.)' || true

echo
echo "## check_module.py outline and relevant contents"
if [ -f moondeck/check/check_module.py ]; then
  wc -l moondeck/check/check_module.py
  cat -n moondeck/check/check_module.py
fi

echo
echo "## check nonblocking/codeql contents"
for f in moondeck/check/check_nonblocking.py mooneck/check/check_nonblocking.py moondeck/check/check_codeql.py; do
  if [ -f "$f" ]; then
    echo "--- $f"
    wc -l "$f"
    rg -n "def |--incremental|--module|optional|main|if __name__" "$f" -C 2 || true
  fi
done

echo
echo "## MoonDeck check module section"
if [ -f moondeck/MoonDeck.md ]; then
  nl -ba moondeck/MoonDeck.md | sed -n '180,230p'
fi

echo
echo "## search for TOOLS and script references"
rg -n "TOOLS|check_nonblocking|check_codeql|-Wfunction-effects|CodeQL|footprint|clang-tidy|clang-query|lizard" moondeck -S || true

Repository: MoonModules/projectMM

Length of output: 13406


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "## check_module call graph by parsing TOOLS and argv construction"
python3 - <<'PY'
from pathlib import Path
import ast

path = Path("moondeck/check/check_module.py")
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
    if isinstance(node, ast.Assign):
        for target in node.targets:
            if isinstance(target, ast.Name) and target.id == "TOOLS":
                print("TOOLS=", ast.literal_eval(node.value))
for node in ast.walk(tree):
    if isinstance(node, ast.Subscript) and isinstance(node.slice, ast.Constant) and node.slice.value == "argv":
        print("subscript argv node at", node.lineno, ast.dump(node))
PY

echo
echo "## MoonDeck check module section"
python3 - <<'PY'
from pathlib import Path
text = Path("moondeck/MoonDeck.md").read_text()
lines = text.splitlines()
for i in range(max(0, 190), min(len(lines), 225)):
    print(f"{i+1:4}: {lines[i]}")
PY

echo
echo "## run count and missing scripts in current TOOLS"
python3 - <<'PY'
from pathlib import Path
import ast
text = Path("moondeck/check/check_module.py").read_text()
tree = ast.parse(text)
for node in ast.walk(tree):
    if isinstance(node, ast.Assign):
        for target in node.targets:
            if isinstance(target, ast.Name) and target.id == "TOOLS":
                tools = ast.literal_eval(node.value)
                print("count:", len(tools))
                for name, argv, optional in tools:
                    print(name, argv, optional, "script", argv[0], "exists", (Path("moondeck/check") / argv[0]).exists())
PY

Repository: MoonModules/projectMM

Length of output: 3006


Align the per-module docs with the wired TOOLS list. check_module.py only schedules clang-tidy, clang-query, lizard, and footprint, but MoonDeck.md says this card runs the six-tool stack and describes -Wfunction-effects/CodeQL as part of that. Either add check_nonblocking.py/check_codeql.py to TOOLS or update the table and “six tools/everything at once” wording to make clear those two must be run separately.

📍 Affects 2 files
  • moondeck/check/check_module.py#L47-L56 (this comment)
  • moondeck/MoonDeck.md#L205-L215
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/check/check_module.py` around lines 47 - 56, The per-module
documentation and scheduled tool list are inconsistent. In
moondeck/check/check_module.py, update the TOOLS configuration to include
check_nonblocking.py and check_codeql.py, then update moondeck/MoonDeck.md lines
205-215 to accurately describe the resulting six-tool stack and retain the
-Wfunction-effects/CodeQL guidance.

Comment thread moondeck/MoonDeck.md
Comment on lines +586 to +591
```bash
uv run moondeck/check/check_footprint.py # every file, biggest first
uv run moondeck/check/check_footprint.py --module HueDriver
uv run moondeck/check/check_footprint.py --firmware esp32 # another target
uv run moondeck/check/check_footprint.py --strings # the string table
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

--strings is not a real flag — contradicts the code and this doc's own later text.

check_footprint.py has no --strings argument; the string table always runs alongside the byte report whenever objdump/c++filt are found (confirmed from the full script). This usage line, and the callout at Line 607 ("--strings measures that half wholesale"), both imply a flag that doesn't exist, and directly contradict Line 624-625 of this same document ("Strings ride along in the same run — one report, both halves, no flag").

📝 Proposed fix
 uv run moondeck/check/check_footprint.py                    # every file, biggest first
 uv run moondeck/check/check_footprint.py --module HueDriver
 uv run moondeck/check/check_footprint.py --firmware esp32   # another target
-uv run moondeck/check/check_footprint.py --strings          # the string table
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```bash
uv run moondeck/check/check_footprint.py # every file, biggest first
uv run moondeck/check/check_footprint.py --module HueDriver
uv run moondeck/check/check_footprint.py --firmware esp32 # another target
uv run moondeck/check/check_footprint.py --strings # the string table
```
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/MoonDeck.md` around lines 586 - 591, Remove the nonexistent
--strings invocation from the check_footprint.py usage examples, and revise the
later --strings callout to state that string-table reporting runs automatically
in the same report when objdump/c++filt are available. Keep the documented
no-separate-flag behavior consistent throughout MoonDeck.md.

Comment thread moondeck/MoonDeck.md
Comment on lines +604 to +608
| Column | |
|---|---|
| **CODE** | `.text` — flash (or IRAM). Costs space, not headroom |
| **RODATA** | **named** constants only. String literals carry no symbol, so most of the ~427 KB in `.flash.rodata` cannot be credited to a file — `--strings` measures that half wholesale |
| **STATIC** | `.bss` + `.data` — RAM held from boot **whether the module is enabled or not**. This is the "an unused module should cost nothing" check: anything here is a standing tax. Not a synonym for *global*: it counts anything with static STORAGE DURATION, which includes a function-local `static` buffer and a file-local one in an anonymous namespace. Measured here, all 69 are file- or function-local; the codebase has no true globals |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Same --strings-as-a-flag error as Line 590.

"--strings measures that half wholesale" repeats the non-existent-flag claim; the string table is unconditional, not flag-gated (see Line 624-625 below, and the companion comment at Lines 586-591).

📝 Proposed fix
-| **RODATA** | **named** constants only. String literals carry no symbol, so most of the ~427 KB in `.flash.rodata` cannot be credited to a file — `--strings` measures that half wholesale |
+| **RODATA** | **named** constants only. String literals carry no symbol, so most of the ~427 KB in `.flash.rodata` cannot be credited to a file — the string table below measures that half wholesale |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/MoonDeck.md` around lines 604 - 608, Update the RODATA description
in the memory-cost table to remove the claim that “--strings” measures the
string table wholesale or acts as a flag; describe string-table accounting as
unconditional, consistent with the behavior documented near the string-table
measurement section and companion explanation.

Comment on lines +124 to +142
void prepare() override {
resizeCorrected();

// Tell the network layer this interface is ours: we drive it below IP, so its DHCP cascade
// should not report a leaseless link as a fault. Claimed HERE rather than on first send —
// stating it before any frame goes out cannot race the cascade's own timeout. Idempotent
// across re-prepares via claimed_.
if (!claimed_) { platform::ethClaimRawL2(true); claimed_ = true; }

// A host needs a named NIC to reach the wire; ESP32 accepts and ignores it. A bind failure
// is a Warning rather than an Error: the driver still runs and still records frames, which
// is exactly what a test or a dry run wants — it just is not driving panels.
if (interface[0] && !platform::ethBindRawInterface(interface)) {
setStatus("cannot open interface (needs root?)", Severity::Warning);
return;
}

writeLinkStatus();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

prepare() doesn't unbind the raw socket when interface is cleared live.

prepare() only calls platform::ethBindRawInterface(interface) when interface[0] is non-empty. Since interface is an affectsPrepare control, clearing it (bound → blank) re-runs prepare() alone (no release() in between — confirmed by the "re-prepare without stacking" test calling prepare() twice directly). With interface[0]==0, prepare() takes neither branch and never calls ethBindRawInterface(nullptr), so the previously-opened raw socket on the old interface stays bound and frames keep going out on it, even though the UI/status now implies capture-only mode. Only a full release() (disable/re-enable) actually reverts to capture mode.

As per coding guidelines, "Every setting must apply live without requiring a reboot" — this is a live-edit path that silently doesn't apply.

🐛 Proposed fix
         // A host needs a named NIC to reach the wire; ESP32 accepts and ignores it. A bind failure
         // is a Warning rather than an Error: the driver still runs and still records frames, which
         // is exactly what a test or a dry run wants — it just is not driving panels.
-        if (interface[0] && !platform::ethBindRawInterface(interface)) {
-            setStatus("cannot open interface (needs root?)", Severity::Warning);
-            return;
-        }
+        if (interface[0]) {
+            if (!platform::ethBindRawInterface(interface)) {
+                setStatus("cannot open interface (needs root?)", Severity::Warning);
+                return;
+            }
+        } else {
+            platform::ethBindRawInterface(nullptr);   // revert to capture mode
+        }

Worth adding a regression test (set interface, prepare(), clear it, prepare() again, assert frames land back in the desktop capture buffer) to lock this in.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void prepare() override {
resizeCorrected();
// Tell the network layer this interface is ours: we drive it below IP, so its DHCP cascade
// should not report a leaseless link as a fault. Claimed HERE rather than on first send —
// stating it before any frame goes out cannot race the cascade's own timeout. Idempotent
// across re-prepares via claimed_.
if (!claimed_) { platform::ethClaimRawL2(true); claimed_ = true; }
// A host needs a named NIC to reach the wire; ESP32 accepts and ignores it. A bind failure
// is a Warning rather than an Error: the driver still runs and still records frames, which
// is exactly what a test or a dry run wants — it just is not driving panels.
if (interface[0] && !platform::ethBindRawInterface(interface)) {
setStatus("cannot open interface (needs root?)", Severity::Warning);
return;
}
writeLinkStatus();
}
void prepare() override {
resizeCorrected();
// Tell the network layer this interface is ours: we drive it below IP, so its DHCP cascade
// should not report a leaseless link as a fault. Claimed HERE rather than on first send —
// stating it before any frame goes out cannot race the cascade's own timeout. Idempotent
// across re-prepares via claimed_.
if (!claimed_) { platform::ethClaimRawL2(true); claimed_ = true; }
// A host needs a named NIC to reach the wire; ESP32 accepts and ignores it. A bind failure
// is a Warning rather than an Error: the driver still runs and still records frames, which
// is exactly what a test or a dry run wants — it just is not driving panels.
if (interface[0]) {
if (!platform::ethBindRawInterface(interface)) {
setStatus("cannot open interface (needs root?)", Severity::Warning);
return;
}
} else {
platform::ethBindRawInterface(nullptr); // revert to capture mode
}
writeLinkStatus();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/drivers/PanelCardDriver.h` around lines 124 - 142, Update
PanelCardDriver::prepare() so every invocation synchronizes the raw interface
binding: pass the configured interface when non-empty and explicitly unbind it
via platform::ethBindRawInterface(nullptr) when interface is cleared. Preserve
the existing bind-failure warning behavior and ensure the live bound-to-blank
transition returns to capture-only mode without requiring release().

Source: Coding guidelines

Comment on lines +777 to +813
bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING {
if (!frame || len == 0) return false;
if (ethTestSendFails_) { ethSendFails_++; return false; } // simulated link-down / full TX ring

#ifndef _WIN32
if (ethRawFd_ >= 0) {
#if defined(__linux__)
sockaddr_ll dst = {};
dst.sll_family = AF_PACKET;
dst.sll_ifindex = static_cast<int>(ethRawIfIndex_);
dst.sll_halen = 6;
std::memcpy(dst.sll_addr, frame, 6); // destination MAC is the frame's first 6 bytes
return ::sendto(ethRawFd_, frame, len, 0,
reinterpret_cast<sockaddr*>(&dst), sizeof(dst)) == static_cast<ssize_t>(len);
#else
return ::write(ethRawFd_, frame, len) == static_cast<ssize_t>(len);
#endif
}
#endif

if (ethTestCount_ < kEthTestMaxFrames) {
if (!ethTestFrames_) {
ethTestFrames_ = static_cast<uint8_t(*)[kEthTestFrameMax]>(
std::calloc(kEthTestMaxFrames, kEthTestFrameMax));
}
if (ethTestFrames_) {
// Record the TRUE length even when the copy is clipped, so an oversized frame is visible
// as a length no reader expected rather than as silently short data.
const size_t copy = len < kEthTestFrameMax ? len : kEthTestFrameMax;
std::memcpy(ethTestFrames_[ethTestCount_], frame, copy);
ethTestLens_[ethTestCount_] = len;
}
}
ethTestCount_++;
ethSendFails_ = 0;
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

ethSendFailStreak() never updates when sending on a real bound interface.

The simulated-failure branch (line 779) and the capture-mode branch (line 811-812) both maintain ethSendFails_, but the real raw-socket branch (ethRawFd_ >= 0, lines 782-794 — Linux sendto / macOS write) returns the syscall result directly without touching ethSendFails_ on either success or failure. Compare with platform_esp32.cpp's ethSendRaw, which increments on failure and resets to 0 on success.

This is exactly the deployment path the PR targets (a Pi/mini-PC driving real ColorLight cards over a bound interface — see docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md and this driver's "running this on a host" doc). On that path ethSendFailStreak() — which PanelCardDriver surfaces as its wedge/"too many refuses" diagnostic — will silently stay frozen at whatever value it had before binding, never reflecting real transmit failures.

🐛 Proposed fix
 `#ifndef` _WIN32
     if (ethRawFd_ >= 0) {
 `#if` defined(__linux__)
         sockaddr_ll dst = {};
         dst.sll_family = AF_PACKET;
         dst.sll_ifindex = static_cast<int>(ethRawIfIndex_);
         dst.sll_halen = 6;
         std::memcpy(dst.sll_addr, frame, 6);   // destination MAC is the frame's first 6 bytes
-        return ::sendto(ethRawFd_, frame, len, 0,
-                        reinterpret_cast<sockaddr*>(&dst), sizeof(dst)) == static_cast<ssize_t>(len);
+        const bool ok = ::sendto(ethRawFd_, frame, len, 0,
+                        reinterpret_cast<sockaddr*>(&dst), sizeof(dst)) == static_cast<ssize_t>(len);
 `#else`
-        return ::write(ethRawFd_, frame, len) == static_cast<ssize_t>(len);
+        const bool ok = ::write(ethRawFd_, frame, len) == static_cast<ssize_t>(len);
 `#endif`
+        if (ok) ethSendFails_ = 0; else ethSendFails_++;
+        return ok;
     }
 `#endif`
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING {
if (!frame || len == 0) return false;
if (ethTestSendFails_) { ethSendFails_++; return false; } // simulated link-down / full TX ring
#ifndef _WIN32
if (ethRawFd_ >= 0) {
#if defined(__linux__)
sockaddr_ll dst = {};
dst.sll_family = AF_PACKET;
dst.sll_ifindex = static_cast<int>(ethRawIfIndex_);
dst.sll_halen = 6;
std::memcpy(dst.sll_addr, frame, 6); // destination MAC is the frame's first 6 bytes
return ::sendto(ethRawFd_, frame, len, 0,
reinterpret_cast<sockaddr*>(&dst), sizeof(dst)) == static_cast<ssize_t>(len);
#else
return ::write(ethRawFd_, frame, len) == static_cast<ssize_t>(len);
#endif
}
#endif
if (ethTestCount_ < kEthTestMaxFrames) {
if (!ethTestFrames_) {
ethTestFrames_ = static_cast<uint8_t(*)[kEthTestFrameMax]>(
std::calloc(kEthTestMaxFrames, kEthTestFrameMax));
}
if (ethTestFrames_) {
// Record the TRUE length even when the copy is clipped, so an oversized frame is visible
// as a length no reader expected rather than as silently short data.
const size_t copy = len < kEthTestFrameMax ? len : kEthTestFrameMax;
std::memcpy(ethTestFrames_[ethTestCount_], frame, copy);
ethTestLens_[ethTestCount_] = len;
}
}
ethTestCount_++;
ethSendFails_ = 0;
return true;
}
bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING {
if (!frame || len == 0) return false;
if (ethTestSendFails_) { ethSendFails_++; return false; } // simulated link-down / full TX ring
`#ifndef` _WIN32
if (ethRawFd_ >= 0) {
`#if` defined(__linux__)
sockaddr_ll dst = {};
dst.sll_family = AF_PACKET;
dst.sll_ifindex = static_cast<int>(ethRawIfIndex_);
dst.sll_halen = 6;
std::memcpy(dst.sll_addr, frame, 6); // destination MAC is the frame's first 6 bytes
const bool ok = ::sendto(ethRawFd_, frame, len, 0,
reinterpret_cast<sockaddr*>(&dst), sizeof(dst)) == static_cast<ssize_t>(len);
`#else`
const bool ok = ::write(ethRawFd_, frame, len) == static_cast<ssize_t>(len);
`#endif`
if (ok) ethSendFails_ = 0; else ethSendFails_++;
return ok;
}
`#endif`
if (ethTestCount_ < kEthTestMaxFrames) {
if (!ethTestFrames_) {
ethTestFrames_ = static_cast<uint8_t(*)[kEthTestFrameMax]>(
std::calloc(kEthTestMaxFrames, kEthTestFrameMax));
}
if (ethTestFrames_) {
// Record the TRUE length even when the copy is clipped, so an oversized frame is visible
// as a length no reader expected rather than as silently short data.
const size_t copy = len < kEthTestFrameMax ? len : kEthTestFrameMax;
std::memcpy(ethTestFrames_[ethTestCount_], frame, copy);
ethTestLens_[ethTestCount_] = len;
}
}
ethTestCount_++;
ethSendFails_ = 0;
return true;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/desktop/platform_desktop.cpp` around lines 777 - 813, Update
ethSendRaw’s real-interface branch to store the Linux sendto/write result,
increment ethSendFails_ when the syscall fails, and reset it to zero on success
before returning the success status. Preserve the existing behavior for
simulated failures and capture mode.

Fixes two real defects found in review — a sync frame could latch a wall where
every send failed (blanking a good image), and the raw-send path on a bound host
never counted failures, so the wedge diagnostic was dead exactly where frames
really go out. The rest is the pre-merge sweep: the panel-card screenshot and its
measured cost, docs that had drifted from the shipped controls, and a pruned
permission list.

Performance: unchanged on the tick path (PanelCardDriver measures 2636 us per
frame on a 128x128 wall, recorded in performance.md; the wall runs ~32 FPS with
the render dominating at 17.6 ms).

**Light domain**
- PanelCardDriver: anyRowSent now tracks a frame REACHING the wire, not the loop
  reaching a row — a link that drops mid-frame no longer latches an empty frame.
- Bail before the brightness pair when the buffer covers no whole row, so a
  misconfigured window is silent rather than emitting frames every tick.
- prepare() re-syncs the interface binding on every call, so clearing the field
  returns a host to capture-only without needing a release.

**Core**
- Desktop ethSendRaw counts failures on the real-interface branch too, not only
  in capture mode.

**UI**
- The driver card screenshot (PanelCardDriver.png), wired via @card and the
  catalog page.

**Scripts/MoonDeck**
- The module card runs all six documented tools; -Wfunction-effects and CodeQL
  were described but never scheduled.
- _matcher_rejected has one home in check_clang_query; check_nonblocking calls it.
- check_footprint says when a module is compiled out of the chosen firmware
  rather than printing a bare zero that reads as a failed run, and admits that
  --module resolves by filename so a differently-named sibling header is missed.

**Tests**
- Two regressions for the latch bug: a link that fails every send emits nothing,
  and a buffer covering no whole row sends nothing.

**Docs/CI**
- performance.md gains the S31 panel-card measurements (per-module tick split,
  the packets-not-pixels ceiling, 0 B static RAM).
- drivers.md: the catalog heading, the controls list, and the anchors matched the
  old name and the deleted geometry controls.
- MoonDeck.md dropped a --strings flag that does not exist.
- Permissions pruned 71 -> 68 and snapshotted; gh release, gh workflow and git rm
  removed as unneeded for this work.

**Reviews**
- 👾 anyRowSent set in the outer loop -> fixed, with regression tests.
- 👾 brightness frames sent for a wall with no rows -> fixed.
- 👾 PanelSendDriver left in esp32/main/CMakeLists.txt -> fixed (last one).
- 👾 catalog heading still read "Panel Send" -> fixed.
- 👾 --strings documented but never implemented -> removed.
- 👾 duplicated comment in check_footprint -> cut.
- 👾 --module under-reports a module's differently-named sibling -> the report now
  says so; fixing the resolver is a separate change.
- 👾 tick() re-checks what prepare() established -> kept: the correction fallback
  is a deliberate sibling convention against a stale correction.
- 🐇 ethSendRaw real-send branch skipped failure tracking -> fixed.
- 🐇 prepare() never unbound a cleared interface -> fixed.
- 🐇 _matcher_rejected duplicated -> centralised.
- 🐇 module card ran four of six documented tools -> wired in.
- 🐇 socket_ comment claimed setup() opens it -> it is prepare().
- 🐇 "broadcasting idiom" -> kept: it is the NumPy/CSS shorthand sense the
  ledsPerPin docs already establish, and the sentence says each receiver is
  addressed only by itself.
- 🐇 move the lazy allocations to setup -> declined: that is what this branch
  removed, and it costs 14 KB held from boot on every board whether the module is
  used or not. One allocation at module-add time, never at steady state, is the
  trade being made and it is documented at each site.

Not addressed here: the S31 occasionally stops sending until a restart. The
diagnostic that distinguishes back-pressure from a wedged transmit path is in
place; the cause is not yet established.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ewowi ewowi changed the title Run every LED driver on the desktop; report doc coverage per declaration Drive LED panel cards over raw Ethernet; free 13.7 KB of static RAM Jul 30, 2026
@ewowi
ewowi merged commit 49795a4 into main Jul 30, 2026
8 checks passed
@ewowi
ewowi deleted the next-iteration branch July 30, 2026 22:11
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