Drive LED panel cards over raw Ethernet; free 13.7 KB of static RAM - #59
Conversation
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>
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (20)
CLAUDE.mddocs/architecture.mddocs/history/plans/Plan-20260728 - Doc-comment size reporting via clang-query.mddocs/metrics/repo-health.jsondocs/metrics/repo-health.mdmoondeck/MoonDeck.mdmoondeck/check/check_clang_query.pymoondeck/check/check_module.pymoondeck/moondeck_config.jsonsrc/light/drivers/HueDriver.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/MultiPinLedDriver.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.htest/unit/light/host_bus.htest/unit/light/unit_MoonLedDriver.cpptest/unit/light/unit_MultiPinLedDriver.cpptest/unit/light/unit_ParlioLedDriver.cpp
| 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}"] |
There was a problem hiding this comment.
📐 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
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>
There was a problem hiding this comment.
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 liftUse a complete source fingerprint for the shadow-tree cache.
.built-fromstores 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 inbuild/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 winFail closed when comment collection is empty.
if rows and ...skips the safeguard whenrows == [], so a shadow/include/matcher failure can print0 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
📒 Files selected for processing (28)
.github/codeql-config.yml.github/workflows/codeql.ymlCLAUDE.mddocs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.mddocs/history/README.mddocs/history/lessons.mddocs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.mddocs/metrics/repo-health.jsondocs/metrics/repo-health.mddocs/moonmodules/light/layouts.mdmoondeck/MoonDeck.mdmoondeck/check/check_clang_query.pymoondeck/check/check_codeql.pymoondeck/check/check_nonblocking.pymoondeck/docs/mkdocs_hooks.pymoondeck/docs/update_module_docs.pymoondeck/moondeck.pymoondeck/moondeck_config.jsonsrc/core/HttpServerModule.cppsrc/core/JsonUtil.hsrc/core/MqttModule.cppsrc/light/drivers/HueDriver.hsrc/light/drivers/NetworkSendDriver.hsrc/platform/desktop/platform_desktop.cpptest/CMakeLists.txttest/scenarios/light/scenario_peripheral_grid_sweep.jsontest/unit/core/unit_JsonUtil_parseint.cpptest/unit/light/unit_SingleColumnLayout.cpp
| # 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' |
There was a problem hiding this comment.
🔒 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.
| # 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.
| 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 |
There was a problem hiding this comment.
📐 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
| _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\"))))"), | ||
| ) |
There was a problem hiding this comment.
📐 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
| /// 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; | ||
| } |
There was a problem hiding this comment.
🎯 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}' || trueRepository: 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))
PYRepository: 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:
- 1: https://www.openhab.org/addons/bindings/hue/doc/readme_v2.html
- 2: https://github.com/openhab/openhab-addons/blob/main/bundles/org.openhab.binding.hue/doc/readme_v2.md
- 3: https://v41.openhab.org/addons/bindings/hue/doc/readme_v2.html
- 4: https://github.com/liblaber/openHue-openHue-2.0.0-python-1.0.0/blob/main/documentation/services/LightService.md
- 5: https://hub.liblab.com/open-hue-io/open-hue-api/2.0.0/python/1.0.0/models/ResourceIdentifier
- 6: https://github.com/home-assistant-libs/aiohue/blob/main/aiohue/v2/controllers/base.py
- 7: https://developers.meethue.com/develop/get-started-2/
- 8: https://github.com/home-assistant-libs/aiohue/blob/main/aiohue/v2/models/light.py
- 9: https://www.podfeet.com/blog/2024/08/philips-hue-programming-evolved-part-2-of-3/
🌐 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:
- 1: https://developers.meethue.com/support/
- 2: https://developers.meethue.com/develop/get-started-2/
- 3: https://developers.meethue.com/bridge-api-1-40-expect-to-be-published-in-wk34-2020/
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
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>
There was a problem hiding this comment.
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 winDo not sort unknown guards as unconditional.
?means guard analysis was unavailable/unknown, not unguarded, but this key places it with—before verified conditional findings. OnlyNO_CAUSEshould 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 winUpdate the capability comment to match the new predicate.
Line 217 checks
platform::hasLcdCam, but Lines 214-215 still say the decision is keyed onlcdLanes. 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 winFail closed when the visibility query fails.
The primary query output is validated, but the
_NONPUBLIC_MATCHERoutput is passed directly tocollect_nonpublic(). If that query is rejected or errors, the empty set causes every row to be labeledpub, 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 liftDo 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 lateronlyfilter, 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 liftUse 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_treecan 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
📒 Files selected for processing (22)
CMakeLists.txtdocs/MIGRATING.mddocs/backlog/backlog-light.mddocs/history/README.mddocs/history/lessons.mddocs/metrics/repo-health.jsondocs/metrics/repo-health.mdesp32/main/CMakeLists.txtmoondeck/MoonDeck.mdmoondeck/check/check_clang_query.pymoondeck/check/check_codeql.pymoondeck/check/check_nonblocking.pymoondeck/moondeck.pysrc/core/JsonUtil.cppsrc/core/JsonUtil.hsrc/light/drivers/HueDriver.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/MultiPinLedDriver.hsrc/light/drivers/NetworkSendDriver.htest/unit/core/unit_JsonUtil_parseint.cpptest/unit/light/unit_SingleColumnLayout.cppweb-installer/deviceModels.json
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>
There was a problem hiding this comment.
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 liftDo 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 fromaudioFft().src/platform/esp32/platform_esp32_tasks.cpp#L45-L74: allocate task scratch beforetick1ssnapshots, 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 winDocument the actual socket lifecycle.
Line 361 says the socket is opened in
setup(), butsetup()only initializescid_;prepare()callssocket_.open()andrelease()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 winUse “fan-out” instead of “broadcasting.”
ledsPerPinpartitions 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
⛔ Files ignored due to path filters (1)
moondeck/build/build_esp32.pyis excluded by!**/build/**
📒 Files selected for processing (29)
docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.mddocs/metrics/repo-health.jsondocs/metrics/repo-health.mddocs/moonmodules/light/drivers.mdesp32/main/CMakeLists.txtmoondeck/MoonDeck.mdmoondeck/check/check_clang_query.pymoondeck/check/check_footprint.pymoondeck/check/check_module.pymoondeck/check/check_nonblocking.pymoondeck/moondeck_config.jsonsrc/core/NetworkModule.hsrc/light/ColorLight5A75Packet.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/MultiPinLedDriver.hsrc/light/drivers/NetworkSendDriver.hsrc/light/drivers/PanelCardDriver.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_i2s.cppsrc/platform/esp32/platform_esp32_ota.cppsrc/platform/esp32/platform_esp32_tasks.cppsrc/platform/platform.htest/CMakeLists.txttest/unit/light/unit_PanelCardDriver.cpptest/unit/light/unit_PanelCardDriver_packet.cpp
| 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() |
There was a problem hiding this comment.
📐 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.
| 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.
| 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 | ||
|
|
||
|
|
There was a problem hiding this comment.
📐 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.pyimportscheck_clang_query.module_files).moondeck/check/check_nonblocking.py#L286-L302: replace the local_matcher_rejecteddefinition/usage withcheck_clang_query._matcher_rejected(out), since this file already importscheck_clang_queryfor 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.
| # | ||
| # `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), | ||
| ] |
There was a problem hiding this comment.
🎯 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 || trueRepository: 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())
PYRepository: 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.
| ```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 | ||
| ``` |
There was a problem hiding this comment.
📐 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.
| ```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.
| | 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 | |
There was a problem hiding this comment.
📐 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.
| 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(); | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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
| 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; | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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>
What this lands
A board can drive LED panel cards directly, over raw Ethernet.
PanelCardDriverstreams 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
formatis 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::ethSendRawis gated onethLinkUp()rather thanethConnected(). A driver claims the interface at prepare time (ethClaimRawL2), andNetworkModulethen reports a leaseless link asEthernet: 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
PanelsLayoutalready 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.
ethBindRawInterfaceopens 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
check_footprint— per-file flash and static RAM from the linked ELF. ItsSTATICcolumn is what surfaced all three of the buffers above.CONDcolumn 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.parseIntStrmoved out of line: +1712 → +136 bytes.I2S-IDF,LCD-IDF,LCD-MM— since "i80" described neither what the user sees nor what the chip does. MIGRATING.md has the entry.Reviewed
The Reviewer raised 9 findings on the branch; CodeRabbit 10. Two were real defects, both in code added during review:
Two were declined with reasons: the "broadcasting idiom" wording is the NumPy/CSS shorthand sense that
ledsPerPinalready 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
--stringsflag that never existed, and the module card running four of the six tools its own docs described.Verification
PanelsLayout.MM_NO_ETHstub path).check_footprint --module PanelCardDriver --firmware esp32s31reports 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