From 8d491cbc785a04df79642f48b48945ad67c1c930 Mon Sep 17 00:00:00 2001 From: ewowi Date: Wed, 29 Jul 2026 13:26:39 +0200 Subject: [PATCH 1/5] Run every LED driver on the desktop; report comments per declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 4 +- docs/architecture.md | 13 + ...-comment size reporting via clang-query.md | 75 ++- docs/metrics/repo-health.json | 46 +- docs/metrics/repo-health.md | 28 +- moondeck/MoonDeck.md | 81 ++++ moondeck/check/check_clang_query.py | 437 +++++++++++++++++- moondeck/check/check_module.py | 13 +- moondeck/moondeck_config.json | 8 +- src/light/drivers/HueDriver.h | 315 +++++++------ src/light/drivers/MoonLedDriver.h | 2 +- src/light/drivers/MultiPinLedDriver.h | 2 +- src/main.cpp | 15 +- src/platform/desktop/platform_config.h | 48 +- src/platform/desktop/platform_desktop.cpp | 175 +++++-- src/platform/esp32/platform_config.h | 10 + test/unit/light/host_bus.h | 73 +++ test/unit/light/unit_MoonLedDriver.cpp | 6 +- test/unit/light/unit_MultiPinLedDriver.cpp | 17 +- test/unit/light/unit_ParlioLedDriver.cpp | 14 +- 20 files changed, 1114 insertions(+), 268 deletions(-) create mode 100644 test/unit/light/host_bus.h diff --git a/CLAUDE.md b/CLAUDE.md index 8835298f..4421b423 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,7 @@ fix, keeping main clean) — creating one silently moves work somewhere the PO i 1. **Pick.** One module/effect/driver/capability — the product owner picks what to build next. 2. **Spec.** Specs before code: the module spec and the UI spec sufficient to implement from (a draft may sit in the backlog until it ships); when in doubt, ask. -3. **Plan.** Plan mode before every feature; save the approved plan to `docs/history/plans/` as `Plan-YYYYMMDD - .md` — a temporary document: it ends up as the PR description and the file is deleted once the plan is realized; the merged PR is the design record. For a restructure ("make it simpler/cleaner"): enumerate 2–4 end states, name what each gains and loses, pick the leanest that solves the actual problem; propose as a question, implement only what's picked; surface follow-ups before starting so it's one coherent refactor. +3. **Plan.** Plan mode before every feature; save the approved plan to `docs/history/plans/` as `Plan-YYYYMMDD - <title>.md` — a temporary document: it ends up as the PR description and the file is deleted once the plan is realized; the merged PR is the design record. **Deleting a plan is the product owner's call — never the agent's.** "The code is written" is not "the plan is realized": a plan is realized when its *verification* is done too, including the judgement steps (thresholds tuned, results read together, the bench check). Ask; do not infer it from a green build. For a restructure ("make it simpler/cleaner"): enumerate 2–4 end states, name what each gains and loses, pick the leanest that solves the actual problem; propose as a question, implement only what's picked; surface follow-ups before starting so it's one coherent refactor. ### Build @@ -74,6 +74,8 @@ Commit message: title ≤ 72 characters, imperative. Then a 1–3 sentence end-u **Reviewer at commit-time:** run the Reviewer on the staged diff when the commit is large (roughly ten files or more across areas) or on PO request — start it first so the other checks run in parallel; findings fixed or accepted-with-reason before "commit now". +**Handling review findings** — from the Reviewer, CodeRabbit, or a human: *verify each finding against current code; fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate.* A reviewer reads a snapshot and can be wrong or already out of date, so a finding is a claim to check, not an instruction to apply. Work through **every** finding, lowest severity first — a nit is a one-line fix while attention is cheap, and leaving the small ones for later means they are never done. Rising to the serious findings last also means the cheap context is already loaded. + ### Merge The PO pushes the branch; external review runs on the PR; findings are processed on the branch. On "run pre-merge": `uv run moondeck/event/premerge.py`, which re-runs the mechanical checks over the whole branch diff and lists the judgment gates it cannot decide. diff --git a/docs/architecture.md b/docs/architecture.md index 413b306e..13065aaf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -236,6 +236,19 @@ Abstractions are added when a concrete implementation needs them, not pre-design **Platform boundary (hard rule).** All `#ifdef`, `#if defined`, platform-specific `#include`s, and hardware API calls live exclusively in `src/platform/`. Everything outside `src/platform/` compiles on every target without modification. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags, never a preprocessor `#ifdef`. The boundary is enforced by [`moondeck/check/check_platform_boundary.py`](../moondeck/check/check_platform_boundary.py), a commit gate (see [CLAUDE.md § The Process](../CLAUDE.md#the-process)). +**The desktop build runs everything (hard rule).** Every module, effect and driver in the repo +links and runs on the host — the platform layer simply has no silicon behind the call. Where a +peripheral is absent the host *emulates* it rather than declaring itself incapable: the parallel +WS2812 buses are backed by heap buffers, `lcdLanes` / `parlioLanes` / `rmtTxChannels` report a +real chip's counts, and `hasLcdCam` is true. Code excluded from the host binary is code that +cannot be unit-tested, cannot be seen by any AST-based check, and only ever runs where it is +hardest to debug — which is exactly what the LED drivers were until they were linked here. + +A capability flag therefore answers *"can this build exercise the path?"*, not *"is this real +hardware?"*. Where a flag must mean the latter (`hasLcdCam` gating the pin expander), that is a +deliberate, commented exception. Timing, wire protocol and pin state are NOT emulated: they need +silicon, and faking them would let a self-test report on hardware it never touched. + ## Firmware vs deviceModel vs board Three distinct things, kept distinct in the vocabulary: diff --git a/docs/history/plans/Plan-20260728 - Doc-comment size reporting via clang-query.md b/docs/history/plans/Plan-20260728 - Doc-comment size reporting via clang-query.md index 9e5fb793..940176b7 100644 --- a/docs/history/plans/Plan-20260728 - Doc-comment size reporting via clang-query.md +++ b/docs/history/plans/Plan-20260728 - Doc-comment size reporting via clang-query.md @@ -111,17 +111,70 @@ lizard covers 94% of it already. ## Verification -1. `uv run moondeck/check/check_clang_query.py --rule comments` — table appears, split by scope. -2. **Control check that must fire** (a zero is indistinguishable from a tool that read nothing): - the report must contain `MoonLedDriver.h` at ~9731 chars / 129 lines and `HttpServerModule.h` - at ~8085 / 101. If the longest known comments are absent, the run is broken, not the tree. -3. Cross-check against the source-text count in this plan: **619 blocks** (class 114 / method 400 - / field 63 / other 42). A materially lower AST count means the matcher is missing a scope — - the `varDecl`-without-`fieldDecl` mistake that silently dropped every class member from the - array rule. -4. Run it through the **MoonDeck card**, not the terminal, so the PO's 📄 log is the real number. -5. `cmake --build build` + `ctest` unaffected (no `src/` change); `check_specs.py` clean. -6. Then read the output together and set the per-scope thresholds. +1. ✅ The rule runs and prints a per-scope table. +2. ✅ **Control check fired.** `HttpServerModule.h` at 7968/101 and `MoonI80Peripheral` at + 9334/129 are both in the report. Four parsing bugs were caught by exactly this step: every + NAME read as `col`; the largest comment in the tree dropped by a fixed 80-line lookahead; + ~45 one-line `///` blocks missed because a same-line span prints `<col:23, col:71>` with no + line number; and 365 of 474 rows mis-attributed until the matcher bound the declaration. +3. ✅ Cross-checked. **629 found** (class 124 / method 400 / field 105) against 642 in source — + the remainder is 4 blocks in `ImprovFrame.h` plus enum/typedef scopes outside the matcher. +4. ✅ Run through the MoonDeck card; the log reads 629. +5. ✅ 876 unit tests, 19 scenarios, specs clean, three ESP32 firmwares byte-identical. +6. ◻ **OPEN — the reason this plan is not yet realized.** Read the output together and set the + per-scope thresholds. The report deliberately ships with none. + +### Where the thresholds landed + +The report went through three shapes on PO direction, each discarded for a measured reason: + +1. **Per comment, by characters** — the biggest single comments. Covered only `///`, so it saw + 24% of the tree's comment lines, and could not tell "one huge comment" from "a file of many". +2. **Per file, by line ratio** — hid the outlier inside an average (a 16-line comment in a 27-line + header is fine), and the PO's "2× the line count" budget could not apply: `/// lines ÷ total + lines` is capped at 1.0 by construction, so 2.0 would flag nothing. +3. **Per declaration, by words** — what shipped. A line is a formatting accident; words are what a + reader absorbs. Measured: a comment line carries a median of **13 words** in both kinds, so the + PO's line yardstick (class 10, method/attribute 3) converts to **130 / 40 / 40 words**. + +`DOC DEVIATION` is the signed % against those ideals. `DEV WORDS` is a raw count with no ideal, +because zero IS the ideal there — measuring deviation from "one line" made the best case (no +developer note at all) read as -100%, i.e. worst. Rows sort by |deviation| so both failure modes — +bloated and missing — surface together. + +A `VIS` column separates public from private: doxygen publishes only public members, so an +undocumented public method is an API gap while a private one is a maintenance note. Both are +reported; a bloated comment is bloat either way. + +**No cutoff is enforced.** The ideals are a ruler — `MoonI80Peripheral`'s header may be right at +ten times the ideal, because it IS the driver's spec. + +## Grew out of this plan: the host runs every driver + +Not in the original scope, added on PO direction while implementing. The plan predicted a +permanent coverage limit — 184 `///` blocks in `src/light/drivers/` that the desktop AST could +never see, because the drivers were `#if defined(CONFIG_SOC_*)`-gated in `main.cpp`. The PO's +question ("shouldn't we instead run all existing drivers on the desktop?") turned out to be +right, and the premise wrong: those headers have zero direct ESP includes, already route +everything through `platform.h`, and already had desktop stubs. Only the *constants* said "not +my chip". + +So the host now **emulates** the peripherals rather than declaring itself incapable: +`lcdLanes`/`parlioLanes` 16, `rmtTxChannels` 4, `hasLcdCam` true, and the platform seams back the +buses with heap memory instead of returning `false`/`nullptr`. `ParallelLedDriver` runs on macOS +against all three real backends, switchable live. + +Recorded as a hard rule in [architecture.md § Platform abstraction](../../architecture.md). Its +limit is deliberate: timing, wire protocol and pin state are NOT emulated, because faking them +would let a self-test report on hardware it never touched. + +Coverage side effects, all from the same change: doc comments 454 → 629, RAM-costing arrays +362 → 390, heap allocation sites 63 → 80. Three ESP32 firmwares stayed byte-identical. + +**Follow-up the PO raised, not yet planned:** a host LED-strip emulator that decodes the encoded +buffer back to RGB — `busLoopback` is already the "read back what the wire carried" seam, with a +fully specified `RmtLoopbackResult`. That would catch encode bugs `PreviewDriver` structurally +cannot, since it shows the *layer* buffer rather than the wire. ## Deliberately not in this plan diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 4936cdd8..54bb2adb 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,18 +1,18 @@ { - "commit": "4a2effa2", + "commit": "1af1be61", "flash": { - "esp32": 1684240, - "esp32p4-eth": 1497264, + "esp32": 1678368, + "esp32p4-eth": 1497168, "esp32p4-eth-wifi": 1793760, "esp32s3-n16r8": 1666592, "esp32s3-n8r8": 1666592, "esp32s31": 1919136, - "desktop": 878680 + "desktop": 942552 }, "perf": { "desktop": { - "tick_us": 127, - "fps": 7874 + "tick_us": 2741, + "fps": 364 }, "esp32": { "tick_us": 4164, @@ -21,11 +21,11 @@ }, "loc": { "core": 14827, - "light": 19515, - "platform": 11914, + "light": 19540, + "platform": 12054, "ui": 5811, - "test": 34447, - "moondeck": 18318 + "test": 34483, + "moondeck": 18766 }, "comments": { "core": { @@ -33,40 +33,40 @@ "ratio": 0.408 }, "light": { - "lines": 7457, - "ratio": 0.422 + "lines": 7482, + "ratio": 0.423 }, "platform": { - "lines": 3937, - "ratio": 0.366 + "lines": 3994, + "ratio": 0.367 }, "ui": { "lines": 1518, "ratio": 0.278 }, "test": { - "lines": 5898, + "lines": 5912, "ratio": 0.198 }, "moondeck": { - "lines": 2781, - "ratio": 0.174 + "lines": 2938, + "ratio": 0.18 } }, "tests": { - "cases": 970, + "cases": 974, "scenarios": 22 }, "docs": { "md_files": 169, - "md_lines": 22610, + "md_lines": 22534, "plans_files": 89, - "backlog_lines": 3114, - "lessons_lines": 378, - "claude_md_lines": 126 + "backlog_lines": 3113, + "lessons_lines": 379, + "claude_md_lines": 135 }, "complexity": { - "functions": 2129, + "functions": 2135, "over_threshold": 136, "worst_ccn": 93 } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index d74198eb..6fc0e4ac 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `4a2effa2`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run — **do not edit by hand**. +Measured at `1af1be61`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run — **do not edit by hand**. Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,8 +8,8 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 858 KB (+0 KB) ⚠ | -| esp32 | 1,645 KB | +| desktop | 920 KB | +| esp32 | 1,639 KB | | esp32p4-eth | 1,462 KB | | esp32p4-eth-wifi | 1,752 KB | | esp32s3-n16r8 | 1,628 KB | @@ -20,7 +20,7 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -| desktop | 127 µs | 7,874 | +| desktop | 2,741 µs (+2,544 µs) ⚠ | 364 (−4,712) ⚠ | | esp32 | 4,164 µs | 240 | ## Code @@ -28,24 +28,24 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Area | Lines | Comments | Comment share | |---|---:|---:|---:| | core | 14,827 | 5,549 | 40.8 % | -| light | 19,515 | 7,457 | 42.2 % | -| platform | 11,914 (+24) ⚠ | 3,937 | 36.6 % | +| light | 19,540 (+1) ⚠ | 7,482 | 42.3 % | +| platform | 12,054 (−1) ✓ | 3,994 | 36.7 % | | ui | 5,811 | 1,518 | 27.8 % | -| test | 34,447 | 5,898 | 19.8 % | -| moondeck | 18,318 | 2,781 | 17.4 % | +| test | 34,483 (−82) ✓ | 5,912 | 19.8 % (−0.1 %) ✓ | +| moondeck | 18,766 (+30) ⚠ | 2,938 | 18.0 % (+0.1 %) ⚠ | ## Tests | Kind | Count | |---|---:| -| unit cases | 970 | +| unit cases | 974 | | scenarios | 22 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,129 (+1) ✓ | +| functions | 2,135 | | over threshold | 136 | | worst CCN | 93 | @@ -54,9 +54,9 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Metric | Value | |---|---:| | markdown files | 169 | -| markdown lines | 22,610 (+18) ⚠ | +| markdown lines | 22,534 (+2) ⚠ | | plan files | 89 | -| backlog lines | 3,114 | -| lessons lines | 378 | -| CLAUDE.md lines | 126 | +| backlog lines | 3,113 | +| lessons lines | 379 | +| CLAUDE.md lines | 135 | diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 6d77af12..b84c489e 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -315,6 +315,87 @@ Member methods named `free()` are excluded. We have three (`MoonLive`, `Buffer`, and matching on the name alone counted every call to them as heap deallocation — 15 false positives pointing at the wrong lines. +**Rule `comments` — which declarations are documented, and with which kind.** The project's rule +is that every class, method and attribute carries a short, readable `///` (that is what moxydoc +publishes), while `//` developer notes belong in the code lines rather than stacked on a +declaration. The report gives the matrix: + +``` + DECL DOC DEV NONE %DOC + class 53 116 25 27% + method 415 651 1141 18% + attribute 133 183 923 10% + + 950 declarations carry only a `//` where the rule asks for `///`; 2089 carry no comment at all. + Public surface: 503 of 2558 documented (19%) — the part doxygen publishes. +``` + +Three columns because there are three states, each with a different fix: `///` is documented; +`//` documents it in the wrong kind (promote it, or move it into the code lines); `NONE` has no +comment at all (write one). `%DOC` is the share carrying a real doc comment. + +Then every declaration, ranked by how far it sits from the ideal: + +``` + DOC DEVIATION DOC WORDS DEV WORDS DECL NAME FILE:LINE + +1135% 1606 0 class MoonI80Peripheral MoonLedDriver.h:10 + +797% 1166 0 class HttpServerModule HttpServerModule.h:17 + -100% 0 61 method driversOn Scheduler.h:138 +``` + +**Sizes are WORDS, not lines.** A line is a formatting accident — the same paragraph is 5 lines at +100 columns and 9 at 60 — while words are what a reader absorbs. Measured here, a comment line +carries a median of **13 words** in both kinds, so a line-based yardstick of class 10 / method 3 / +attribute 3 converts to the ideals below. + +| Column | Meaning | +|---|---| +| `DOC DEVIATION` | Signed % difference from the ideal doc size — **class 130 words, method and attribute 40**. `0%` is ideal, `-100%` is absent, and over-documenting is unbounded. Not a "ratio": a ratio is a bare quotient (2.3×), this is a deviation from a target. | +| `DOC WORDS` | Raw `///` word count — the number the deviation is derived from, so it sits beside it. | +| `DEV WORDS` | Raw `//` word count. **No deviation column**, because zero is the ideal: measuring against "one line" made the best case (no note at all) read as `-100%`, i.e. worst. | +| `VIS` | `pub` or `priv`. Doxygen publishes only public members, so an undocumented public method is an **API gap** while a private one is a maintenance note. Both are reported — a bloated comment is bloat either way, and clangd's hover shows both kinds to whoever maintains the code — but the reader can weigh them. | + +The `Public surface: N of M documented` line is the number the doc site reflects. It differs +sharply from the overall figure: `HueDriver` reads 40% public against 13% overall, because 52 of +its 77 declarations are private. + +Rows sort by **|DOC DEVIATION|**, so both failure modes surface together: a bloated header and a +declaration with no comment at all are equally wrong in opposite directions, and ranking on the +signed value would bury one of them. + +The ideals are a ruler, not a gate. `MoonI80Peripheral`'s 1606 words may be exactly right — they +ARE the driver's spec. The ratio says how far from typical something sits; a human decides. + +**How `//` becomes visible to the AST.** Clang's lexer discards `//` — only `///` and `/** */` +become AST nodes, because the compiler consumes those itself. So the rule parses a SHADOW COPY of +`src/` under `build/comment-shadow/`, where every leading `//` has been rewritten to +`/// MMDEV: …`. The marker survives into the comment text, which is what keeps the two kinds +apart; `src/` is never touched and the reported paths are mapped back. + +The marker must be plain text — an `@`-prefixed one is parsed as a doc *command* and stripped, +which silently merges the two kinds again. `-Wdocumentation` would fabricate warnings on +rewritten dev notes (`// @param wrong` becomes a real diagnostic), but clang-query never enables +it, so that risk does not apply here. + +Not reported, because none of them is a declaration anyone documents: **forward declarations** +(`class JsonSink;` — no body, and the real class is reported from the header that defines it), +**lambdas** (`unless(isLambda())` on the record and `unless(ofClass(isLambda()))` on the method +— a lambda written inside a function body is code, not a documented declaration, and its +synthesised closure class plus call operator would otherwise report as 53 undocumented +"methods"), `implicit` closure classes, `invalid` declarations that only parse in the TU that +owns them, and `= default` / `= delete` members. + +A **real** `operator()` on a named functor class is still reported — the exclusion asks the AST +whether the enclosing record is a lambda, rather than testing the method's name, so writing +`struct Compare { bool operator()(…) const; }` tomorrow does not silently drop it. + +Not reported: function PARAMETERS. C++ cannot attach a doc comment to one — 1054 probed, zero +with a comment — they are documented via `@param` inside the method's own comment, and this tree +uses `@param` 8 times, all in a `.js` file. Class ATTRIBUTES are the per-member scope that does +exist, and they are the `field` row above. Declarations clang marks `implicit` (a lambda's +closure class) or `invalid` (a parse that only succeeds in the TU that owns it) are skipped: +both are anonymous, so their only "name" is the literal word `definition`. + Takes ~50s cold (a few seconds once the compilation database is warm). clang-query has no parallel runner of its own and costs ~44s per translation unit, so this runs the 15 `src/` TUs across cores; serial would be ~11 minutes. diff --git a/moondeck/check/check_clang_query.py b/moondeck/check/check_clang_query.py index 1bcb55ea..796ae1a9 100644 --- a/moondeck/check/check_clang_query.py +++ b/moondeck/check/check_clang_query.py @@ -26,6 +26,13 @@ Not a violation — the driver layer allocates deliberately — but the hot path must not, and the count is the thing worth trending. +3. **Doc comment size.** `///` comments become the published module pages (moxydoc), and some + have grown past being read. Reported per scope — class, method, field — with NO threshold: + the scopes have different natural sizes (a class header IS the module spec; a method comment + is a sentence), so one cutoff would flag the best-documented modules as defects. The summary + line is what the thresholds get decided from. Plain `//` is not here: the lexer discards it, + so it never reaches the AST at all. + Usage: uv run moondeck/check/check_clang_query.py # every rule uv run moondeck/check/check_clang_query.py --rule arrays @@ -37,6 +44,7 @@ import json import os import re +import shutil import subprocess import sys from concurrent.futures import ThreadPoolExecutor @@ -115,6 +123,95 @@ # The type a `new` produces: `CXXNewExpr 0x... <...> 'Foo *' array ...`. _NEW_TYPE = re.compile(r"CXXNewExpr 0x\w+ <[^>]*> '(?P<type>[^']+?) ?\*'") +# A doc comment's span. The AST dump elides whatever is unchanged from the previous node, so the +# same construct prints three ways and all three must parse: +# <line:272:4, line:298:76> multi-line +# <line:309:8, col:61> multi-column, one line +# <col:23, col:71> ENTIRELY on the current line — no line number at all +# The third form is why ~45 one-line `///` comments (every effect, layout and modifier header) +# were silently missing from the first version: a regex demanding a digit pair never matched +# them. When a `line:` is absent the line is inherited from the enclosing context, which is +# tracked while walking. +_FULLCOMMENT = re.compile( + r"FullComment 0x\w+ <(?:(?P<path>[^:>]*):(?P<pstart>\d+):\d+|line:(?P<start>\d+):\d+|col:\d+)" + r"(?:, (?:[^:>]*:(?P<pend>\d+):\d+|line:(?P<end>\d+):\d+|col:\d+))?>") + +# The text of one comment line: `TextComment 0x... <col:4, col:29> Text=" A doc comment."`. +_TEXTCOMMENT = re.compile(r"TextComment 0x\w+ <[^>]*> Text=\"(?P<text>.*)\"") + +# Any node that is part of a doc comment's subtree rather than the declaration below it. +# `Comment` alone would also swallow the FullComment of the NEXT comment, so the wrappers are +# named explicitly and FullComment is deliberately absent — reaching one means this comment ended. +_COMMENT_NODE = re.compile( + r"\b(?:ParagraphComment|BlockCommandComment|ParamCommandComment|TParamCommandComment" + r"|InlineCommandComment|VerbatimBlockComment|VerbatimBlockLineComment|VerbatimLineComment" + r"|HTMLStartTagComment|HTMLEndTagComment)\b") + +# The declaration a comment is attached to — the next decl node after the comment subtree. +# Clang dumps a doc comment as the first child of the declaration it documents, so "the next +# decl below this FullComment" is its owner. Validated on a real TU: 206 of 207 comments +# resolved, split 17 class / 155 method / 28 field / 6 constructor. +# The name is the LAST identifier before the type/qualifier tail, not the first token after the +# source range — a non-greedy `\b(\w+)\b` there captures `col` out of `<line:299:1, col:7> col:7 +# implicit referenced class ControlList`, which is how the first version reported every row as +# "col". Anchoring on the trailing keyword (`class`/`struct`/`union`) or the quoted type is what +# makes it the declaration's own name. +_OWNER = re.compile( + r"(?P<kind>CXXRecordDecl|CXXMethodDecl|CXXConstructorDecl|CXXDestructorDecl" + r"|CXXConversionDecl|FieldDecl" + r"|FunctionDecl|VarDecl|EnumDecl|EnumConstantDecl|TypedefDecl|TypeAliasDecl) 0x\w+[^<]*" + r"<(?:[^:>]*:)?(?:line:)?(?P<line>\d+):\d+[^>]*>" + # A record's name follows its keyword and may be trailed by `definition`; a `[^\n]*?` before + # the keyword would let the tail win, so the trailing token is matched explicitly. Without + # this, every `class Foo … definition` line reported its name as "definition". + r"(?:[^\n]*?\b(?:class|struct|union|enum)\s+(?P<cname>\w+)(?:\s+definition)?\s*$" + r"|[^\n]*?(?P<fname>~?(?:operator\s*\S+|\w+))\s+')", re.M) + +# A DIRECT child of the block head: exactly one tree connector before the node name. A deeper +# node (`| |-`, `| `-`) belongs to a nested declaration, which clang-query emits as its own +# bound block — so depth is what stops a class absorbing every comment its members carry. +_TOP_CHILD = re.compile(r"^[|`]-") + +# A record declaration head, for the forward-declaration test above. Only records can be +# forward-declared; a method or attribute always has its definition where it is declared. +_RECORD_HEAD = re.compile(r"^(?:CXXRecordDecl|ClassTemplateDecl) ") + +# The file a dumped node belongs to. The AST dump prints an absolute path only when it CHANGES, +# then `line:N` for subsequent nodes in the same file — so the current file has to be tracked +# as the dump is walked, not read off each node. +_PATH_HINT = re.compile(r"<(?P<path>/[^:>]*\.(?:h|cpp|hpp|cc)):(?P<line>\d+):") + +# `<line:N:C>` with no path — same file, new line. Feeds the line that a `col:`-only span inherits. +_LINE_HINT = re.compile(r"<line:(?P<line>\d+):\d+") + +# What each owner kind counts as, for the per-scope split. A constructor is a method; an enum or +# a typedef is neither class nor method, so it reports as `other` rather than being dropped — +# a scope nobody expected is a finding, not a reason to hide the row. +_SCOPE = { + "CXXRecordDecl": "class", "EnumDecl": "class", + "EnumConstantDecl": "attribute", + "CXXMethodDecl": "method", "CXXConstructorDecl": "method", + "CXXDestructorDecl": "method", "CXXConversionDecl": "method", "FunctionDecl": "method", + "FieldDecl": "attribute", "VarDecl": "attribute", +} + +# What "documented well" looks like, in WORDS, per declaration kind. +# +# Words rather than lines: a line is a formatting accident — the same paragraph is 5 lines at 100 +# columns and 9 at 60 — while the word count is what a reader actually absorbs. Measured on this +# tree, a comment line carries a median of 13 words in BOTH kinds, so a line-based yardstick of +# class 10 / method 3 / attribute 3 converts to 130 and 40. +# +# A class carries the spec: what it is for, how it fits, what a caller must know. A method or an +# attribute is one idea. This is a ruler, not a gate — MoonI80Peripheral's header may be exactly +# right at ten times the ideal, because it IS the driver's spec. +_IDEAL_DOC_WORDS = {"class": 130, "method": 40, "attribute": 40, "other": 40} + +# The `//` side deliberately has NO ideal, because zero IS the ideal: a developer note stacked on +# a declaration is usually a thought that belongs in the code below it. Measuring it as +# deviation-from-one-line made the best case (no note at all) read as -100%, i.e. worst — so DEV +# is reported as a raw word count. Zero reads as zero. + # Three constraints this matcher set was built around. Recorded here because two are NEGATIVE # results — the kind that gets re-attempted by whoever forgets they were already tried: # @@ -141,6 +238,32 @@ "output": "dump", "matcher": ('fieldDecl(hasType(cxxRecordDecl(hasName("ScratchBuffer"))))'), }, + "comments": { + "title": "Comments per declaration (scope x kind)", + "output": "dump", + # `shadow: True` parses a copy of src/ where every leading `//` became `/// MMDEV: …`, + # so ONE pass reports both kinds against the declaration each documents. Without it this + # rule sees only `///` — 24% of the tree's comment lines. + "shadow": True, + # The scopes that can actually carry a comment: class/struct, method, and class ATTRIBUTE. + # parmVarDecl is deliberately absent — a C++ parameter cannot hold a doc comment (1054 + # probed, zero with one), it is documented via `@param` in the method's own comment, and + # this tree uses `@param` 8 times, all in a .js file. Reporting 1054 permanent violations + # would bury every other row. + # `unless(isLambda())` on the record, and `unless(ofClass(isLambda()))` on the method: + # a lambda written INSIDE a function body is code, not a documented declaration, and + # its compiler-synthesised closure class + call operator would otherwise be reported + # as 53 undocumented "methods". The AST knows the difference — this asks it, rather + # than testing the name, so a REAL `operator()` on a named functor class is still + # reported. + "matcher": ("namedDecl(anyOf(cxxRecordDecl(unless(isLambda())), " + "cxxMethodDecl(unless(ofClass(cxxRecordDecl(isLambda())))), " + "cxxConstructorDecl(), cxxDestructorDecl(), cxxConversionDecl(), " + "fieldDecl(), enumDecl(), " + "enumConstantDecl(), " + "functionDecl(unless(cxxMethodDecl(ofClass(cxxRecordDecl(isLambda())))))), " + 'unless(isExpansionInSystemHeader())).bind("d")'), + }, "heap": { "title": "Heap allocation sites", # `dump`, not `diag`: diag gives a location with no expression and print gives an @@ -158,7 +281,13 @@ # says which code allocates rather than only which line. clang-query then emits two # blocks per match ("fn" then "root"), which is why collect_heap parses per-match. "matcher": ("stmt(anyOf(cxxNewExpr(), cxxDeleteExpr(), callExpr(callee(functionDecl(" + # The project's OWN allocator belongs here, not just libc's: platform::alloc + # is how 35 sites in src/ acquire memory, and listing only `free` made the + # table read as "8 frees, 0 allocations" on HueDriver — an implied leak that + # was not there. allocInternal pairs with the ordinary free() (platform.h:63); + # allocExec/freeExec are their own pair. 'hasAnyName("malloc","calloc","realloc","free","strdup",' + '"alloc","allocInternal","allocExec","freeExec",' '"heap_caps_malloc","heap_caps_calloc","heap_caps_realloc","heap_caps_free",' '"ps_malloc","ps_calloc"), unless(cxxMethodDecl()))))), ' 'hasAncestor(functionDecl().bind("fn")))'), @@ -230,6 +359,57 @@ def _source_tus(build_dir): return sorted({e["file"] for e in db if "/src/" in e["file"].replace("\\", "/")}) +# The marker a `//` comment carries once it has been rewritten into a doc comment for parsing. +# Plain text on purpose: an `@`-prefixed marker is parsed as a doc COMMAND and stripped from the +# text, so `@MMDEV` vanishes and the two kinds become indistinguishable again. This survives. +_DEV_MARK = "MMDEV:" + +# Leading `//` only — never `///` (already a doc comment) and never a trailing `x = 1; // note` +# (it documents nothing, and rewriting it would change the code line). +_LEADING_DEV = re.compile(r"^([ \t]*)//(?!/)( ?)", re.M) + + +def _shadow_tree(build_dir): + """A copy of `src/` where every leading `//` is a MARKED doc comment. + + Clang's lexer DISCARDS `//`: only `///` and `/** */` become AST nodes, because the compiler + consumes those itself. That leaves 76% of this tree's comment lines invisible to any matcher. + Rewriting them into `/// MMDEV: …` makes them parse, and the marker keeps them separable from + real doc comments — so one AST pass can report BOTH kinds against the declaration each one + documents, which is the whole point (a `//` on a class is a different finding from a `///`). + + The rewrite happens in a SHADOW COPY under the build dir; `src/` is never touched. That is the + line between this and a bad idea: the report describes the real tree, and the only thing the + marker changes is which lexer bucket a comment lands in. + + Two things probed and settled, recorded so they are not re-derived: + - `-Wdocumentation` WOULD fabricate warnings on rewritten dev notes (a `// @param wrong` + becomes a real diagnostic). It does not matter here: clang-query never enables it. + - Function PARAMETERS cannot carry a doc comment in C++ at all — 1054 ParmVarDecls in one TU, + zero with a comment, and `@param` appears 8 times in the tree (all in a .js file). Class + ATTRIBUTES (FieldDecl) are the per-member scope that does exist, and they are reported. + """ + shadow = build_dir / "comment-shadow" + # Reused within a run: the comments rule and the visibility pass both need it, and rebuilding + # (rmtree + copytree + rewrite of all of src/) twice per run bought nothing — the source + # cannot change mid-run. A stale tree from a PREVIOUS run would be wrong, so the marker file + # records which source state it was built from. + stamp = shadow / ".built-from" + newest = max((f.stat().st_mtime for f in (ROOT / "src").rglob("*") if f.is_file()), default=0) + if stamp.exists() and stamp.read_text(encoding="utf-8").strip() == str(newest): + return shadow + if shadow.exists(): + shutil.rmtree(shadow) + shutil.copytree(ROOT / "src", shadow / "src") + for f in list((shadow / "src").rglob("*.h")) + list((shadow / "src").rglob("*.cpp")): + text = f.read_text(encoding="utf-8", errors="replace") + marked = _LEADING_DEV.sub(rf"\1/// {_DEV_MARK}\2", text) + if marked != text: + f.write_text(marked, encoding="utf-8") + stamp.write_text(str(newest), encoding="utf-8") + return shadow + + def _run_rule(rule, tus, build_dir, tool): """Run one matcher over every TU, in parallel. @@ -243,12 +423,29 @@ def _run_rule(rule, tus, build_dir, tool): cmd = [tool, "-p", str(build_dir), "-f", str(qfile)] cmd += [f"--extra-arg={a}" for a in check_clang_tidy._toolchain_args()] + # The comments rule parses the shadow tree instead, so `//` is visible. The include path is + # prepended so a TU's `#include "core/Foo.h"` resolves to the rewritten header, not the real + # one — without it only the .cpp itself would be marked and every header would read as clean. + shadow = None + if rule.get("shadow"): + shadow = _shadow_tree(build_dir) + # `--extra-arg-before`, NOT `--extra-arg`: the latter APPENDS, so the compile + # database's own `-I…/src` was searched first and every transitive include resolved + # to the REAL, unmarked header. Only first-level quoted includes hit the shadow, so + # `//` counts were a systematic undercount — a silent zero for most shared headers. + cmd += [f"--extra-arg-before=-I{shadow / 'src'}"] + tus = [str(shadow / Path(tu).relative_to(ROOT)) for tu in tus] + def one(tu): p = subprocess.run(cmd + [tu], cwd=ROOT, capture_output=True, text=True) return p.stdout + p.stderr with ThreadPoolExecutor(max_workers=max(1, (os.cpu_count() or 4) - 2)) as ex: - return "".join(ex.map(one, tus)) + out = "".join(ex.map(one, tus)) + if shadow: + # Paths in the dump point into the shadow; map them back so a row is clickable. + out = out.replace(str(shadow / "src"), str(ROOT / "src")) + return out def _rel(path): @@ -408,6 +605,223 @@ def render_scratch(rows, max_rows=0): return L + note +# Access is NOT in the AST dump's declaration line — clang prints `private` only as a separate +# AccessSpecDecl node, which a per-block parse never sees. So visibility comes from a second, +# cheap matcher pass listing the non-public declarations by file:line, and rows are tagged +# against that set. +# +# Why report private members at all, rather than filtering them out: doxygen never publishes +# them, so the "it should reach the module page" argument does not apply — but a bloated comment +# is still bloat, and clangd's hover shows it to whoever maintains the code. The VIS column lets +# a reader weigh a finding (a public method with no `///` is an API gap; a private one is a +# maintenance note) without dropping half the tree from the report. +_NONPUBLIC_MATCHER = ("namedDecl(anyOf(cxxRecordDecl(), cxxMethodDecl(), cxxConstructorDecl(), " + "cxxDestructorDecl(), cxxConversionDecl(), fieldDecl(), enumDecl(), " + "enumConstantDecl(), functionDecl()), " + "anyOf(isPrivate(), isProtected()), " + 'unless(isExpansionInSystemHeader())).bind("d")') + + +def collect_nonpublic(out): + """The (file, line) of every private/protected declaration in the dump.""" + seen = set() + cur_path = None + for block in out.split('Binding for "d":')[1:]: + head = next((l for l in block.split("\n") if l.strip()), "") + hint = _PATH_HINT.search(head) + if hint: + cur_path = hint["path"] + rel = _rel(cur_path) if cur_path else None + owner = _OWNER.search(head) + if rel and owner: + seen.add((rel, int(owner["line"]))) + return seen + + +def collect_comments(out): + """Every declaration, with the WORDS of `///` and `//` attached to it. + + Parsed per BOUND MATCH: `.bind("d")` makes clang-query emit one block per matched declaration, + headed by that declaration, so the owner is stated rather than inferred. Inferring it + positionally fails two ways — a class match dumps its whole subtree (so a member's comment + hangs off the class), and a trailing `///<` attaches to the declaration BEFORE it. + + Only a DIRECT child of the block head is this declaration's own comment; a deeper FullComment + belongs to a nested declaration, which has its own block. + + Deduplicated by (file, declaration line), and sizes are a MAX rather than a sum: a header + parsed by several TUs yields the same comment once per TU, and adding them reported a + 129-line class comment as 258. + """ + rows = {} + cur_path = None + for block in out.split('Binding for "d":')[1:]: + lines = block.split("\n") + head = next((l for l in lines if l.strip()), "") + + hint = _PATH_HINT.search(head) + if hint: + cur_path = hint["path"] + rel = _rel(cur_path) if cur_path else None + owner = _OWNER.search(head) + if not rel or not owner: + continue + # `implicit` — a lambda's closure class or a compiler-injected self-reference: anonymous, + # so the dump's only "name" is the literal word "definition". + # `invalid` — a declaration clang could not fully parse in THIS TU (it parses fine in the + # TU that owns it). Same anonymous shape, and it would overwrite the good row. + if " implicit " in head or " invalid " in head: + continue + # A record with no body is a FORWARD DECLARATION (`class JsonSink;`) — there is nothing + # to document, and the real class is reported from the header that defines it. Clang + # marks a defined record with a trailing `definition`; without it, the two `class Foo;` + # lines at the top of every module header showed up as undocumented classes. + if _RECORD_HEAD.match(head) and not head.rstrip().endswith("definition"): + continue + + + # Both kinds accumulate: a `///` spec and a `//` note can sit on the same method, and + # keeping only whichever the dump listed first would hide half of it. A declaration with + # neither still earns a row — "undocumented" is the other half of the question. + seen = {"///": 0, "//": 0} + first_line = None + for k, line in enumerate(lines): + if "FullComment" not in line or not _TOP_CHILD.match(line): + continue + fc = _FULLCOMMENT.search(line) + if not fc: + continue + # Three span forms, and the PATH one is not optional: the dump prints a full path + # whenever the comment is the first node from a new file, which is exactly the case + # for a declaration whose doc comment opens a header. Missing it reported two + # correctly-documented methods as having no comment at all. + begin = int(fc["pstart"] or fc["start"] or owner["line"]) + body = "" + for sub in lines[k + 1:]: + tc = _TEXTCOMMENT.search(sub) + if tc: + body += tc["text"] + " " + continue + # The subtree is not flat — ParagraphComment and friends wrap the text — so the + # scan ends only at a node that is not part of a comment at all. + if not _COMMENT_NODE.search(sub): + break + kind = "//" if _DEV_MARK in body else "///" + # The marker is not prose: strip it before counting, or every rewritten `//` block + # would read one word longer per line than it is on disk. + words = len(body.replace(_DEV_MARK, " ").split()) + seen[kind] = max(seen[kind], words) + if first_line is None: + first_line = begin + + # `= default` / `= delete` have no body to document — reporting them as undocumented is + # noise. They still count when they DO carry a comment. + if not seen["///"] and not seen["//"]: + if " default" in head or " delete" in head: + continue + + scope = _SCOPE.get(owner["kind"], "other") + ideal = _IDEAL_DOC_WORDS.get(scope, _IDEAL_DOC_WORDS["other"]) + # MAX across TUs, not last-writer-wins. A header is parsed once per TU that includes it, + # and a plain assignment let a later TU that saw fewer words overwrite an earlier one that + # saw more — the docstring claimed max while the code overwrote. + key = (rel, int(owner["line"])) + prev = rows.get(key) + if prev: + seen["///"] = max(seen["///"], prev["doc_words"]) + seen["//"] = max(seen["//"], prev["dev_words"]) + rows[key] = { + "file": rel, "line": first_line or int(owner["line"]), + # The DECLARATION's line, distinct from `line` (where its comment starts) — the + # visibility pass keys on the declaration, so the two must not be conflated. + "decl_line": int(owner["line"]), + "doc_words": seen["///"], "dev_words": seen["//"], + "scope": scope, + # DOC is a target, so the deviation is a signed percentage: 0% ideal, -100% absent, + # no ceiling. DEV has no target — zero IS the ideal — so `dev_words` above is + # reported raw, with no deviation column at all. + "doc_deviation": 100 * (seen["///"] - ideal) / ideal, + "name": owner["cname"] or owner["fname"] or "?", + } + # Absolute deviation: a bloated header and a missing comment are both wrong, in opposite + # directions, and ranking by |deviation| surfaces the two together rather than burying one. + return sorted(rows.values(), key=lambda r: -abs(r["doc_deviation"])) + + +def render_comments(rows, max_rows=0): + """The DECL x kind matrix, then every declaration ranked by how far it sits from the ideal. + + The project's rule is that every class, method and attribute carries a short readable `///` + — that is what moxydoc publishes — while `//` developer notes belong in the code lines rather + than stacked on a declaration. Three states, three different fixes, so the matrix shows which + way each kind of declaration is leaning. + + Sizes are WORDS, not lines: a line is a formatting accident (the same paragraph is 5 lines at + 100 columns and 9 at 60) while words are what a reader absorbs. Measured here, a comment line + carries a median of 13 words in both kinds. + + DOC DEVIATION is the signed % difference from `_IDEAL_DOC_WORDS`; DEV is an absolute + count because zero is the + ideal there, and a ratio would report the best case (no note at all) as -100%. + """ + L = [f"{len(rows)} found."] + if not rows: + return L + + scopes = ("class", "method", "attribute", "other") + blank = {"///": 0, "//": 0, "none": 0} + counts = {s: dict(blank) for s in scopes} + for r in rows: + c = counts.setdefault(r["scope"], dict(blank)) + if r["doc_words"]: + c["///"] += 1 + elif r["dev_words"]: + c["//"] += 1 + else: + c["none"] += 1 + + ideals = " · ".join(f"{s} {_IDEAL_DOC_WORDS[s]}" for s in ("class", "method", "attribute")) + # Column names match the detail table below, so a reader carries one vocabulary down the + # report rather than mapping "DOC ///" onto "DOC WORDS" halfway through. + 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}"] + for s in scopes: + c = counts.get(s, blank) + total = c["///"] + c["//"] + c["none"] + if total: + L.append(f" {s:<10} {c['///']:>7} {c['//']:>7} {c['none']:>7} " + f"{100 * c['///'] // total:>4}%") + + dev = sum(1 for r in rows if not r["doc_words"] and r["dev_words"]) + none = sum(1 for r in rows if not r["doc_words"] and not r["dev_words"]) + L += ["", f" {dev} declarations carry only a `//` where the rule asks for `///`; " + f"{none} carry no comment at all."] + + name_w = min(max(len(r["name"]) for r in rows), 30) + # The public subset, called out because it is the part that SHIPS as documentation: doxygen + # publishes only public members, so an undocumented public method is an API gap while a + # private one is a maintenance note. Both are reported; the split says which is which. + pub = [r for r in rows if r.get("vis") == "pub"] + if pub: + pub_doc = sum(1 for r in pub if r["doc_words"]) + L += [f" Public surface: {pub_doc} of {len(pub)} documented " + f"({100 * pub_doc // len(pub)}%) — the part doxygen publishes."] + + # DOC DEVIATION sits beside DOC WORDS: the percentage and the number it is derived from + # belong together, and DEV WORDS is a separate measure. "Deviation", not "ratio" — a ratio is + # a bare quotient (2.3x), this is a signed percentage difference from a target. + L += ["", f" {'DOC DEVIATION':>13} {'DOC WORDS':>9} {'DEV WORDS':>9} {'VIS':<4} " + f"{'DECL':<9} {'NAME':<{name_w}} FILE:LINE", + f" {'-' * 13} {'-' * 9} {'-' * 9} {'-' * 4} {'-' * 9} {'-' * name_w} {'-' * 30}"] + shown, note = _truncate(rows, max_rows) + for r in shown: + L.append(f" {r['doc_deviation']:>+12.0f}% {r['doc_words']:>9} {r['dev_words']:>9} " + f"{r.get('vis', '?'):<4} {r['scope']:<9} {r['name'][:name_w]:<{name_w}} " + f"{r['file']}:{r['line']}") + return L + note + + def render_arrays(rows, min_bytes, max_rows=0): over = f" over {min_bytes} bytes" if min_bytes else "" L = [f"{len(rows)} found{over}."] @@ -431,7 +845,7 @@ def render_arrays(rows, min_bytes, max_rows=0): # Which side of the lifecycle a site is on. `realloc` acquires (it can move and grow), so it # sits with the allocations; a bare `free`/`delete` releases. -_RELEASING = ("free()", "delete", "delete[]", "heap_caps_free()") +_RELEASING = ("free()", "freeExec()", "delete", "delete[]", "heap_caps_free()") def render_heap(rows, max_rows=0): @@ -541,12 +955,31 @@ def main(): rows = collect_arrays(out, args.min_bytes) elif name == "scratch": rows = collect_scratch(out) + elif name == "comments": + rows = collect_comments(out) + # A zero here is indistinguishable from a shadow tree that never got included: the + # first version appended `--extra-arg`, so every transitive header resolved to the + # REAL unmarked file and `//` counts silently halved. If NOTHING carries a dev + # comment, the rewrite did not reach the headers — say so instead of reporting it. + if rows and not any(r["dev_words"] for r in rows): + print("[comments] no `//` comment found anywhere — the shadow tree did not reach " + "the headers (include order?). Refusing to report a false zero.", + file=sys.stderr) + return 2 + # Second pass for visibility — see collect_nonpublic. Cheap next to the main run: + # same TUs, a matcher with no comment traversal. + nonpub = collect_nonpublic( + _run_rule({"output": "dump", "matcher": _NONPUBLIC_MATCHER, "shadow": True}, + tus, build_dir, tool)) + for r in rows: + r["vis"] = "priv" if (r["file"], r["decl_line"]) in nonpub else "pub" else: rows = collect_heap(out) if only: rows = [r for r in rows if r["file"] in only] body = (render_arrays(rows, args.min_bytes, args.max_rows) if name == "arrays" else render_scratch(rows, args.max_rows) if name == "scratch" + else render_comments(rows, args.max_rows) if name == "comments" else render_heap(rows, args.max_rows)) print("\n".join(body)) print() diff --git a/moondeck/check/check_module.py b/moondeck/check/check_module.py index 8c42e19c..145c7d08 100644 --- a/moondeck/check/check_module.py +++ b/moondeck/check/check_module.py @@ -36,13 +36,16 @@ sys.path.insert(0, str(HERE)) import check_clang_query # noqa: E402 — the module→files resolver, one owner -# `--all` on lizard and `--max-rows=0` on clang-query: the repo-wide defaults exist to keep a -# 362-row sweep readable, but you scoped to ONE module precisely to see all of its findings. -# Truncating here would hide the tail that scoping was meant to expose (HttpServerModule alone -# has 71 arrays). +# `--all` on lizard ignores the baseline: you scoped to ONE module precisely to see all of its +# findings, not just the ones newer than the freeze. +# +# clang-query keeps its 60-row cap, unlike lizard. It used to run `--max-rows=0` on the same +# reasoning, but the comments rule made a single module's report unreadable — HttpServerModule +# alone prints 212 declarations, and a wall of rows is skimmed rather than read. The cap is per +# TABLE and always announces what it dropped, so the tail is one `--max-rows=0` away. TOOLS = [ ("clang-tidy", ["check_clang_tidy.py"]), - ("clang-query", ["check_clang_query.py", "--max-rows=0"]), + ("clang-query", ["check_clang_query.py"]), ("lizard", ["check_lizard.py", "--all"]), ] diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json index ac85676e..9e085312 100644 --- a/moondeck/moondeck_config.json +++ b/moondeck/moondeck_config.json @@ -17,7 +17,9 @@ "speed": "slow", "help": "compile_tests", "script": "build/build_desktop.py", - "args": ["--tests"] + "args": [ + "--tests" + ] }, { "id": "test_desktop", @@ -277,7 +279,9 @@ "speed": "slow", "help": "build_docs", "script": "docs/build_docs.py", - "args": ["--serve"], + "args": [ + "--serve" + ], "long_running": true, "process_name": "mkdocs" }, diff --git a/src/light/drivers/HueDriver.h b/src/light/drivers/HueDriver.h index 133bc53e..9d452148 100644 --- a/src/light/drivers/HueDriver.h +++ b/src/light/drivers/HueDriver.h @@ -14,13 +14,11 @@ namespace mm { /// driver reads its window of the shared buffer and pushes each light's color to the bridge. Same /// shape as NetworkSendDriver (read a window, send it out), but over the Hue v1 HTTP API not UDP. /// -/// It's HTTP, not a wire protocol (`GET /api/<key>/lights`, `PUT .../lights/<id>/state`), so the -/// rate is bounded by connection churn — each PUT opens a fresh TCP connection (the bridge speaks -/// `Connection: close`), and tick() does at most one PUT every `kPutIntervalMs` (see there) — giving -/// smooth ambient color, not real-time. The shared output Correction applies as on the LED/network -/// drivers, so the brightness slider and color-order preset reach the Hue lights too (brightness -/// 0 → light off). Only color-capable, reachable lights are driven (see `parseLights`); the `room` -/// and `light` dropdowns aim the effect at a subset (see `rebuildDriven`). +/// It's HTTP, not a wire protocol, so the rate is bounded by connection churn — each PUT opens a +/// fresh TCP connection (the bridge speaks `Connection: close`), giving smooth ambient color, not +/// real-time (see `kPutIntervalMs`). The shared output Correction applies as on the LED/network +/// drivers, so brightness and color-order reach the Hue lights too. Only color-capable, reachable +/// lights are driven (`parseLights`); the `room` / `light` dropdowns narrow that (`rebuildDriven`). /// /// **Wire contract (Hue v1 API, plain HTTP, no TLS — bench-confirmed on a BSB002 bridge, API 1.77):** /// - Pair — `POST http://<bridgeIp>/api` `{"devicetype":"projectMM#device"}`; before the link @@ -47,12 +45,12 @@ class HueDriver : public DriverBase { /// The Hue username/app key — filled by the Pair button, then persisted. char appKey[48] = {}; + /// Hue converts to HSV, RGB-fixed, so there is no correction UI to show. + bool hasCorrectionControls() const override { return false; } + /// Register the controls: bridge IP, the persisted app key, the Pair link-button, the room + /// light filter dropdowns (both default to index 0 = "All", rebuilt in place from the parsed /// bridge data on every control change), the shared window, then refresh the status line. - /// Hue converts to HSV, RGB-fixed, no correction UI. - bool hasCorrectionControls() const override { return false; } - void defineDriverControls() override { controls_.addIPv4("bridgeIp", bridgeIp); controls_.addText("appKey", appKey, sizeof(appKey)); // persisted credential @@ -134,10 +132,10 @@ class HueDriver : public DriverBase { DriverBase::release(); } - // Test seam: drive the changed-light diff + PUT formatting without a live bridge — feed a - // light's RGB and get back whether it would PUT + the body it would send. Records the push - // (like pushChangedLights does) so a follow-up call with the same RGB exercises the - // unchanged-skip path. + /// Test seam: drive the changed-light diff + PUT formatting without a live bridge — feed a + /// light's RGB and get back whether it would PUT + the body it would send. Records the push + /// (like pushChangedLights does) so a follow-up call with the same RGB exercises the + /// unchanged-skip path. bool wouldPushForTest(uint8_t idx, uint8_t r, uint8_t g, uint8_t b, char* outBody, size_t cap) { if (!diffAndFormat(idx, r, g, b, outBody, cap)) return false; if (idx < kMaxLights) { @@ -147,33 +145,41 @@ class HueDriver : public DriverBase { return true; } - // Test seam: parse a real /lights JSON body through fetchLights' color-light extractor. + /// Test seam: parse a real /lights JSON body through fetchLights' color-light extractor. void parseLightsForTest(const char* json) { parseLights(json); rebuildDriven(); } - uint8_t lightCountForTest() const { return lightCount_; } // kept color+reachable lights + /// Count of kept color+reachable lights. + uint8_t lightCountForTest() const { return lightCount_; } + /// The bridge light id at window index `i`, or 0 when out of range. uint16_t hueIdForTest(uint8_t i) const { return i < kMaxLights ? hueId_[i] : 0; } + /// The same count as the read-only control / bridge field; 0 before any fetch. int8_t colorCountForTest() const { return colorCount_; } - // Test seam: parse a real /groups JSON body through fetchGroups' Room extractor. Call - // parseLightsForTest FIRST — room membership resolves against the known color lights (hueId_), - // exactly as production order guarantees (fetchGroups runs only after fetchLights). + /// Test seam: parse a real /groups JSON body through fetchGroups' Room extractor. Call + /// parseLightsForTest FIRST — room membership resolves against the known color lights (hueId_), + /// exactly as production order guarantees (fetchGroups runs only after fetchLights). void parseGroupsForTest(const char* json) { parseGroups(json); rebuildDriven(); } - uint8_t roomCountForTest() const { return roomCount_; } // kept Rooms (type=="Room") + /// Count of kept Rooms (bridge groups with type=="Room"). + uint8_t roomCountForTest() const { return roomCount_; } - // Test seams for the room→light filtering. setRoomForTest/setLightForTest mirror what a UI - // Select change does (write the index, then re-derive the driven subset); drivenCountForTest / - // drivenIdForTest report the filtered set pushOneChangedLight walks. + /// Test seams for the room→light filtering: mirror what a UI Select change does — write the + /// index, then re-derive the driven subset. + /// Select room `r` and re-derive the driven subset, as the UI dropdown does. void setRoomForTest(uint8_t r) { room_ = r; if (light_ >= lightOptionCount_) light_ = 0; rebuildDriven(); refreshStatus(); } + /// Select light `l` within the chosen room and re-derive the driven subset. void setLightForTest(uint8_t l) { light_ = l; rebuildDriven(); refreshStatus(); } + /// Recompute the status line without waiting for a tick. void refreshStatusForTest() { refreshStatus(); } + /// How many lights survive the room+light filter — the set pushOneChangedLight walks. uint8_t drivenCountForTest() const { return drivenLightCount_; } + /// The bridge light id at filtered index `i`, or 0 when out of range. uint16_t drivenIdForTest(uint8_t i) const { return i < drivenLightCount_ ? hueId_[drivenIdx_[i]] : 0; } - // Test seam for the RGB→HSV mapping (no bridge needed). + /// Test seam for the RGB→HSV mapping (no bridge needed). static void rgbToHsvForTest(uint8_t r, uint8_t g, uint8_t b, uint16_t& h, uint8_t& s, uint8_t& v) { rgbToHsv(r, g, b, h, s, v); } - // Test seam: the truncation signal fetchLights grows against (a complete /lights body ends '}'). + /// Test seam: the truncation signal fetchLights grows against (a complete /lights body ends '}'). static bool bodyLooksCompleteForTest(const char* body) { return bodyLooksComplete(body); } private: @@ -204,66 +210,89 @@ class HueDriver : public DriverBase { static constexpr uint32_t kHttpTimeoutMs = 200; static constexpr uint32_t kSlowTimeoutMs = 400; + /// The shared layer buffer this driver reads its window from; null until setSourceBuffer. Buffer* sourceBuffer_ = nullptr; - // Per-light Hue id + the last RGB we pushed (the changed-only filter). hueId maps a window - // index → the bridge's light id, learned from GET /api/<key>/lights. + /// Window index → the bridge's light id, learned from GET /api/<key>/lights. Holds ONLY + /// color-capable lights (the bridge's "Extended color light"s) — a dimmable-only white or an + /// on/off plug is skipped, so every window pixel maps to a bulb that can show the full color. uint16_t hueId_[kMaxLights] = {}; + /// The last RGB pushed per light — what the changed-only filter compares against. uint8_t lastRgb_[kMaxLights][3] = {}; - bool sent_[kMaxLights] = {}; // have we pushed this light at least once - // hueId_ holds ONLY color-capable lights (the bridge's "Extended color light"s) — a - // dimmable-only white or an on/off plug is skipped, so every window pixel maps to a bulb - // that can show the effect's full color. lightCount_ is that filtered count. - uint8_t lightCount_ = 0; // number of color-capable lights - int8_t colorCount_ = 0; // same, as the read-only control / bridge field - bool sawLights_ = false; // fetchLights ran → the list is trustworthy - // Friendly names for the dropdowns. Heap, NOT inline: a fixed [kMaxLights][kNameLen] array - // would reserve 768 B whether the bridge has 4 lights or 32 (and cap at 32). Instead one - // contiguous block of (count × kNameLen) is allocated to the ACTUAL light/room count when the - // fetch runs, and freed in release()/release — so memory scales to the real bridge and - // sizeof(HueDriver) stays small (the lightsBuf_ stack-overflow lesson, applied to the names). - char* lightNames_ = nullptr; // kMaxLights × kNameLen; lightNameAt(i) indexes it - char* roomNames_ = nullptr; // kMaxRooms × kNameLen + /// Whether this light has been pushed at least once (the first send is never "unchanged"). + bool sent_[kMaxLights] = {}; + /// How many color-capable lights survived the filter — the length of hueId_. + uint8_t lightCount_ = 0; + /// The same count as the read-only control / bridge field. + int8_t colorCount_ = 0; + /// fetchLights has run, so the list is trustworthy. + bool sawLights_ = false; + /// Friendly light names for the dropdown — `kMaxLights × kNameLen`, indexed by lightNameAt(). + /// Heap, NOT inline: a fixed array would reserve 768 B whether the bridge has 4 lights or 32 + /// (and cap at 32). One contiguous block is allocated to the ACTUAL count when the fetch runs + /// and freed in release(), so memory scales to the real bridge and sizeof(HueDriver) stays + /// small (the lightsBuf_ stack-overflow lesson, applied to the names). + char* lightNames_ = nullptr; + /// Friendly room names, same shape — `kMaxRooms × kNameLen`, indexed by roomNameAt(). + char* roomNames_ = nullptr; + /// Pointer to light `i`'s name inside the lightNames_ block, or null before it is allocated. char* lightNameAt(uint8_t i) { return lightNames_ ? lightNames_ + static_cast<size_t>(i) * kNameLen : nullptr; } + /// Pointer to room `i`'s name inside the roomNames_ block, or null before it is allocated. char* roomNameAt(uint8_t i) { return roomNames_ ? roomNames_ + static_cast<size_t>(i) * kNameLen : nullptr; } - // Allocate the two name blocks lazily on first parse (so an unconfigured driver pays nothing), - // and free them on release / cache reset (so a removed-then-readded bridge starts clean). The - // blocks are sized to the kMax bound, not the live count, because the parser fills them - // incrementally and the count isn't known until it finishes — keeping the names off the - // resident sizeof(HueDriver) is the win (the lightsBuf_ stack-probe lesson), not per-byte fit. + /// Allocate the two name blocks lazily on first parse (so an unconfigured driver pays nothing), + /// and free them on release / cache reset (so a removed-then-readded bridge starts clean). The + /// blocks are sized to the kMax bound, not the live count, because the parser fills them + /// incrementally and the count isn't known until it finishes — keeping the names off the + /// resident sizeof(HueDriver) is the win (the lightsBuf_ stack-probe lesson), not per-byte fit. void ensureNameBuffers() { if (!lightNames_) lightNames_ = static_cast<char*>(platform::alloc(static_cast<size_t>(kMaxLights) * kNameLen)); if (!roomNames_) roomNames_ = static_cast<char*>(platform::alloc(static_cast<size_t>(kMaxRooms) * kNameLen)); } + /// Release both name blocks and null the pointers — a re-add re-fetches and re-allocates. void freeNameBuffers() { platform::free(lightNames_); lightNames_ = nullptr; platform::free(roomNames_); roomNames_ = nullptr; } - // --- Rooms (GET /api/<key>/groups, type=="Room"): name + a color-light membership bitmask. - uint32_t roomMask_[kMaxRooms] = {}; // bit i set ⇔ this Room references color light hueId_[i] - uint8_t roomCount_ = 0; // number of Rooms kept - bool sawGroups_ = false; // fetchGroups ran → the room list is trustworthy - - // --- Filter selection (Select indices, persisted as uint8) and the derived driven subset. - uint8_t room_ = 0; // 0 = "All", else roomName_[room_-1] - uint8_t light_ = 0; // 0 = "All", else the n-th light of the current option list - uint8_t drivenIdx_[kMaxLights] = {}; // color-light array-indices actually driven (after filter) - uint8_t drivenLightCount_ = 0; // size of drivenIdx_ — what pushOneChangedLight walks - - // --- Stable option pointer arrays for the two Selects. addSelect borrows the pointer; these - // live for the driver's lifetime and are refilled in place (pointing into the *Name_ buffers) - // by buildRoomOptions / buildLightOptions on every defineControls. "All" is always index 0. + /// Rooms from GET /api/<key>/groups (type=="Room"): bit i set ⇔ this Room references color + /// light hueId_[i]. + uint32_t roomMask_[kMaxRooms] = {}; + /// Number of Rooms kept. + uint8_t roomCount_ = 0; + /// fetchGroups has run, so the room list is trustworthy. + bool sawGroups_ = false; + + /// Room filter (a Select index, persisted as uint8): 0 = "All", else roomName_[room_-1]. + uint8_t room_ = 0; + /// Light filter: 0 = "All", else the n-th light of the current option list. + uint8_t light_ = 0; + /// Color-light array indices actually driven, after the room+light filter. + uint8_t drivenIdx_[kMaxLights] = {}; + /// Size of drivenIdx_ — the set pushOneChangedLight walks. + uint8_t drivenLightCount_ = 0; + + /// Stable option pointers for the room Select. addSelect BORROWS the pointer, so these live + /// for the driver's lifetime and are refilled in place (pointing into the *Names_ buffers) by + /// buildRoomOptions on every defineControls. "All" is always index 0. const char* roomOptions_[kMaxRooms + 1] = {}; + /// How many entries of roomOptions_ are live. uint8_t roomOptionCount_ = 1; + /// The same borrowed-pointer arrangement for the light Select, refilled by buildLightOptions. const char* lightOptions_[kMaxLights + 1] = {}; + /// How many entries of lightOptions_ are live. uint8_t lightOptionCount_ = 1; - uint8_t pushCursor_ = 0; // round-robin position across the lights - uint8_t drivenCount_ = 0; // lights driven this pass (n); fade-time basis - uint32_t lastPutMs_ = 0; // millis() of the last PUT — the tick() rate gate + /// Round-robin position across the lights — one bounded PUT per tick, not a whole sweep. + uint8_t pushCursor_ = 0; + /// Lights driven this pass (n), the basis for the fade time. + uint8_t drivenCount_ = 0; + /// millis() of the last PUT — the rate gate tick() checks before doing any work. + uint32_t lastPutMs_ = 0; + /// Remaining 1 Hz ticks to keep polling for the link-button press; 0 = not pairing. int pairTicksLeft_ = 0; - uint16_t reportTick_ = 0; // counts tick1s ticks toward kReportEverySec + /// Counts tick1s ticks toward kReportEverySec, for the periodic DevicesModule announce. + uint16_t reportTick_ = 0; + /// The status line shown on the driver's card; refreshStatus() rewrites it in place. char statusBuf_[40] = "unpaired"; // /lights read buffer is sized dynamically in fetchLights (grow-and-retry): a small first try // covers a typical home; it doubles up to the cap only when a bigger bridge's response fills it. @@ -272,10 +301,11 @@ class HueDriver : public DriverBase { static constexpr size_t kLightsBufInitial = 2048; static constexpr size_t kLightsBufMax = 16384; + /// True once a bridge IP has been set — any non-zero octet counts. bool haveBridge() const { return bridgeIp[0] || bridgeIp[1] || bridgeIp[2] || bridgeIp[3]; } - // Does the JSON span [begin, end) contain `key` (e.g. "\"hue\"") — used to read a light's - // capabilities off its state block (a color light has "hue"; the bridge omits it otherwise). + /// Does the JSON span [begin, end) contain `key` (e.g. "\"hue\"") — used to read a light's + /// capabilities off its state block (a color light has "hue"; the bridge omits it otherwise). static bool containsKey(const char* begin, const char* end, const char* key) { const size_t kl = std::strlen(key); for (const char* s = begin; s + kl <= end; s++) @@ -283,11 +313,11 @@ class HueDriver : public DriverBase { return false; } - // Read a `"<key>":"<value>"` string from WITHIN a JSON object span [begin, end) — the - // span-bounded analogue of containsKey, used to grab one light's / one room's "name" without - // matching the first "name" elsewhere in the bridge's big response. Copies the raw value up to - // its closing quote (the bridge's names carry no escapes worth decoding) into out[cap], NUL- - // terminated; leaves out empty if the key isn't in the span. + /// Read a `"<key>":"<value>"` string from WITHIN a JSON object span [begin, end) — the + /// span-bounded analogue of containsKey, used to grab one light's / one room's "name" without + /// matching the first "name" elsewhere in the bridge's big response. Copies the raw value up to + /// its closing quote (the bridge's names carry no escapes worth decoding) into out[cap], NUL- + /// terminated; leaves out empty if the key isn't in the span. static void parseStringIn(const char* begin, const char* end, const char* key, char* out, size_t cap) { if (cap == 0) return; out[0] = 0; @@ -304,14 +334,15 @@ class HueDriver : public DriverBase { } } + /// Format bridgeIp as a dotted quad into `out`, for the HTTP host argument. void bridgeStr(char out[16]) const { std::snprintf(out, 16, "%u.%u.%u.%u", bridgeIp[0], bridgeIp[1], bridgeIp[2], bridgeIp[3]); } - // The single status line, folding what were three separate controls (status / hueStatus / - // colorLights). Shows the pairing state and the light count as driven-of-total: "paired, - // 3-4 lights" = the room/light filter narrowed 4 color lights to 3 driven. When nothing is - // filtered (driven == total) it collapses to the plain count, "paired, 4 lights". + /// Rebuild the single status line, folding what were three separate controls (status / + /// hueStatus / colorLights). Shows the pairing state and the light count as driven-of-total: + /// "paired, 3-4 lights" = the room/light filter narrowed 4 color lights to 3 driven. When + /// nothing is filtered (driven == total) it collapses to the plain count, "paired, 4 lights". void refreshStatus() { if (!appKey[0]) std::snprintf(statusBuf_, sizeof(statusBuf_), "unpaired"); else if (!lightCount_) std::snprintf(statusBuf_, sizeof(statusBuf_), "paired"); @@ -321,7 +352,8 @@ class HueDriver : public DriverBase { setStatus(statusBuf_); } - // --- Pairing: POST /api {"devicetype":"projectMM#<name>"} until the user presses the button. + /// One pairing attempt: POST /api {"devicetype":"projectMM#<name>"} and, if the user has + /// pressed the bridge's link button, keep the app key it returns. void pollPairing() { if (!haveBridge()) { pairTicksLeft_ = 0; std::snprintf(statusBuf_, sizeof(statusBuf_), "set bridge IP first"); setStatus(statusBuf_); return; } char host[16]; bridgeStr(host); @@ -353,7 +385,7 @@ class HueDriver : public DriverBase { } } - // Drop the learned light list + room list + push cache so tick1s re-fetches (bridge/key change). + /// Drop the learned light list + room list + push cache so tick1s re-fetches (bridge/key change). void resetLightCache() { lightCount_ = 0; colorCount_ = 0; @@ -371,10 +403,10 @@ class HueDriver : public DriverBase { rebuildDriven(); // empty caches → empty driven set, until the re-fetch repopulates them } - // A complete /lights response is a JSON object: its last non-whitespace char is '}'. A read cut - // short by a too-small buffer ends mid-content, so this is the truncation signal fetchLights - // grows against. (Not a full JSON validator — the bridge's well-formed body is the contract; - // this only distinguishes "whole" from "cut off".) + /// A complete /lights response is a JSON object: its last non-whitespace char is '}'. A read cut + /// short by a too-small buffer ends mid-content, so this is the truncation signal fetchLights + /// grows against. (Not a full JSON validator — the bridge's well-formed body is the contract; + /// this only distinguishes "whole" from "cut off".) static bool bodyLooksComplete(const char* body) { size_t len = std::strlen(body); while (len > 0 && (body[len - 1] == '\n' || body[len - 1] == '\r' @@ -382,7 +414,7 @@ class HueDriver : public DriverBase { return len > 0 && body[len - 1] == '}'; } - // --- Learn the bridge's light ids (window index → hue id, in id order). + /// --- Learn the bridge's light ids (window index → hue id, in id order). void fetchLights() { char host[16]; bridgeStr(host); char path[80]; std::snprintf(path, sizeof(path), "/api/%s/lights", appKey); @@ -417,10 +449,10 @@ class HueDriver : public DriverBase { } } - // List the bridge in DevicesModule (so it shows alongside discovered WLED/projectMM peers, - // carrying its dimmable-light count for layout sizing). The bridge isn't a UDP-presence - // device, so it's registered explicitly through the static seam — no compile-time core↔light - // dependency beyond the same DevicesModule::active() shape AudioService::latestFrame() uses. + /// List the bridge in DevicesModule (so it shows alongside discovered WLED/projectMM peers, + /// carrying its dimmable-light count for layout sizing). The bridge isn't a UDP-presence + /// device, so it's registered explicitly through the static seam — no compile-time core↔light + /// dependency beyond the same DevicesModule::active() shape AudioService::latestFrame() uses. void reportBridge() { auto* dev = DevicesModule::active(); if (!dev || !haveBridge()) return; @@ -434,14 +466,11 @@ class HueDriver : public DriverBase { dev->upsertHueBridge(bridgeIp, name, static_cast<uint8_t>(colorCount_)); } - // Extract the COLOR-capable, REACHABLE light ids from a /lights JSON body: - // {"1":{…},"5":{…},…}. A color light's object carries a "hue" field in its state; a - // dimmable-only white or an on/off plug does not. A light that's powered off / out of mesh - // reports "reachable":false. We keep only lights that are BOTH color-capable and reachable - // — those are the ones an effect can actually animate right now — so the window maps every - // pixel to a live color bulb. The bridge response (~8 KB / hundreds of fields) exceeds the - // recursive JSON reader's node arena, so this is a lightweight forward scan: spot each - // top-level id key, then keep it iff its object span (up to the next id key) has both. + /// Extract the COLOR-capable, REACHABLE light ids from a /lights body ({"1":{…},"5":{…},…}), + /// so the window maps every pixel to a bulb an effect can animate right now. Color lights + /// carry a "hue" field; a dimmable white or on/off plug does not, and an unpowered light + /// reports "reachable":false. A forward scan, not the recursive JSON reader — the ~8 KB + /// response exceeds its node arena. void parseLights(const char* resp) { ensureNameBuffers(); lightCount_ = 0; @@ -480,10 +509,10 @@ class HueDriver : public DriverBase { rebuildDriven(); // the color-light set changed → re-derive the filtered driven subset } - // --- Learn the bridge's Rooms (GET /api/<key>/groups). Same dynamic grow-and-retry read as - // fetchLights — the /groups body grows with the room+zone count, so size the heap buffer up - // until the response parses whole. fetchGroups runs at 1 Hz, off the render loop, after - // fetchLights (gated by sawGroups_), so this alloc/refetch is never hot-path. + /// --- Learn the bridge's Rooms (GET /api/<key>/groups). Same dynamic grow-and-retry read as + /// fetchLights — the /groups body grows with the room+zone count, so size the heap buffer up + /// until the response parses whole. fetchGroups runs at 1 Hz, off the render loop, after + /// fetchLights (gated by sawGroups_), so this alloc/refetch is never hot-path. void fetchGroups() { char host[16]; bridgeStr(host); char path[80]; std::snprintf(path, sizeof(path), "/api/%s/groups", appKey); @@ -503,11 +532,11 @@ class HueDriver : public DriverBase { } } - // Extract the Rooms from a /groups JSON body: {"1":{"name":"Living","lights":["3","5"], - // "type":"Room",…},…}. Keep only type=="Room" (drop Zones, LightGroups, Entertainment); for - // each, store its name and the light ids its "lights" array references. Same lightweight - // forward scan as parseLights (the response exceeds the recursive reader's node arena): spot - // each top-level id key, then read the object span up to the next id key. + /// Extract the Rooms from a /groups JSON body: {"1":{"name":"Living","lights":["3","5"], + /// "type":"Room",…},…}. Keep only type=="Room" (drop Zones, LightGroups, Entertainment); for + /// each, store its name and the light ids its "lights" array references. Same lightweight + /// forward scan as parseLights (the response exceeds the recursive reader's node arena): spot + /// each top-level id key, then read the object span up to the next id key. void parseGroups(const char* resp) { ensureNameBuffers(); roomCount_ = 0; @@ -540,11 +569,11 @@ class HueDriver : public DriverBase { sawGroups_ = true; } - // Resolve a Room's "lights":["3","5",…] array (within [begin, end)) to a color-light - // membership bitmask: for each listed bridge id, set bit i if it equals a kept color light - // hueId_[i]. Ids the Room lists that aren't color-capable (a white bulb, a plug) simply don't - // match and are dropped. Scans from the "lights" key to the array's ']' so a later array - // (e.g. a Zone's "lights" in a wider scan) can't bleed in. + /// Resolve a Room's "lights":["3","5",…] array (within [begin, end)) to a color-light + /// membership bitmask: for each listed bridge id, set bit i if it equals a kept color light + /// hueId_[i]. Ids the Room lists that aren't color-capable (a white bulb, a plug) simply don't + /// match and are dropped. Scans from the "lights" key to the array's ']' so a later array + /// (e.g. a Zone's "lights" in a wider scan) can't bleed in. uint32_t roomMaskFor(const char* begin, const char* end) const { const char* s = begin; const size_t kl = std::strlen("\"lights\":["); @@ -563,16 +592,13 @@ class HueDriver : public DriverBase { return mask; } - // The color-light array-indices (into hueId_ / lightName_) that the CURRENT room selection - // exposes: room_==0 ("All") → every color light, in order; else only the color lights whose - // id appears in that Room's member list. Writes up to kMaxLights indices into `out`, returns - // the count. The single source of truth both the light-dropdown options and the driven set - // derive from, so the dropdown and the driven subset can never disagree. - // `out` is a reference-to-array, not a bare pointer: the bound then lives in the TYPE, so the - // compiler checks it (a wrong-sized caller is a compile error) instead of trusting the comment. - // With a bare uint8_t* the callee cannot see the caller's size at all, and GCC must assume the - // worst — it warned that these writes could run past the end (-Wstringop-overflow). lightCount_ - // is itself capped at kMaxLights when the lights are parsed, so n never exceeds the array. + /// The color-light indices (into hueId_ / lightNames_) the CURRENT room selection exposes: + /// room_==0 ("All") → every color light in order, else only those in that Room's member list. + /// Writes up to kMaxLights indices into `out` and returns the count. The single source of + /// truth for both the light-dropdown options and the driven set, so the two cannot disagree. + /// + /// `out` is a reference-to-array so the bound lives in the TYPE and the compiler checks it — + /// with a bare `uint8_t*` GCC cannot see the caller's size and warns (-Wstringop-overflow). uint8_t roomColorLights(uint8_t (&out)[kMaxLights]) const { uint8_t n = 0; if (room_ == 0 || room_ > roomCount_) { // "All" (or a stale index) → every color light @@ -585,7 +611,7 @@ class HueDriver : public DriverBase { return n; } - // Rebuild the room dropdown options: {"All", room0, room1, …}, pointing into roomName_. + /// Rebuild the room dropdown options: {"All", room0, room1, …}, pointing into roomName_. void buildRoomOptions() { roomOptions_[0] = "All"; uint8_t n = 1; @@ -593,9 +619,9 @@ class HueDriver : public DriverBase { roomOptionCount_ = n; } - // Rebuild the light dropdown options: {"All", <names of the current room's color lights>}, - // pointing into lightName_. The option count tracks the current room, so the light index - // selects within that narrowed list (index 0 = "All", index k = the k-th listed light). + /// Rebuild the light dropdown options: {"All", <names of the current room's color lights>}, + /// pointing into lightName_. The option count tracks the current room, so the light index + /// selects within that narrowed list (index 0 = "All", index k = the k-th listed light). void buildLightOptions() { lightOptions_[0] = "All"; uint8_t idx[kMaxLights]; @@ -605,10 +631,10 @@ class HueDriver : public DriverBase { lightOptionCount_ = n; } - // Derive drivenIdx_ from the current room+light filter — the subset pushOneChangedLight walks. - // room=All & light=All → every color light (the original behaviour, unchanged). - // room=X → that room's color lights. - // light=Y → just that one light (the Y-th of the current room's list). + /// Derive drivenIdx_ from the current room+light filter — the subset pushOneChangedLight walks. + /// room=All & light=All → every color light (the original behaviour, unchanged). + /// room=X → that room's color lights. + /// light=Y → just that one light (the Y-th of the current room's list). void rebuildDriven() { drivenLightCount_ = 0; uint8_t idx[kMaxLights]; @@ -621,10 +647,10 @@ class HueDriver : public DriverBase { if (pushCursor_ >= drivenLightCount_) pushCursor_ = 0; } - // Push AT MOST ONE changed light per call (the tick() gate already limited the rate). The - // round-robin cursor walks every light over successive calls, so each gets its turn; we - // advance the cursor whether or not this light changed, scanning at most one full lap so an - // all-unchanged frame costs no PUT and returns fast (no blocking I/O on the render loop). + /// Push AT MOST ONE changed light per call (the tick() gate already limited the rate). The + /// round-robin cursor walks every light over successive calls, so each gets its turn; we + /// advance the cursor whether or not this light changed, scanning at most one full lap so an + /// all-unchanged frame costs no PUT and returns fast (no blocking I/O on the render loop). void pushOneChangedLight() { if (!sourceBuffer_ || !sourceBuffer_->data()) return; nrOfLightsType winStart, winLen; @@ -666,17 +692,12 @@ class HueDriver : public DriverBase { // No light changed this lap — nothing to send. Cursor stays put. } - // The changed-only diff + the Hue state body. Returns true (and fills `out`) when light - // `idx`'s RGB differs from the last push (or was never sent). Every driven light is color- - // capable (parseLights keeps only those), so the body carries the full color: on/off, plus - // bri (value) + hue + sat from a textbook RGB→HSV — so a color effect actually animates. - // "transitiontime" is the bridge's built-in fade — the smoothing knob. Set to roughly the - // per-light update interval (a light updates every kPutIntervalMs × lightCount), so the bulb - // glides from its current color to the next instead of snapping. The bridge's default is - // 400 ms (too long for our cadence — it smears and looks frozen); we compute a value matched - // to the actual rate so transitions are smooth but keep up. transitiontime is in deciseconds - // (×100 ms). The Hue standard API tops out ~10 cmd/s — true real-time needs the Entertainment - // API; this is smooth ambient color, the standard API's sweet spot. + /// The changed-only diff + the Hue state body. Returns true (and fills `out`) when light + /// `idx`'s RGB differs from the last push (or was never sent). Every driven light is + /// color-capable (parseLights keeps only those), so the body carries on/off plus bri + hue + + /// sat from a textbook RGB→HSV. `transitiontime` is the bridge's fade: its 400 ms default is + /// too long for our cadence — it smears and looks frozen — so transitionDeciseconds() sizes it + /// to the actual refresh rate. bool diffAndFormat(uint8_t idx, uint8_t r, uint8_t g, uint8_t b, char* out, size_t cap) { if (idx >= kMaxLights) return false; if (sent_[idx] && lastRgb_[idx][0] == r && lastRgb_[idx][1] == g && lastRgb_[idx][2] == b) @@ -690,10 +711,10 @@ class HueDriver : public DriverBase { return true; } - // Fade time matched to how often THIS light is refreshed: with n lights round-robined one - // per kPutIntervalMs, each light's turn comes every (n × kPutIntervalMs) ms. Convert to - // deciseconds and clamp to ≥1 (0 = snap) so the fade lasts about until the next update — - // continuous glide, no visible steps. + /// Fade time matched to how often THIS light is refreshed: with n lights round-robined one + /// per kPutIntervalMs, each light's turn comes every (n × kPutIntervalMs) ms. Convert to + /// deciseconds and clamp to ≥1 (0 = snap) so the fade lasts about until the next update — + /// continuous glide, no visible steps. uint8_t transitionDeciseconds() const { // Use the count actually driven this pass (n = min(lightCount_, window)), not the full // discovered lightCount_ — a partial window refreshes each of its lights sooner, so a @@ -704,8 +725,8 @@ class HueDriver : public DriverBase { return static_cast<uint8_t>(ds < 1 ? 1 : (ds > 30 ? 30 : ds)); } - // Textbook RGB→HSV mapped to Hue's ranges: hue 0..65535 (Hue's 16-bit wheel), sat 0..254, - // val(=bri) 0..254. Integer math, no float — the standard max/min/chroma formulation. + /// Textbook RGB→HSV mapped to Hue's ranges: hue 0..65535 (Hue's 16-bit wheel), sat 0..254, + /// val(=bri) 0..254. Integer math, no float — the standard max/min/chroma formulation. static void rgbToHsv(uint8_t r, uint8_t g, uint8_t b, uint16_t& hueOut, uint8_t& satOut, uint8_t& valOut) { const uint8_t mx = r > g ? (r > b ? r : b) : (g > b ? g : b); const uint8_t mn = r < g ? (r < b ? r : b) : (g < b ? g : b); diff --git a/src/light/drivers/MoonLedDriver.h b/src/light/drivers/MoonLedDriver.h index 1ff8a2dd..40395e58 100644 --- a/src/light/drivers/MoonLedDriver.h +++ b/src/light/drivers/MoonLedDriver.h @@ -274,7 +274,7 @@ class MoonI80Peripheral : public LedPeripheral { const char* initFailMsg() const override { return "MoonI80 bus init failed — check pins / memory"; } /// The expander needs a backend that can stream its ×8 frame; LCD_CAM is it, and this backend is /// LCD_CAM-only, so the answer is simply "wherever this backend runs at all". - bool supportsPinExpander() const override { return platform::lcdLanes > 0; } + bool supportsPinExpander() const override { return platform::hasLcdCam; } /// No async double-buffer on this backend — the own-GDMA two-buffer completion handshake races and /// wedges the bus (see busInit). Single-buffer only; the ring is where MoonI80's speed lives. diff --git a/src/light/drivers/MultiPinLedDriver.h b/src/light/drivers/MultiPinLedDriver.h index 58a61ee7..c86083c5 100644 --- a/src/light/drivers/MultiPinLedDriver.h +++ b/src/light/drivers/MultiPinLedDriver.h @@ -210,7 +210,7 @@ class I80Peripheral : public LedPeripheral { /// the flag on `lcdLanes` (non-zero only on the LCD_CAM chips, S3/P4/S31) makes 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; } /// The bus pin list comes from the orchestrator: in shift mode it appends the latch to the data pins /// (the latch is a bus lane), so the peripheral drives it. busClockMultiplier() tells the platform diff --git a/src/main.cpp b/src/main.cpp index 4fad5140..54b8ada2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -87,19 +87,22 @@ // (ESP_PLATFORM / CONFIG_IDF_TARGET_* / __APPLE__ / …) — check_platform_boundary.py // passes them, by design. The driver bodies themselves keep all hardware behind // the platform seam; this gate only decides which driver headers are present. -#if defined(CONFIG_SOC_RMT_SUPPORTED) +// `|| MM_LINKS_ALL_LED_DRIVERS`: the desktop build links every driver — the rule and its reasons +// live in architecture.md § Platform abstraction. On a real chip the CONFIG_SOC_* gate is +// unchanged, so no board links a driver its silicon cannot run. +#if defined(CONFIG_SOC_RMT_SUPPORTED) || MM_LINKS_ALL_LED_DRIVERS #include "light/drivers/RmtLedDriver.h" #endif // The parallel-WS2812 driver + its peripheral backends. Each backend header self-registers its factory // into ParallelLedDriver's peripheral registry (gated by the chip's CONFIG_SOC_*), so including the ones // this silicon supports is what populates the `peripheral` control's options. -#if defined(CONFIG_SOC_LCD_I80_SUPPORTED) +#if defined(CONFIG_SOC_LCD_I80_SUPPORTED) || MM_LINKS_ALL_LED_DRIVERS #include "light/drivers/MultiPinLedDriver.h" // esp_lcd i80 backend (I80Peripheral) #endif -#if defined(CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED) +#if defined(CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED) || MM_LINKS_ALL_LED_DRIVERS #include "light/drivers/MoonLedDriver.h" // MoonI80 own-GDMA backend (MoonI80Peripheral) #endif -#if defined(CONFIG_SOC_PARLIO_SUPPORTED) +#if defined(CONFIG_SOC_PARLIO_SUPPORTED) || MM_LINKS_ALL_LED_DRIVERS #include "light/drivers/ParlioLedDriver.h" // Parlio backend (ParlioPeripheral) #endif #include "core/HttpServerModule.h" @@ -220,7 +223,7 @@ static void registerModuleTypes() { // Register only the LED drivers this chip's silicon can run (see the gated // includes above) — keeps the type picker honest (no MultiPinLedDriver offered on a // chip without an i80 bus) and the binary lean. -#if defined(CONFIG_SOC_RMT_SUPPORTED) +#if defined(CONFIG_SOC_RMT_SUPPORTED) || MM_LINKS_ALL_LED_DRIVERS mm::ModuleFactory::registerType<mm::RmtLedDriver>("RmtLedDriver", "light/drivers.md#rmtled"); #endif // ParallelLedDriver — ONE driver for the parallel-WS2812 output, whatever the DMA peripheral. The @@ -228,7 +231,7 @@ static void registerModuleTypes() { // peripheral registry when their header is included above (gated by the same CONFIG_SOC_* below), so // the `peripheral` control offers exactly the ones this chip links. Registered once, on any chip that // links at least one parallel backend. -#if defined(CONFIG_SOC_LCD_I80_SUPPORTED) || defined(CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED) || defined(CONFIG_SOC_PARLIO_SUPPORTED) +#if defined(CONFIG_SOC_LCD_I80_SUPPORTED) || defined(CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED) || defined(CONFIG_SOC_PARLIO_SUPPORTED) || MM_LINKS_ALL_LED_DRIVERS mm::ModuleFactory::registerType<mm::ParallelLedDriver>("ParallelLedDriver", "light/drivers.md#parallelled"); #endif mm::ModuleFactory::registerType<mm::HttpServerModule>("HttpServerModule", "core/system.md"); diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index e15b7eca..8e02cc3a 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -17,17 +17,35 @@ constexpr bool hasPsram = true; // (the general isEsp32/isEsp32S3 family flags had no users and were removed). constexpr bool isEsp32P4 = false; -// No RMT peripheral — the RMT LED driver guards on this and is inert on desktop. -constexpr uint8_t rmtTxChannels = 0; - -// No LCD_CAM peripheral — the LCD LED driver guards on this and is inert too. -constexpr uint8_t lcdLanes = 0; - -// No Parlio peripheral — the Parlio LED driver guards on this and is inert too. -constexpr uint8_t parlioLanes = 0; - -// No I2S-i80 peripheral — MultiPinLedDriver's lanesAvailable() reads lcdLanes + i2sLanes; -// both 0 on desktop, so the driver is inert (host tests exercise only its parse/slice math). +// RMT channels the host reports. Non-zero for the same reason as the parallel lane counts: the +// desktop build emulates the peripheral so RmtLedDriver actually RUNS here, rather than guarding +// itself off and leaving its encode + channel-assignment logic testable only on hardware. 4 matches +// the S3 / P4 / S31 TX channel count (the classic ESP32 has 8), so the host exercises the tighter +// of the two real constraints. +constexpr uint8_t rmtTxChannels = 4; + +// Lane counts the parallel backends report on desktop. NOT zero, deliberately: everything in the +// repo runs on the desktop build — the platform layer just has no hardware behind the call. A +// zero here makes every backend's lanesAvailable() report "not my silicon", so ParallelLedDriver +// idles and its ~2500-line body never executes off-device: not runnable, not unit-testable, and +// invisible to every AST-based check. +// +// 16 is the widest real rig (LightCrafter 16), so the host exercises the same lane-splitting and +// bus-rounding arithmetic the hardware does rather than a degenerate 1-lane path. The bus behind +// them is a heap buffer (platform_desktop.cpp § Parallel-WS2812 buses): the driver encodes real +// WS2812 bit patterns into real memory, and only the DMA hand-off is absent. +constexpr uint8_t lcdLanes = 16; + +// hasLcdCam — TRUE on the host, like the lane counts above: the desktop build emulates the +// peripheral rather than declaring itself incapable. Saying false here would leave the pin-expander +// path (a real feature with real config validation) unreachable off-device, which is the same gap +// the zero lane counts used to create. There is no LCD_CAM silicon; there is a memory bus that +// behaves like one. +constexpr bool hasLcdCam = true; +constexpr uint8_t parlioLanes = 16; + +// MultiPinLedDriver's lanesAvailable() reads lcdLanes + i2sLanes, so this stays 0 — otherwise the +// i80 backend would claim 32 lanes, which no real chip offers. constexpr uint8_t i2sLanes = 0; // No I2S microphone — AudioService guards on this and is inert on desktop. The @@ -98,3 +116,11 @@ constexpr bool hasImprov = false; #else #define MM_MOONLIVE_HAS_HOST_JIT 0 #endif + +// MM_LINKS_ALL_LED_DRIVERS — 1 where the build links every LED driver regardless of silicon. +// The desktop host does: the repo's rule is that everything runs there, with the platform layer +// simply having no hardware behind the call. A driver 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. A #define (not constexpr) because it gates `#include`s in main.cpp, which `if constexpr` +// cannot do — and it lives here, not in core, per the platform-boundary rule. +#define MM_LINKS_ALL_LED_DRIVERS 1 diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index c0cf2ebf..42ab8ca6 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -9,6 +9,7 @@ #include <cstring> #include <filesystem> #include <string> +#include <vector> // HostBus frame buffers — the memory-backed parallel bus #include <thread> #include <mutex> #include <condition_variable> @@ -1219,21 +1220,44 @@ void TcpServer::close() { } // --------------------------------------------------------------------------- -// RMT WS2812 — no-op stubs. Desktop has no RMT peripheral; the driver guards -// every call with `if constexpr (platform::rmtTxChannels == 0)` (0 here), so -// these exist only to satisfy the linker and are never reached at runtime. +// RMT WS2812 on the host: accepted and counted, not refused. +// +// Same rule as the parallel buses above (architecture.md § Platform abstraction). Refusing here +// made RmtLedDriver inert off device, so nothing in it could be tested on a host. +// +// RMT is symbol-based rather than buffer-based, so there is nothing to hand back: the driver owns +// the symbol array and this seam only has to accept it. The resolution is echoed so the driver's +// timing arithmetic (which divides by it) works on real numbers instead of zero. // --------------------------------------------------------------------------- -bool rmtWs2812Init(RmtWs2812Handle& /*h*/, uint8_t /*gpio*/, uint32_t /*resolutionHz*/, +namespace { +struct HostRmt { uint32_t resolutionHz = 0; }; +HostRmt* hostRmt(void*& impl) { + if (!impl) impl = new HostRmt(); + return static_cast<HostRmt*>(impl); +} +} // namespace + +bool rmtWs2812Init(RmtWs2812Handle& h, uint8_t /*gpio*/, uint32_t resolutionHz, bool /*invert*/) { - return false; + // A zero resolution would make the driver divide by zero when it converts nanoseconds to + // ticks — refuse it here rather than hand back a channel that cannot be used. + if (resolutionHz == 0) return false; + hostRmt(h.impl)->resolutionHz = resolutionHz; + return true; } -uint32_t rmtWs2812Resolution(const RmtWs2812Handle& /*h*/) MM_NONBLOCKING { return 0; } -bool rmtWs2812Transmit(RmtWs2812Handle& /*h*/, const uint32_t* /*symbols*/, - size_t /*symbolCount*/) { - return false; +uint32_t rmtWs2812Resolution(const RmtWs2812Handle& h) MM_NONBLOCKING { + return h.impl ? static_cast<HostRmt*>(h.impl)->resolutionHz : 0; +} +bool rmtWs2812Transmit(RmtWs2812Handle& h, const uint32_t* symbols, + size_t symbolCount) { + if (!h.impl || !symbols || symbolCount == 0) return false; + return true; } void rmtWs2812Wait(RmtWs2812Handle& /*h*/, uint32_t /*timeoutMs*/) {} -void rmtWs2812Deinit(RmtWs2812Handle& /*h*/) {} +void rmtWs2812Deinit(RmtWs2812Handle& h) { + delete static_cast<HostRmt*>(h.impl); + h.impl = nullptr; +} size_t rmtWs2812RxCapture(uint8_t /*gpio*/, uint32_t /*resolutionHz*/, uint32_t* /*outSymbols*/, size_t /*maxSymbols*/, uint32_t /*timeoutMs*/) { @@ -1253,22 +1277,80 @@ RmtLoopbackResult ws2812LoopbackRide(uint16_t /*rxGpio*/, const uint8_t* /*sent* } // --------------------------------------------------------------------------- -// LCD_CAM WS2812 — no-op stubs. Desktop has no i80 peripheral; the LCD LED -// driver guards every call with `if constexpr (platform::lcdLanes == 0)` -// (0 here), so these exist only to satisfy the linker. +// Parallel-WS2812 buses on desktop: REAL MEMORY, no silicon. +// +// The repo's rule is that everything runs on the desktop build — the platform layer simply has +// no hardware behind the call. These used to return false/nullptr, which made every parallel +// backend report failure, so ParallelLedDriver's ~2500-line body never executed off-device: not +// runnable, not unit-testable, and invisible to every AST-based check. +// +// So the bus is implemented against a heap buffer. `init` allocates and zeroes, `Buffer` hands +// back writable memory, `Transmit` records the byte count, `Wait` returns immediately. Everything +// ABOVE the seam is then the same code that runs on hardware — the driver encodes real WS2812 bit +// patterns into a real buffer — and only the DMA hand-off is absent. +// +// What is deliberately NOT modelled: timing, wire protocol, pin state, and loopback capture. +// Those need silicon, and faking them would make the driver's self-test lie about hardware it +// never touched. // --------------------------------------------------------------------------- -bool i80Ws2812Init(I80Ws2812Handle& /*h*/, const uint16_t* /*dataPins*/, +namespace { + +/// One memory-backed parallel bus. Shared by the i80, MoonI80 and Parlio seams below — they are +/// three DMA peripherals for the same job, and off-device the job is "hold a frame". +struct HostBus { + std::vector<uint8_t> buf[2]; + size_t capacity = 0; + + bool init(size_t bytes, bool wantSecond) { + if (bytes == 0) return false; + capacity = bytes; + buf[0].assign(bytes, 0); + if (wantSecond) buf[1].assign(bytes, 0); + else buf[1].clear(); + return true; + } + uint8_t* buffer(uint8_t i) { + if (i > 1 || buf[i].empty()) return nullptr; + return buf[i].data(); + } + bool transmit(uint8_t i, size_t bytes) { + if (i > 1 || buf[i].empty() || bytes > capacity) return false; + return true; + } +}; + +HostBus* hostBus(void*& impl) { + if (!impl) impl = new HostBus(); + return static_cast<HostBus*>(impl); +} +void freeHostBus(void*& impl) { + delete static_cast<HostBus*>(impl); + impl = nullptr; +} + +} // namespace + +bool i80Ws2812Init(I80Ws2812Handle& h, const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, uint16_t /*wrGpio*/, uint16_t /*dcGpio*/, - size_t /*bufferBytes*/, bool /*wantSecondBuffer*/, + size_t bufferBytes, bool wantSecondBuffer, uint8_t /*clockMultiplier*/) { - return false; + if (bufferBytes == 0) return false; // refuse before allocating, as the RMT seam does + return hostBus(h.impl)->init(bufferBytes, wantSecondBuffer); +} +uint8_t* i80Ws2812Buffer(const I80Ws2812Handle& h, uint8_t buffer) { + return h.impl ? static_cast<HostBus*>(h.impl)->buffer(buffer) : nullptr; +} +size_t i80Ws2812BufferCapacity(const I80Ws2812Handle& h) { + return h.impl ? static_cast<HostBus*>(h.impl)->capacity : 0; } -uint8_t* i80Ws2812Buffer(const I80Ws2812Handle& /*h*/, uint8_t /*buffer*/) { return nullptr; } -size_t i80Ws2812BufferCapacity(const I80Ws2812Handle& /*h*/) { return 0; } -bool i80Ws2812Transmit(I80Ws2812Handle& /*h*/, uint8_t /*buffer*/, size_t /*bytes*/) { return false; } +bool i80Ws2812Transmit(I80Ws2812Handle& h, uint8_t buffer, size_t bytes) { + return h.impl && static_cast<HostBus*>(h.impl)->transmit(buffer, bytes); +} +// True, not false: the driver reads a false as "the previous frame never completed" and holds +// the next one back, which would stall the render path on a bus that is never busy. bool i80Ws2812Wait(I80Ws2812Handle& /*h*/, uint8_t /*buffer*/, uint32_t /*timeoutMs*/) { return true; } uint32_t i80Ws2812LastTransmitUs(const I80Ws2812Handle& /*h*/) { return 0; } -void i80Ws2812Deinit(I80Ws2812Handle& /*h*/) {} +void i80Ws2812Deinit(I80Ws2812Handle& h) { freeHostBus(h.impl); } RmtLoopbackResult i80Ws2812Loopback(const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, uint16_t /*wrGpio*/, uint16_t /*dcGpio*/, uint16_t /*rxGpio*/, const uint8_t* /*frame*/, @@ -1277,14 +1359,15 @@ RmtLoopbackResult i80Ws2812Loopback(const uint16_t* /*dataPins*/, uint8_t /*lane return {}; // not supported off the S3 } -// MoonI80 (our own LCD_CAM DMA driver, ADR-0014) — no-op stubs, same as the esp_lcd-backed family -// above. Desktop has no LCD_CAM, so the driver instantiates (lanesAvailable() == 0) and idles, which -// is what lets its config/validation half be tested on the host. -bool moonI80Ws2812Init(MoonI80Ws2812Handle& /*h*/, const uint16_t* /*dataPins*/, +// MoonI80 (our own LCD_CAM DMA driver, ADR-0014) — the same memory-backed bus as the esp_lcd +// family above. The RING path stays inert: it is a GDMA construct with no host equivalent, so a +// driver that would stream on device runs whole-frame here (busInitRing returns false and the +// orchestrator falls back, exactly as its contract specifies). +bool moonI80Ws2812Init(MoonI80Ws2812Handle& h, const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, uint16_t /*wrGpio*/, - size_t /*bufferBytes*/, bool /*wantSecondBuffer*/, + size_t bufferBytes, bool wantSecondBuffer, uint8_t /*clockMultiplier*/) { - return false; + return hostBus(h.impl)->init(bufferBytes, wantSecondBuffer); } // Ring mode is a GDMA construct with no host equivalent — inert here, bench-verified on the S3, exactly // like the whole-frame path above. A driver that would pick the ring on device stays whole-frame on host. @@ -1301,13 +1384,19 @@ void moonI80Ws2812PrimeRange(MoonI80Ws2812Handle& /*h*/, uint8_t /*bufLo*/, uint bool moonI80Ws2812ArmRing(MoonI80Ws2812Handle& /*h*/) { return false; } bool moonI80Ws2812IsRing(const MoonI80Ws2812Handle& /*h*/) { return false; } bool moonI80Ws2812InternalFits(size_t /*bytes*/) { return false; } -uint8_t* moonI80Ws2812Buffer(const MoonI80Ws2812Handle& /*h*/, uint8_t /*buffer*/) { return nullptr; } -size_t moonI80Ws2812BufferCapacity(const MoonI80Ws2812Handle& /*h*/) { return 0; } -bool moonI80Ws2812Transmit(MoonI80Ws2812Handle& /*h*/, uint8_t /*buffer*/, size_t /*bytes*/) { return false; } +uint8_t* moonI80Ws2812Buffer(const MoonI80Ws2812Handle& h, uint8_t buffer) { + return h.impl ? static_cast<HostBus*>(h.impl)->buffer(buffer) : nullptr; +} +size_t moonI80Ws2812BufferCapacity(const MoonI80Ws2812Handle& h) { + return h.impl ? static_cast<HostBus*>(h.impl)->capacity : 0; +} +bool moonI80Ws2812Transmit(MoonI80Ws2812Handle& h, uint8_t buffer, size_t bytes) { + return h.impl && static_cast<HostBus*>(h.impl)->transmit(buffer, bytes); +} bool moonI80Ws2812Wait(MoonI80Ws2812Handle& /*h*/, uint8_t /*buffer*/, uint32_t /*timeoutMs*/) { return true; } uint32_t moonI80Ws2812LastTransmitUs(const MoonI80Ws2812Handle& /*h*/) { return 0; } MoonI80RingStats moonI80Ws2812RingStats(const MoonI80Ws2812Handle& /*h*/) { return {}; } -void moonI80Ws2812Deinit(MoonI80Ws2812Handle& /*h*/) {} +void moonI80Ws2812Deinit(MoonI80Ws2812Handle& h) { freeHostBus(h.impl); } RmtLoopbackResult moonI80Ws2812Loopback(const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, uint16_t /*wrGpio*/, uint16_t /*rxGpio*/, const uint8_t* /*frame*/, @@ -1323,19 +1412,25 @@ RmtLoopbackResult moonI80Ws2812LoopbackRide(uint16_t /*rxGpio*/, const uint8_t* return {}; // not supported off LCD_CAM } -// Parlio WS2812 — no-op stubs. Desktop has no Parlio peripheral; the driver -// idles (parlioLanes == 0). Sizing/slicing is host-pinned by the driver tests. -bool parlioWs2812Init(ParlioWs2812Handle& /*h*/, const uint16_t* /*dataPins*/, - uint8_t /*laneCount*/, uint32_t /*pclkHz*/, size_t /*bufferBytes*/, - bool /*wantSecondBuffer*/) { - return false; +// Parlio WS2812 — the same memory-backed bus. No Parlio silicon here, but the driver runs and +// its sizing/slicing is host-pinned by the driver tests. +bool parlioWs2812Init(ParlioWs2812Handle& h, const uint16_t* /*dataPins*/, + uint8_t /*laneCount*/, uint32_t /*pclkHz*/, size_t bufferBytes, + bool wantSecondBuffer) { + return hostBus(h.impl)->init(bufferBytes, wantSecondBuffer); +} +uint8_t* parlioWs2812Buffer(const ParlioWs2812Handle& h, uint8_t buffer) { + return h.impl ? static_cast<HostBus*>(h.impl)->buffer(buffer) : nullptr; +} +size_t parlioWs2812BufferCapacity(const ParlioWs2812Handle& h) { + return h.impl ? static_cast<HostBus*>(h.impl)->capacity : 0; +} +bool parlioWs2812Transmit(ParlioWs2812Handle& h, uint8_t buffer, size_t bytes) { + return h.impl && static_cast<HostBus*>(h.impl)->transmit(buffer, bytes); } -uint8_t* parlioWs2812Buffer(const ParlioWs2812Handle& /*h*/, uint8_t /*buffer*/) { return nullptr; } -size_t parlioWs2812BufferCapacity(const ParlioWs2812Handle& /*h*/) { return 0; } -bool parlioWs2812Transmit(ParlioWs2812Handle& /*h*/, uint8_t /*buffer*/, size_t /*bytes*/) { return false; } bool parlioWs2812Wait(ParlioWs2812Handle& /*h*/, uint8_t /*buffer*/, uint32_t /*timeoutMs*/) { return true; } uint32_t parlioWs2812LastTransmitUs(const ParlioWs2812Handle& /*h*/) { return 0; } -void parlioWs2812Deinit(ParlioWs2812Handle& /*h*/) {} +void parlioWs2812Deinit(ParlioWs2812Handle& h) { freeHostBus(h.impl); } RmtLoopbackResult parlioWs2812Loopback(const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, uint16_t /*rxGpio*/, const uint8_t* /*frame*/, size_t /*frameBytes*/, size_t /*dataBytes*/, diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index 2f2d4511..de60cbc4 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -98,6 +98,11 @@ constexpr uint8_t lcdLanes = 16; constexpr uint8_t lcdLanes = 0; #endif +// hasLcdCam — is this LCD_CAM silicon (S3/P4/S31)? Separate from the lane COUNT because the host +// sets a non-zero count so the parallel driver RUNS there against a memory bus, while having no +// LCD_CAM at all. On a real chip the two coincide; the pin expander keys off the capability. +constexpr bool hasLcdCam = (lcdLanes > 0); + // Parallel WS2812 lanes over the Parlio (Parallel IO) TX peripheral — the // ESP32-P4's scale path. The unit does 16 data lines; the driver derives the bus // width (8 or 16) from the pin count. SOC-derived like the others, so a future @@ -304,3 +309,8 @@ constexpr bool hasImprov = true; // in src/platform/esp32/moonlive_emit.cpp), validated by the live hardware run, not host tests. // Kept in platform_config.h so the core header stays free of architecture #ifs. #define MM_MOONLIVE_HAS_HOST_JIT 0 + +// MM_LINKS_ALL_LED_DRIVERS — 0 on ESP32: a board links only the drivers its silicon can run, so +// the type picker stays honest and the binary lean. See the desktop config for why the host is +// the other way round. +#define MM_LINKS_ALL_LED_DRIVERS 0 diff --git a/test/unit/light/host_bus.h b/test/unit/light/host_bus.h new file mode 100644 index 00000000..2322460f --- /dev/null +++ b/test/unit/light/host_bus.h @@ -0,0 +1,73 @@ +#pragma once + +// The host-bus contract, shared by every parallel peripheral's test file. +// +// `i80`, `MoonI80` and `Parlio` are three DMA peripherals for one job, and off-device that job is +// "hold a frame": the desktop platform backs all three with the same heap buffer +// (platform_desktop.cpp § Parallel-WS2812 buses). So the assertions are identical per peripheral, +// and writing them out per file produced two byte-identical ~50-line test cases differing only in +// a type name. Same shape as test/unit/core/conditional_controls.h — one behaviour, one home, +// called with the type under test. + +#include "doctest.h" +#include "light/drivers/ParallelLedDriver.h" + +#include <cstdint> + +namespace mm::test { + +/// Pin the memory-backed bus contract for one peripheral type: allocation, that the encode path +/// can really write and read back, that an over-capacity transmit is refused rather than +/// truncated, and that busWait reports success. +template <typename Peripheral> +inline void checkHostBusAllocates() { + mm::ParallelLedDriver drv; + Peripheral peripheral; + peripheral.attach(&drv); // busInit reads the pin list through the owner + drv.setPeripheralForTest(&peripheral); // borrowed, not owned — no delete of a local + + REQUIRE(peripheral.busInit(768, /*wantSecondBuffer=*/false)); + CHECK(peripheral.busCapacity() == 768); + + uint8_t* buf = peripheral.busBuffer(0); + REQUIRE(buf != nullptr); + // Writable and reads back: the difference between a bus that carries the frame and one that + // silently discards it. + buf[0] = 0xA5; + buf[767] = 0x5A; + CHECK(buf[0] == 0xA5); + CHECK(buf[767] == 0x5A); + + CHECK(peripheral.busTransmit(0, 768)); + // Truncating instead of refusing would look like a good frame while dropping every light past + // the end of the buffer. + CHECK_FALSE(peripheral.busTransmit(0, 4096)); + // Must be true: the driver reads a false as "the previous frame never completed" and holds the + // next one back, stalling a bus that is never actually busy. + CHECK(peripheral.busWait(0, 10)); + + peripheral.busDeinit(); + CHECK(peripheral.busBuffer(0) == nullptr); +} + +/// Double-buffering lets the driver encode one frame while the other is in flight, so the two must +/// be distinct allocations — aliasing them would tear every frame. +template <typename Peripheral> +inline void checkHostBusDoubleBuffer() { + mm::ParallelLedDriver drv; + Peripheral peripheral; + peripheral.attach(&drv); + drv.setPeripheralForTest(&peripheral); + + REQUIRE(peripheral.busInit(256, /*wantSecondBuffer=*/true)); + REQUIRE(peripheral.busBuffer(0) != nullptr); + REQUIRE(peripheral.busBuffer(1) != nullptr); + CHECK(peripheral.busBuffer(0) != peripheral.busBuffer(1)); + + // Re-initialising single-buffered releases the second, rather than leaving a stale span a + // later transmit could read from. + REQUIRE(peripheral.busInit(256, /*wantSecondBuffer=*/false)); + CHECK(peripheral.busBuffer(1) == nullptr); +} + +} // namespace mm::test diff --git a/test/unit/light/unit_MoonLedDriver.cpp b/test/unit/light/unit_MoonLedDriver.cpp index ebe672e2..2ff47a83 100644 --- a/test/unit/light/unit_MoonLedDriver.cpp +++ b/test/unit/light/unit_MoonLedDriver.cpp @@ -69,8 +69,10 @@ void wire(mm::ParallelLedDriver& d, mm::MoonI80Peripheral& peripheral, mm::Buffe TEST_CASE("MoonLedDriver is LCD_CAM-only — it does not claim the classic ESP32's I2S i80") { mm::MoonI80Peripheral peripheral; CHECK(peripheral.lanesAvailable() == mm::platform::lcdLanes); - // The expander needs LCD_CAM, which is exactly where this driver runs — so the two agree. - CHECK(peripheral.supportsPinExpander() == (mm::platform::lcdLanes > 0)); + // The expander needs LCD_CAM silicon — `hasLcdCam`, not `lcdLanes > 0`. The two used to be + // the same test, until the host set a non-zero lane count so the driver would RUN there + // against a memory bus; a lane count is "how wide", the capability is "which peripheral". + CHECK(peripheral.supportsPinExpander() == mm::platform::hasLcdCam); } // The i80 BUS is 8 or 16 bits wide whatever the pin count, so the base rounds it up (powerOfTwoBus()) diff --git a/test/unit/light/unit_MultiPinLedDriver.cpp b/test/unit/light/unit_MultiPinLedDriver.cpp index 6a170d57..6c64a275 100644 --- a/test/unit/light/unit_MultiPinLedDriver.cpp +++ b/test/unit/light/unit_MultiPinLedDriver.cpp @@ -2,6 +2,7 @@ // @also Drivers, Correction #include "doctest.h" +#include "host_bus.h" #include "light/drivers/Correction.h" #include "correction_presets.h" #include "light/drivers/MultiPinLedDriver.h" @@ -416,7 +417,10 @@ TEST_CASE("MultiPinLedDriver loopbackTxPin tracks the loopbackTest toggle") { // driver's peripheral_ (which is protected). TEST_CASE("MultiPinLedDriver hides pinExpander where the chip can't host it") { mm::I80Peripheral peripheral; - CHECK_FALSE(peripheral.supportsPinExpander()); // desktop lcdLanes==0 → unsupported + // Tied to the capability flag, not to a hard-coded value: the host now EMULATES LCD_CAM (so + // the expander path is reachable off-device), and a classic ESP32 still reports false. The + // control's visibility must track whatever the target says, which is what this pins. + CHECK(peripheral.supportsPinExpander() == mm::platform::hasLcdCam); mm::ParallelLedDriver d; d.setPeripheralForTest(&peripheral); @@ -432,3 +436,14 @@ TEST_CASE("MultiPinLedDriver hides pinExpander where the chip can't host it") { } CHECK(found); // still BOUND (a saved value survives), just not shown } + +// The host bus is REAL MEMORY, not a refusal: `busInit` used to return false on desktop, so +// every bus assertion was unreachable off-device and the driver's encode path only ever ran +// on hardware. The contract is identical for all three peripherals, so it lives in one place. +TEST_CASE("MultiPinLedDriver allocates a real host bus the driver can encode into") { + mm::test::checkHostBusAllocates<mm::I80Peripheral>(); +} + +TEST_CASE("MultiPinLedDriver gives the host bus two distinct buffers when asked") { + mm::test::checkHostBusDoubleBuffer<mm::I80Peripheral>(); +} diff --git a/test/unit/light/unit_ParlioLedDriver.cpp b/test/unit/light/unit_ParlioLedDriver.cpp index c1e36f2d..acc6afdc 100644 --- a/test/unit/light/unit_ParlioLedDriver.cpp +++ b/test/unit/light/unit_ParlioLedDriver.cpp @@ -2,6 +2,7 @@ // @also Drivers, Correction #include "doctest.h" +#include "host_bus.h" #include "light/drivers/Correction.h" #include "correction_presets.h" #include "light/drivers/ParlioLedDriver.h" @@ -195,7 +196,7 @@ TEST_CASE("ParlioLedDriver frame grows on RGBW preset") { // latch pad. So the max lights/lane is ~ (65535 − 864) / (channels × 24): **897 for RGB (3ch)**, ~673 // for RGBW (4ch), ~538 for RGBCCT (5ch) — wider fixtures fit fewer lights per one-shot transfer. This // pins the boundary in host-visible frameBytes terms for the RGB and RGBW cases. The reject itself is -// hardware-only (busInit is a desktop no-op), verified on the P4 (LEDs burn at 8×896 RGB/lane; the +// hardware-only (the host bus allocates but enforces no Parlio transfer ceiling), verified on the P4 (LEDs burn at 8×896 RGB/lane; the // driver reports a status error above the ceiling). Catches the ceiling shifting if the encoding // changes. Mirrors the platform constant. TEST_CASE("ParlioLedDriver frame at the Parlio single-transfer ceiling (byte limit, channel-relative)") { @@ -363,3 +364,14 @@ TEST_CASE("ParlioLedDriver loopbackTxPin tracks the loopbackTest toggle") { }; mm::test::checkConditionalControl(d, "loopbackTxPin", setTest, /*visibleWhenTrue=*/true); } + +// The host bus is REAL MEMORY, not a refusal: `busInit` used to return false on desktop, so +// every bus assertion was unreachable off-device and the driver's encode path only ever ran +// on hardware. The contract is identical for all three peripherals, so it lives in one place. +TEST_CASE("ParlioLedDriver allocates a real host bus the driver can encode into") { + mm::test::checkHostBusAllocates<mm::ParlioPeripheral>(); +} + +TEST_CASE("ParlioLedDriver gives the host bus two distinct buffers when asked") { + mm::test::checkHostBusDoubleBuffer<mm::ParlioPeripheral>(); +} From a1ef9ec2255698618250fa33eec7b33f663b891f Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Thu, 30 Jul 2026 12:32:44 +0200 Subject: [PATCH 2/5] =?UTF-8?q?Guard-form=20column=20on=20the=20hot-path?= =?UTF-8?q?=20report;=20safe=20string=E2=86=92int;=20CodeQL=20card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .github/codeql-config.yml | 14 +- .github/workflows/codeql.yml | 31 +- CLAUDE.md | 2 +- ...-led-driver-runtime-peripheral-strategy.md | 2 +- docs/history/README.md | 2 +- docs/history/lessons.md | 2 +- ... scope - effect breadth + rename runway.md | 2 +- docs/metrics/repo-health.json | 40 +- docs/metrics/repo-health.md | 26 +- docs/moonmodules/light/layouts.md | 2 + moondeck/MoonDeck.md | 167 ++++++++- moondeck/check/check_clang_query.py | 53 ++- moondeck/check/check_codeql.py | 251 +++++++++++++ moondeck/check/check_nonblocking.py | 353 +++++++++++++++++- moondeck/docs/mkdocs_hooks.py | 40 +- moondeck/docs/update_module_docs.py | 30 +- moondeck/moondeck.py | 54 ++- moondeck/moondeck_config.json | 147 ++++---- src/core/HttpServerModule.cpp | 2 +- src/core/JsonUtil.h | 32 +- src/core/MqttModule.cpp | 2 +- src/light/drivers/HueDriver.h | 23 +- src/light/drivers/NetworkSendDriver.h | 70 ++-- src/platform/desktop/platform_desktop.cpp | 2 + test/CMakeLists.txt | 2 + .../light/scenario_peripheral_grid_sweep.json | 4 +- test/unit/core/unit_JsonUtil_parseint.cpp | 55 +++ test/unit/light/unit_SingleColumnLayout.cpp | 108 ++++++ 28 files changed, 1321 insertions(+), 197 deletions(-) create mode 100644 moondeck/check/check_codeql.py create mode 100644 test/unit/core/unit_JsonUtil_parseint.cpp create mode 100644 test/unit/light/unit_SingleColumnLayout.cpp diff --git a/.github/codeql-config.yml b/.github/codeql-config.yml index 8338e746..f6e6201e 100644 --- a/.github/codeql-config.yml +++ b/.github/codeql-config.yml @@ -1,11 +1,15 @@ name: "projectMM CodeQL config" # Code we did not write and will not change is not a finding we can act on. doctest.h is the -# vendored single-header test framework (test/doctest.h) and produced the only critical alert -# in the tree that we do not own — excluding it here is the standard mechanism, rather than -# dismissing the same alert by hand after every scan. +# vendored single-header test framework (test/doctest.h) and the only third-party source in the +# repo; everything else under src/ and test/ is ours. Add to this list only for genuinely +# third-party code, never to quiet a finding in our own. # -# This is the ONLY vendored source in the repo; everything else under src/ and test/ is ours. -# Add to this list only for genuinely third-party code, never to quiet a finding in our own. +# NB it does NOT suppress findings inside doctest.h. For C/C++ `paths-ignore` filters which files +# are ANALYZED, not which are extracted through an `#include`: 143 test files include this header, +# so its code reaches the database via them and alerts still land on it (measured — the critical +# `cpp/use-after-free` at doctest.h:3705 stayed open across scans after this entry was added, and +# was dismissed in the Security tab instead). Kept because it still drops the header as a +# standalone analysis target; the dismissal is what actually silences it. paths-ignore: - test/doctest.h diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7ff61b90..a7f4491f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -14,7 +14,9 @@ name: CodeQL # `build-mode: none` scans C/C++ WITHOUT building it, inferring compile flags per file. That # matters here because the real firmware build is an ESP-IDF cross-compile for Xtensa/RISC-V a # runner would have to reproduce. The trade is some extraction noise on macro/template-dense -# headers. +# headers — and, more sharply, that what it analyzes is the DESKTOP configuration: anything whose +# truth depends on a target typedef (`nrOfLightsType` is uint32_t here, uint16_t on a no-PSRAM +# ESP32) is judged against the wrong build. Read findings of that shape with the target in mind. # # Free on public repos, so the cost is wall-clock only. @@ -32,10 +34,14 @@ on: - main - next-iteration paths: - # Only when C/C++ or the workflow itself changes — a docs commit has nothing new to - # analyse and would just burn a runner. + # 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' workflow_dispatch: schedule: # Monday 04:00 UTC. Weekly is enough for a codebase this size, and keeps the signal @@ -64,10 +70,21 @@ jobs: with: languages: c-cpp build-mode: none - # security-and-quality is the widest stock set: the security queries answer the - # question above, and the quality ones tell us whether CodeQL sees anything our - # existing checks miss. Narrow it later if the noise is not worth it. - queries: security-and-quality + # security-extended, NOT security-and-quality. The quality pack was included to answer + # one question — does CodeQL see anything our existing checks miss — and after two runs + # the answer is no: its findings land in territory that already has an owner (unused + # variables and commented-out code are clang-tidy's, long/trivial switches are lizard's), + # which is the one-rule-one-owner rule this stack is built on. + # + # It was also actively misleading here. `build-mode: none` analyzes the DESKTOP + # configuration only, so 7 `cpp/constant-comparison` alerts flagged the layouts' overflow + # clamps as always-false — correct for that build, wrong for the target: `nrOfLightsType` + # is uint16_t on a no-PSRAM ESP32, where those guards are load-bearing. Build-dependent + # dead code is exactly what the quality pack hunts and exactly what it cannot judge here. + # + # The security queries are untouched, and they are the reason CodeQL has a slot at all: + # whole-program taint across the six packet parsers on a device with no MMU. + queries: security-extended # Excludes vendored code (test/doctest.h) — see the file for why. config-file: ./.github/codeql-config.yml diff --git a/CLAUDE.md b/CLAUDE.md index 4421b423..3ad3c378 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,7 +128,7 @@ Published at [moonmodules.org/projectMM](https://moonmodules.org/projectMM/); so - [backlog/](https://moonmodules.org/projectMM/backlog/index.html) — forward-looking to-build lists (core / light / mixed) - [adr/](https://moonmodules.org/projectMM/adr/index.html) — immutable architecture decision records (Nygard format) - [history/](https://moonmodules.org/projectMM/history/index.html) — lessons, prior-project inventories, friend-repo digests -- [moonmodules/](docs/moonmodules/) — module catalog pages + generated technical pages +- [moonmodules/](https://github.com/MoonModules/projectMM/tree/main/docs/moonmodules) — module catalog pages + generated technical pages Docs describe the system as it is; git is the history; specs precede implementation. **Documentation model**: [coding-standards.md § Documentation model](docs/coding-standards.md#documentation-model). diff --git a/docs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md b/docs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md index d372ed5f..2d52e05b 100644 --- a/docs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md +++ b/docs/adr/0016-one-parallel-led-driver-runtime-peripheral-strategy.md @@ -40,4 +40,4 @@ The alternatives weighed and rejected: **keep CRTP + one registered wrapper** (s The migration cost is documented, not coded (per [ADR-0013](0013-no-migration-code-robust-persistence-plus-documented-breaks.md)): a field device's persisted `MoonLedDriver`/`MultiPinLedDriver`/`ParlioLedDriver` type no longer resolves, so the module drops on boot and the user re-adds a Parallel LED driver and picks the peripheral — a `MIGRATING.md` entry covers it, and the web-installer catalog names the new type so a fresh install is correct. -The design intent and staged plan are the [consolidation plan](../history/plans/); this ADR is the decision record. +The design intent and staged plan are the [consolidation plan](../history/plans/README.md); this ADR is the decision record. diff --git a/docs/history/README.md b/docs/history/README.md index af2b13e9..1a307bda 100644 --- a/docs/history/README.md +++ b/docs/history/README.md @@ -33,7 +33,7 @@ One-time surveys of earlier projects, used to decide what to harvest into projec ### The plan archive -[`plans/`](plans/) holds 89 approved feature plans from before plans became temporary. Under the current rule ([CLAUDE.md § Branch](../../CLAUDE.md#branch)) a plan's text goes into its PR description and the file is deleted once the plan ships, so nothing new is added here. These files predate that: they follow the older kept-forever convention, with the outcome marked in the filename (`… (shipped).md`, `… (attempted, abandoned).md`, unmarked = never finished). Reference only, and a candidate for the same subtraction the rest of `history/` gets — the merged PRs are the permanent record of what these describe. +[`plans/`](plans/README.md) holds 89 approved feature plans from before plans became temporary. Under the current rule ([CLAUDE.md § Branch](../../CLAUDE.md#branch)) a plan's text goes into its PR description and the file is deleted once the plan ships, so nothing new is added here. These files predate that: they follow the older kept-forever convention, with the outcome marked in the filename (`… (shipped).md`, `… (attempted, abandoned).md`, unmarked = never finished). Reference only, and a candidate for the same subtraction the rest of `history/` gets — the merged PRs are the permanent record of what these describe. ### Our own lessons diff --git a/docs/history/lessons.md b/docs/history/lessons.md index 1c060845..35df5c89 100644 --- a/docs/history/lessons.md +++ b/docs/history/lessons.md @@ -172,7 +172,7 @@ The first audio-reactive capability: `AudioModule` (a SystemModule Peripheral) r - **Effects reach the producer via `AudioModule::latestFrame()` (a static accessor), not a boot setter**, because an audio effect can be added through the UI after boot and a setter only wired the boot instance. The active mic registers in `setup()`, clears in `teardown()`, returns a static silent frame when there's no mic. - **Two hardware-only bugs, now pinned:** a missing `registerType<AudioModule>` made `create(...)->markWiredByCode()` deref null and boot-loop; the I²S read blocked the tick ~7.7 ms at a 20 ms timeout (fixed to non-blocking + a cross-tick sample accumulator, dropping to ~400 µs). The INMP441 is on the **left** I²S slot here (right reads empty), the first bench suspect when level floors with sound present. - **Shipped the manual core; the adaptive conditioner was prototyped and removed.** Auto noise-floor + AGC + smoothing needs tuning in a *quiet* room; the dev environment (a campground van with strong varying low-frequency rumble) was the adversarial worst case that kept it from settling. Land the manual core, treat adaptive auto-tuning as its own increment, tune it where the noise floor is real-quiet. Also: `level` is overall RMS (independent of the FFT), don't derive it from the bands or it stops fluctuating with volume. -- **Designed fresh from the datasheet + textbook DSP, not a prior project.** The datasheet made even a behaviour-reference unnecessary: a flat ±3 dB mic has no per-frequency error to correct, so the hand-tuned band-correction table years of prior-project work produced was the wrong tool; the textbook defaults sufficed. The DSP choices and *why* live in [AudioModule.md](../moonmodules/core/moxygen/AudioModule.md). +- **Designed fresh from the datasheet + textbook DSP, not a prior project.** The datasheet made even a behaviour-reference unnecessary: a flat ±3 dB mic has no per-frequency error to correct, so the hand-tuned band-correction table years of prior-project work produced was the wrong tool; the textbook defaults sufficed. The DSP choices and *why* live in [AudioService.md](../moonmodules/core/moxygen/AudioService.md). ## ESP32-P4 round 3, WiFi via the C6: the abstraction the earlier round feared wasn't needed diff --git a/docs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.md b/docs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.md index ac6033af..9bd21226 100644 --- a/docs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.md +++ b/docs/history/plans/Plan-20260722 - Release 4 scope - effect breadth + rename runway.md @@ -24,7 +24,7 @@ Then the next migration batch on top. This is the R4 headline: it unblocks the r ## Two quick wins — scoped and ready - **Active-instance election primitive.** ([Plan-20260710 - Active-instance election primitive](Plan-20260710%20-%20Active-instance%20election%20primitive.md).) A core `ActiveInstance<T>` that removes duplicated singleton-election bookkeeping from `AudioService` + `DevicesModule` (both had real dangling-static bugs). Textbook *Complexity-lives-in-core* subtraction; small; in flight. -- **CodeRabbit #29 boundary findings (4).** ([backlog-core § MoonLive core/platform layering](../../backlog/backlog-core.md#moonlive-coreplatform-layering--jit-sdkconfig-scoping-coderabbit-29-4-findings).) MoonLive core-includes-platform + compiled-into-`mm_core`, W^X disabled in the board default, a scenario riding timing + network. Real, already scoped; good hygiene to close before a named release. +- **CodeRabbit #29 boundary findings (4).** ([backlog-core § MoonLive core/platform layering](../../backlog/backlog-core.md#moonlive-coreplatform-layering-jit-sdkconfig-scoping-coderabbit-29-4-findings).) MoonLive core-includes-platform + compiled-into-`mm_core`, W^X disabled in the board default, a scenario riding timing + network. Real, already scoped; good hygiene to close before a named release. ## The RS-485 / DMX-512 opportunity (candidate, larger) diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 54bb2adb..2d8d2e14 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,18 +1,18 @@ { - "commit": "1af1be61", + "commit": "8d491cbc", "flash": { "esp32": 1678368, - "esp32p4-eth": 1497168, + "esp32p4-eth": 1498160, "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1666592, + "esp32s3-n16r8": 1668336, "esp32s3-n8r8": 1666592, "esp32s31": 1919136, - "desktop": 942552 + "desktop": 942648 }, "perf": { "desktop": { - "tick_us": 2741, - "fps": 364 + "tick_us": 126, + "fps": 7936 }, "esp32": { "tick_us": 4164, @@ -20,20 +20,20 @@ } }, "loc": { - "core": 14827, - "light": 19540, - "platform": 12054, + "core": 14857, + "light": 19571, + "platform": 12056, "ui": 5811, - "test": 34483, - "moondeck": 18766 + "test": 34719, + "moondeck": 19477 }, "comments": { "core": { - "lines": 5549, + "lines": 5566, "ratio": 0.408 }, "light": { - "lines": 7482, + "lines": 7508, "ratio": 0.423 }, "platform": { @@ -45,28 +45,28 @@ "ratio": 0.278 }, "test": { - "lines": 5912, - "ratio": 0.198 + "lines": 5966, + "ratio": 0.199 }, "moondeck": { - "lines": 2938, - "ratio": 0.18 + "lines": 3097, + "ratio": 0.182 } }, "tests": { - "cases": 974, + "cases": 982, "scenarios": 22 }, "docs": { "md_files": 169, - "md_lines": 22534, + "md_lines": 22536, "plans_files": 89, "backlog_lines": 3113, "lessons_lines": 379, "claude_md_lines": 135 }, "complexity": { - "functions": 2135, + "functions": 2137, "over_threshold": 136, "worst_ccn": 93 } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 6fc0e4ac..74b19f41 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `1af1be61`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run — **do not edit by hand**. +Measured at `8d491cbc`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run — **do not edit by hand**. Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,11 +8,11 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 920 KB | +| desktop | 921 KB | | esp32 | 1,639 KB | -| esp32p4-eth | 1,462 KB | +| esp32p4-eth | 1,463 KB | | esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,628 KB | +| esp32s3-n16r8 | 1,629 KB (+2 KB) ⚠ | | esp32s3-n8r8 | 1,628 KB | | esp32s31 | 1,874 KB | @@ -20,32 +20,32 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -| desktop | 2,741 µs (+2,544 µs) ⚠ | 364 (−4,712) ⚠ | +| desktop | 126 µs (−14 µs) ✓ | 7,936 (+794) ✓ | | esp32 | 4,164 µs | 240 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 14,827 | 5,549 | 40.8 % | -| light | 19,540 (+1) ⚠ | 7,482 | 42.3 % | -| platform | 12,054 (−1) ✓ | 3,994 | 36.7 % | +| core | 14,857 | 5,566 | 40.8 % | +| light | 19,571 | 7,508 | 42.3 % | +| platform | 12,056 | 3,994 | 36.7 % | | ui | 5,811 | 1,518 | 27.8 % | -| test | 34,483 (−82) ✓ | 5,912 | 19.8 % (−0.1 %) ✓ | -| moondeck | 18,766 (+30) ⚠ | 2,938 | 18.0 % (+0.1 %) ⚠ | +| test | 34,719 | 5,966 | 19.9 % | +| moondeck | 19,477 | 3,097 | 18.2 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 974 | +| unit cases | 982 | | scenarios | 22 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,135 | +| functions | 2,137 | | over threshold | 136 | | worst CCN | 93 | @@ -54,7 +54,7 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Metric | Value | |---|---:| | markdown files | 169 | -| markdown lines | 22,534 (+2) ⚠ | +| markdown lines | 22,536 | | plan files | 89 | | backlog lines | 3,113 | | lessons lines | 379 | diff --git a/docs/moonmodules/light/layouts.md b/docs/moonmodules/light/layouts.md index d429cddc..783825af 100644 --- a/docs/moonmodules/light/layouts.md +++ b/docs/moonmodules/light/layouts.md @@ -1,5 +1,7 @@ # Layouts +![layouts controls](../../assets/core/Layouts.png) + Every layout, one block each: what it does and what each control means — together. A layout maps light indices to physical `(x, y, z)` positions — it defines the *shape* an [effect](effects.md) draws onto and a [driver](drivers.md) sends out. The [Layouts](moxygen/Layouts.md) container holds one or more layout children and composes them into one coordinate space; a [Layer](moxygen/Layer.md) renders over that combined space. (For how this page maps to the source/asset folders, see the [folder-structure decision](../../adr/0015-library-is-a-tag-not-a-folder.md).) ## MoonLight layouts diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index b84c489e..26d0edd2 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -10,6 +10,7 @@ Below: the UI behaviours common to every card, described once, then one section - **Status dots** on each card: grey (not run), orange (running), green (exit 0), red (exit non-zero). - **Last-run log** — the **📄** button replays that script's last run in the log pane. It appears **only on cards that have actually run** (and shows up the moment a first run finishes, no reload needed), so its absence is informative too: a card with no 📄 is one nobody has used yet. Every run is teed to `build/moondeck-logs/<id>.log` as it streams (not buffered to the end, so a run you Stop still leaves what it printed), which answers "what did this do last time" after a page reload or a switch to another card — the case a live-only stream cannot. One file per script, overwritten each run: a last-run record, not a history. Gitignored, being derived state. +- **Run count** — every run ends `[exit code: 0] [run #7]`, counting that script's runs in `build/moondeck-logs/run-counts.json`. Two identical reports are otherwise indistinguishable, so the number answers "did this actually re-run since the fix, or am I reading the same output again?". Derived like the logs: deleting `build/` resets it, and a missing or corrupt file costs the count, never the run. - **Run/Stop toggle** for long-running scripts (Run desktop, Monitor ESP32). - **Duration hint** — every card shows how long it takes: ⚡ about a second, ⏱️ a few seconds up to ~30, 🐌 more than 30 seconds (a build, a flash, a gate list, clang-tidy). All three are shown rather than only the extremes, so a blank badge reads as "nobody set a speed on this card" instead of being confused with medium. Set per script as `"speed": "instant" | "medium" | "slow"` in `moondeck_config.json`. This is a *label*, not a timeout — nothing enforces it, so a script that grows slower needs its flag updated by hand. Separate from `long_running`, which controls the Run/Stop toggle rather than expected duration. - **Group headers** in the sidebar (setup, build, flash, run, test, check, scenario). @@ -201,6 +202,22 @@ the site. Every static-analysis tool, on ONE module — the repo-wide reports turned around. +**The stack, and why it is five tools.** Each answers a question the others structurally cannot, +which is what keeps them from being redundant: + +| | sees | answers | +|---|---|---| +| **clang-tidy** | one statement, with full type information | is this line wrong? | +| **clang-query** | the AST, via matchers we write | does this codebase's own rule hold? | +| **`-Wfunction-effects`** | the whole call graph, from the compiler | can the render path block? | +| **lizard** | tokens, no types | is this function getting more complex over time? | +| **CodeQL** | a queryable database of the program | can LAN bytes reach a `memcpy`? | + +One rule, one owner: where two tools could report the same thing, one is switched off (lizard owns +complexity, so clang-tidy's `readability-function-*` checks stay disabled). The individual cards +run each of these across the tree; this card inverts that — everything at once, scoped to the +module you are actually working on. + ```bash uv run moondeck/check/check_module.py --module Control uv run moondeck/check/check_module.py --module Layer --skip clang-tidy @@ -215,12 +232,12 @@ disagree about a finding. Each tool also accepts `--module` on its own if you wa **Module is not the same as a translation unit.** A TU is one `.cpp` plus everything it includes — the unit the compiler processes, and there are 15 under `src/`. A module is one class with its `.h` and optional `.cpp`, and there are ~90. Most are **header-only**, so they have no -TU of their own: `ParallelLedDriver.h` is analysed through whichever `.cpp` includes it. That is -why `--module` filters the findings rather than the file list — scoping by TU would analyse +TU of their own: `ParallelLedDriver.h` is analyzed through whichever `.cpp` includes it. That is +why `--module` filters the findings rather than the file list — scoping by TU would analyze nothing for a header-only module and report a confident, wrong zero. It still scopes the *parse*, by resolving which TUs actually reach the module's files — -following `#include` edges transitively, so a header-only module is analysed through the one or +following `#include` edges transitively, so a header-only module is analyzed through the one or two `.cpp` files that include it rather than all 15. `BouncingBallsEffect` is reached only by `main.cpp`: **8s for all three tools, down from over four minutes**. The run prints which TUs it picked, so the scope is visible rather than assumed. @@ -229,6 +246,20 @@ picked, so the scope is visible rather than assumed. Run clang-tidy over the whole tree and write a Markdown report. +**What clang-tidy is.** LLVM's linter for C++. It compiles each file for real — same parser as the +compiler, so it sees types, overloads and template instantiations — then matches a library of +named checks against the resulting AST (605 available in the LLVM 22 we run). That is the +difference from a text linter: it knows +`std::atoi(s)` is a call to the C library, not a word that looks like one. Checks come in +families (`bugprone-*`, `performance-*`, `readability-*`, `cert-*`), each independently +switchable, and many carry a machine-applicable fix. + +**What it does for us.** It is the per-statement layer: the bug that lives inside one function and +needs type information to see. `bugprone-unchecked-string-to-number-conversion` is the shape — +`atoi` cannot report a failure, which is invisible to grep and uninteresting to a complexity +counter. Enabled checks live in [.clang-tidy](../.clang-tidy) at the repo root, so the editor and +this report agree. + ```bash uv run moondeck/check/check_clang_tidy.py # full run, report to stdout uv run moondeck/check/check_clang_tidy.py --check bugprone-infinite-loop # one check, for triage @@ -251,6 +282,20 @@ report when more than ten files fail to compile. Our own AST rules — the checks we invent, that no off-the-shelf tool reports. +**What clang-query is.** An interactive REPL over Clang's AST, shipped with LLVM. You write a +matcher in the same domain-specific language clang-tidy checks are built from — +`cxxRecordDecl(hasName("Foo"))`, `callExpr(hasAncestor(ifStmt()))` — and it prints or dumps every +node that matches. It has no opinions and no built-in checks: it answers structural questions +about the code, and what counts as a finding is left to the caller. + +**What it does for us.** It is how a project-specific rule gets written without building a +clang-tidy plugin. A plugin is a compiled C++ target with its own build; a matcher is one line of +text, editable in minutes. So the rules here are ours by definition — how much RAM the fixed +arrays cost, where the heap is touched, whether every class carries a `///` — questions no +off-the-shelf linter asks because they are about *this* codebase's constraints. The matchers +match broadly and the filtering happens in Python, because the matcher language has no notion of +"bigger than 64 bytes". + ```bash uv run moondeck/check/check_clang_query.py # every rule uv run moondeck/check/check_clang_query.py --rule=arrays # one rule @@ -289,8 +334,9 @@ prints everything, and `--min-bytes N` restores a size cutoff — both CLI-only, one button and the default plus the "N more not shown" line already answer the question from the browser. -The per-module card runs with no cap: you scoped to one module precisely to see all of its -findings, and `HttpServerModule` alone has 71 arrays. +The per-module card keeps the 60-row cap, on every table including arrays: scoping to one module +narrows WHICH findings, not how many fit on a screen — `HttpServerModule` alone reports 212 +declarations. Truncation is always announced, so the tail is one `--max-rows 0` away. **Rule `scratch` — `ScratchBuffer` members.** `ScratchBuffer` *is* the project's heap manager, so a module that uses one allocates without any `new` or `malloc` appearing in its own source — @@ -337,10 +383,10 @@ comment at all (write one). `%DOC` is the share carrying a real doc comment. Then every declaration, ranked by how far it sits from the ideal: ``` - DOC DEVIATION DOC WORDS DEV WORDS DECL NAME FILE:LINE - +1135% 1606 0 class MoonI80Peripheral MoonLedDriver.h:10 - +797% 1166 0 class HttpServerModule HttpServerModule.h:17 - -100% 0 61 method driversOn Scheduler.h:138 + DOC DEVIATION DOC WORDS DEV WORDS VIS DECL NAME FILE:LINE + +1135% 1606 0 pub class MoonI80Peripheral MoonLedDriver.h:10 + +797% 1166 0 pub class HttpServerModule HttpServerModule.h:17 + -100% 0 61 priv method driversOn Scheduler.h:138 ``` **Sizes are WORDS, not lines.** A line is a formatting accident — the same paragraph is 5 lines at @@ -392,7 +438,7 @@ whether the enclosing record is a lambda, rather than testing the method's name, Not reported: function PARAMETERS. C++ cannot attach a doc comment to one — 1054 probed, zero with a comment — they are documented via `@param` inside the method's own comment, and this tree uses `@param` 8 times, all in a `.js` file. Class ATTRIBUTES are the per-member scope that does -exist, and they are the `field` row above. Declarations clang marks `implicit` (a lambda's +exist, and they are the `attribute` row above. Declarations clang marks `implicit` (a lambda's closure class) or `invalid` (a parse that only succeeds in the TU that owns it) are skipped: both are anonymous, so their only "name" is the literal word `definition`. @@ -404,6 +450,19 @@ across cores; serial would be ~11 minutes. What the render path calls that can block or allocate — checked by the compiler. +**What `-Wfunction-effects` is.** Not a separate tool: a Clang 20+ warning implementing the C++ +*function effects* proposal (P2698). Annotate a function `[[clang::nonblocking]]` and the compiler +verifies, **transitively through the whole call graph**, that nothing it reaches allocates, locks, +throws, or otherwise blocks. It is a compiler feature, so it sees exactly what the compiler sees — +including through virtual dispatch and member-pointer calls, which is where a hand-written check +gives up. + +**What it does for us.** The render tick has a hard real-time budget, and the failure mode is a +`malloc` four calls deep in something that looks harmless. Nothing else in the stack can answer +that: clang-tidy checks one statement, lizard counts branches, the Python checks read text. This +script's own job is deduplication and framing — a raw build prints ~1350 warning lines for ~175 +unique sites, because a header is recompiled once per translation unit that includes it. + ```bash uv run moondeck/check/check_nonblocking.py # summary by callee, then every site uv run moondeck/check/check_nonblocking.py --module AudioService @@ -427,11 +486,25 @@ not be resolved from source. | Column | | |---|---| +| **COND** | the branch guarding the call: `—` none (runs every time its tick does), `if`, `loop`, `switch`, `?:`, `&&`, and `ret` for a call reached only past an `if (…) return;` above it. `·rate` marks a rate limiter (`if (++n >= kEvery)`, `if (now - last < kIntervalMs) return;`) or a once-only latch (`if (!inited_)`). `?` means clang-query was unavailable, which is *unknown*, not unguarded | | **CALLS** | the function that blocks — or `(static local variable)`, a violation with no callee: a static local needs a guard variable and a one-time lock on first use | | **IN** | the method the call sits in, which is what places it in a tier. Clang names the call and the callee but *not* their enclosing function, so this is read back from the source | | **WHY IT BLOCKS** | clang's own root cause, e.g. `calls mm::platform::UdpSocket::sendTo`. `—` means a leaf the compiler could not look inside (external or unannotated) | | **FILE:LINE** | where to go | +**Sorted unconditional-first** within each tier, then by file and line. A call that runs every +time its tick does costs more than the same call behind a branch, and ties keep source order so +findings in one file stay together and the list diffs cleanly between runs. + +**Two engines, each doing what it is good at.** The compiler finds the blocking calls, because +`-Wfunction-effects` is transitive and a matcher would have to rebuild the call graph to match it. +clang-query then annotates each site with its guard — a purely local AST question — joined on +`file:line`. Measured ~1s per TU, against a report whose cost is the clean rebuild. + +**Guarded is not rare.** `if (enabled_)` is conditional and true every frame; only the `·rate` +hint separates those, and it reads the condition's *spelling*, so it is a lead rather than a +verdict. COND answers "is this reached every tick", never "is this acceptable". + **Desktop-only, and that loses nothing.** On GCC `MM_NONBLOCKING` expands to `noexcept` — the exception contract still holds; only the clang attribute and the warning are absent. The ESP32 toolchain has neither the attribute nor the warning, and builds with `-Werror`, so a bare attribute there @@ -445,12 +518,84 @@ carrying `MM_NONBLOCKING` is invisible. Closing that needs an xtensa clang — b "ESP32 clang/LLVM toolchain" in backlog-core.md. Not a gate yet: `-Wno-error=function-effects` keeps the build green while the findings are -triaged. Each is a judgement — fix it, annotate the callee, or accept it with a scoped reason. +triaged. Each is a judgment — fix it, annotate the callee, or accept it with a scoped reason. + +### check_codeql + +CodeQL's open alerts, read from GitHub — the Security tab as a card. + +**What CodeQL is.** GitHub's semantic analysis engine. It compiles the codebase into a *relational +database* of every declaration, expression and control-flow edge, then runs queries written in QL, +a logic language, against it. Because it is a database rather than a pass over one file, a query +can follow data **across function and file boundaries** — the standard packs trace values from a +`source` (something attacker-controlled) to a `sink` (somewhere dangerous) and report the path. +That is *taint tracking*, and it is what nothing else in this stack can do. + +**What it does for us.** One question: we parse six network packet formats (ArtNet, DDP, E1.31, +WLED audio sync, MQTT, WLED) plus HTTP, doing ~22 `memcpy` operations on bytes arriving from the +LAN, on a device with **no MMU and no process isolation** — a bad length check is not a crash, it +is arbitrary memory. CodeQL is the only layer that can trace a length field from the wire to the +copy. It has already paid for itself once (3 thread-unsafe `localtime` findings, fixed), and its +current answer on the packet parsers is *clean* — which is positive evidence, not silence. + +```bash +uv run moondeck/check/check_codeql.py # open alerts, worst first +uv run moondeck/check/check_codeql.py --state fixed # what has been resolved +uv run moondeck/check/check_codeql.py --all # every state, including dismissed +uv run moondeck/check/check_codeql.py --module HueDriver +``` + +CodeQL runs in CI ([codeql.yml](../.github/workflows/codeql.yml)), not locally: it is the one +layer of the stack that sees whole-program taint, which is what the six network packet parsers +justify. Its findings then live behind the Security tab — a report nobody opens. + +**Fetches, does not scan.** The CodeQL CLI would need a ~1 GB install and minutes per run to +recompute what CI already has, and the alert lifecycle (open / fixed / dismissed, tracked across +runs) is the valuable part — baselining we would otherwise build. The trade is stated in every +run's output: alerts describe the last **analyzed pushed commit**, so local edits are not in them. + +Two severity scales are merged into one ordering, because the question is "what matters most", not +"which query pack found it": `security_severity_level` on the security queries +(critical/high/medium/low) and `severity` on the quality ones (error/warning/note/recommendation). + +**Split into `src/` and everything else**, because the two are read differently: a finding in +shipping code reaches a device, one in a test does not. Measured at 855 open alerts, **783 sat in +`test/`** — pooling them buried the three `high` findings in `src/` under doctest noise. The +severity tally prints before either table so the counts survive row truncation. + +**All states** (`--all`) adds the `fixed` and `dismissed` alerts to the open ones — the lifecycle +GitHub tracks and the reason this fetches rather than rescans. It answers "did that finding go +away, or did someone dismiss it?", so the tables gain a STATE column and a per-state tally +whenever more than one state is present. On a repo with nothing fixed or dismissed yet it reads +the same as the default, which is correct, not a broken flag. Each state is queried **explicitly**: +omitting `state` does not mean "any" — the endpoint defaults to `open`. + +`--module` scopes to one module's files from the command line. It is deliberately NOT wired to the +tools-group module dropdown: that selector is promoted above the cards as soon as two cards in a +group declare `needs_module`, which moves it out of *All Tools on Module* where it belongs. + +**Exit 2 when the answer is unknown** — no `gh`, not authenticated, code scanning disabled — with +the reason on stderr. An empty list and an unreachable API look identical in a table, and only one +of them means the code is clean. ### check_lizard Complexity gate: fail on **new** over-complex functions, not the ones already there. +**What lizard is.** A small language-agnostic complexity counter (Python, ~20 languages). It does +not parse C++ properly — it tokenizes, counts branch keywords, and reports cyclomatic complexity +(CCN), line count (NLOC), parameter count and token count per function. That shallowness is the +point: no build, no compile database, no toolchain, so it runs anywhere in about a second. + +**What it does for us.** It owns ONE number — how complex a function is — and it is the only tool +here that produces a per-commit trend rather than a verdict. clang-tidy can tell you a function is +complex today; only a series tells you the codebase is drifting, which is what +[repo-health](../docs/metrics/repo-health.json) and `collect_kpi` plot. Its own +`readability-function-*` checks stay off in clang-tidy for exactly that reason (one rule, one +owner). The tokenizer's cost is real: on template- and macro-dense C++ it reports a mangled +function name (`SolidEffect::static_cast<lengthType>` for a method called `tick`), and since the +baseline matches on `file:name`, such an entry pins nothing — see the note below. + ```bash uv run moondeck/check/check_lizard.py # report NEW violations, exit 1 if any uv run moondeck/check/check_lizard.py --all # every violation, baseline ignored diff --git a/moondeck/check/check_clang_query.py b/moondeck/check/check_clang_query.py index 796ae1a9..af9c897f 100644 --- a/moondeck/check/check_clang_query.py +++ b/moondeck/check/check_clang_query.py @@ -26,12 +26,15 @@ Not a violation — the driver layer allocates deliberately — but the hot path must not, and the count is the thing worth trending. -3. **Doc comment size.** `///` comments become the published module pages (moxydoc), and some - have grown past being read. Reported per scope — class, method, field — with NO threshold: - the scopes have different natural sizes (a class header IS the module spec; a method comment - is a sentence), so one cutoff would flag the best-documented modules as defects. The summary - line is what the thresholds get decided from. Plain `//` is not here: the lexer discards it, - so it never reaches the AST at all. +3. **Comments per declaration.** Which classes, methods and attributes are documented, and with + which kind: `///` is what moxydoc publishes, while a `//` stacked on a declaration is usually a + thought that belongs in the code below it. Sized in WORDS against per-scope ideals, with the + deviation reported rather than a pass/fail — a class header IS the module spec, so its length + is a judgment, not a defect. + + Clang's lexer discards `//`, so it reaches the AST only via the shadow tree below: a copy of + src/ where a leading `//` becomes `/// MMDEV:`. That marker is what keeps the two kinds apart + in the DEV column; the real source is never touched. Usage: uv run moondeck/check/check_clang_query.py # every rule @@ -207,6 +210,20 @@ # right at ten times the ideal, because it IS the driver's spec. _IDEAL_DOC_WORDS = {"class": 130, "method": 40, "attribute": 40, "other": 40} +# `@moreinfo` ends the part being measured. The directive splits a class comment in two (see +# gen_api.py): everything before it is the lead description, rendered above the attribute/method +# lists, and everything after is deep-dive reference relocated BELOW them under `## More info`. +# +# Only the lead is measured, because the ideal asks "how much must a reader take in before the +# member lists" — and reference material deliberately parked at the bottom of the page is not that. +# Counting the whole block punished the very structure the directive exists to encourage: +# NetworkSendDriver read +407% as one blob, and +48% once its 469 More-info words were separated +# from its 193-word lead. +# +# Matched on the AST node, not the source text: clang parses the directive into +# `InlineCommandComment ... Name="moreinfo"`, so the split point is stated rather than guessed at. +_MOREINFO_NODE = re.compile(r'InlineCommandComment.*Name="moreinfo"') + # The `//` side deliberately has NO ideal, because zero IS the ideal: a developer note stacked on # a declaration is usually a thought that belongs in the code below it. Measuring it as # deviation-from-one-line made the best case (no note at all) read as -100%, i.e. worst — so DEV @@ -309,7 +326,7 @@ def module_files(module): def including_tus(files, build_dir): - """The translation units that reach `files` — the TUs worth parsing to analyse them. + """The translation units that reach `files` — the TUs worth parsing to analyze them. A header is not a TU: it is compiled through whichever .cpp includes it. Parsing every TU to reach one header costs minutes (263s here) when a single TU usually suffices (18s), so this @@ -346,14 +363,14 @@ def including_tus(files, build_dir): frontier = nxt hits = [tu for tu in tus if Path(tu).name in reached] - return hits or tus # no edge found -> analyse everything rather than nothing + return hits or tus # no edge found -> analyze everything rather than nothing def _source_tus(build_dir): - """The src/ translation units to analyse. + """The src/ translation units to analyze. Only src/: test/ TUs would double the runtime to report on code that never ships, and a - header is analysed through whichever .cpp includes it (hence the dedupe below). + header is analyzed through whichever .cpp includes it (hence the dedupe below). """ db = json.loads((build_dir / "compile_commands.json").read_text(encoding="utf-8")) return sorted({e["file"] for e in db if "/src/" in e["file"].replace("\\", "/")}) @@ -627,7 +644,7 @@ def collect_nonpublic(out): seen = set() cur_path = None for block in out.split('Binding for "d":')[1:]: - head = next((l for l in block.split("\n") if l.strip()), "") + head = next((ln for ln in block.split("\n") if ln.strip()), "") hint = _PATH_HINT.search(head) if hint: cur_path = hint["path"] @@ -657,7 +674,7 @@ def collect_comments(out): cur_path = None for block in out.split('Binding for "d":')[1:]: lines = block.split("\n") - head = next((l for l in lines if l.strip()), "") + head = next((ln for ln in lines if ln.strip()), "") hint = _PATH_HINT.search(head) if hint: @@ -698,6 +715,10 @@ def collect_comments(out): begin = int(fc["pstart"] or fc["start"] or owner["line"]) body = "" for sub in lines[k + 1:]: + # `@moreinfo` ends the LEAD description; everything after it is relocated below + # the member lists at render time and is not what the ideal measures. + if _MOREINFO_NODE.search(sub): + break tc = _TEXTCOMMENT.search(sub) if tc: body += tc["text"] + " " @@ -864,7 +885,7 @@ def render_heap(rows, max_rows=0): def table(title, subset): if not subset: - return [f"", f"{title}: none."] + return ["", f"{title}: none."] kinds = collections.Counter(r["what"] for r in subset) what_w = min(max(len(r["what"]) for r in subset), 12) tgt_w = min(max((len(r["target"]) for r in subset), default=0), 24) or 1 @@ -909,7 +930,7 @@ def main(): f"run `uv run moondeck/build/build_desktop.py` first.", file=sys.stderr) return 2 - # A module's HEADER is not a translation unit — it is analysed through whichever .cpp + # A module's HEADER is not a translation unit — it is analyzed through whichever .cpp # includes it. So --module narrows the REPORT, not the TU list; narrowing the TUs would # miss every header-only module, which is most of the light domain. only = None @@ -944,8 +965,8 @@ def main(): # do not all say "error:" — an ambiguous top-level anyOf() reports "Input value has # unresolved overloaded type" — so key on "no matches at all", which is never a real # result for these rules on this codebase. - bad = [l for l in out.splitlines() - if "error: " in l or "unresolved overloaded type" in l or "not found" in l] + bad = [ln for ln in out.splitlines() + if "error: " in ln or "unresolved overloaded type" in ln or "not found" in ln] if bad and "binds here" not in out and "Match #" not in out: print(f"[{name}] clang-query rejected the matcher: {bad[0].strip()}", file=sys.stderr) return 2 diff --git a/moondeck/check/check_codeql.py b/moondeck/check/check_codeql.py new file mode 100644 index 00000000..8bc432c5 --- /dev/null +++ b/moondeck/check/check_codeql.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""CodeQL's open alerts, read from GitHub — the Security tab as a card. + +CodeQL runs in CI (.github/workflows/codeql.yml), not locally: it is the one layer of the +analysis stack that sees whole-program taint, and it earns that slot on the six network packet +formats we parse. But its findings live behind the Security tab, which means they are invisible +while working — a report nobody opens is a report nobody reads. + +This FETCHES rather than scans. The CodeQL CLI would need a ~1 GB install and minutes per run to +reproduce what CI already computed, and the alert lifecycle (open / fixed / dismissed, tracked +across runs) is the part worth having — that is baselining we would otherwise build ourselves. +The trade is honest and stated in the output: this shows the last ANALYZED commit, so local edits +and unpushed commits are not in it. + +Not a gate, like the rest of the stack (docs/testing.md § Static analysis): it reports, the human +judges. Exit is 0 whenever the fetch succeeded, whatever the findings — and non-zero only when the +answer is unknown (no `gh`, not authenticated, no network), because "I could not look" must never +render as "nothing found". + +Usage: + uv run moondeck/check/check_codeql.py # open alerts, worst first + uv run moondeck/check/check_codeql.py --state fixed # what has been resolved + uv run moondeck/check/check_codeql.py --all # every state, including dismissed + uv run moondeck/check/check_codeql.py --module HueDriver +""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import check_clang_query # noqa: E402 — the module→files resolver, one owner + +# Worst first. CodeQL reports two different severity scales: `security_severity_level` on the +# security queries (critical/high/medium/low) and `severity` on the quality ones +# (error/warning/note/recommendation). They are merged into one ordering here, because a reader +# wants "what matters most", not two tables to compare by eye. +_ORDER = {"critical": 0, "high": 1, "error": 2, "medium": 3, + "warning": 4, "low": 5, "note": 6, "recommendation": 7} + +MAX_ROWS = 60 + + +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 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/check_nonblocking.py b/moondeck/check/check_nonblocking.py index 44422867..bf108976 100644 --- a/moondeck/check/check_nonblocking.py +++ b/moondeck/check/check_nonblocking.py @@ -10,8 +10,14 @@ warning, so a raw build prints ~1350 lines for ~175 real findings; deduplicating on (file, line) is most of this script's value. +Two engines, each doing what it is good at. The compiler finds the blocking calls, because +`-Wfunction-effects` is transitive and a matcher would have to rebuild the call graph to match +it. clang-query then annotates each site with the branch that guards it (the COND column) — +a purely local AST question. Sites that run unconditionally sort first: they cost every tick, +where a guarded one may not run at all. + Not a gate. `-Wno-error=function-effects` in CMakeLists keeps the build green while these are -triaged; each finding is a judgement — fix it, annotate the callee, or accept it with a scoped +triaged; each finding is a judgment — fix it, annotate the callee, or accept it with a scoped reason — and the answer differs per site. Usage: @@ -20,7 +26,6 @@ """ import argparse -import collections import re import subprocess import sys @@ -110,9 +115,285 @@ def write_baseline(rows): # Which tick tier a function belongs to, once resolved through the enclosing method. TIERS = ("tick", "tick20ms", "tick1s") +# ---------------------------------------------------------------- guard form (COND) +# +# Whether a flagged call actually runs every time its tick does. `-Wfunction-effects` answers +# "can this block", never "is it reached" — so a report without this column ranks a call behind +# `if (++n >= 50)` (fires twice a second) level with one that runs unconditionally every frame. +# +# Asked of clang-query rather than scanned from the text, because the branch forms in this tree +# defeat a text scan: `if constexpr` (AudioService.h — a text scan reads it as a plain `if`), +# conditions spanning lines, and macros. The ancestor of a call is exactly the kind of LOCAL AST +# fact clang-query is for. Detection stays with the compiler: -Wfunction-effects is TRANSITIVE +# through the call graph, which a matcher would have to rebuild by hand. +# +# GUARDED IS NOT RARE. `if (enabled_)` is conditional and true every frame; only the rate hint +# below separates those, and it is a heuristic — read it as a lead, not a verdict. -def enclosing_function(rel_path, line, _cache={}): - """The method a call site sits in, or "" when it cannot be determined.""" +# One matcher per branch kind, each naming the guard from the OUTSIDE in (`ifStmt(hasDescendant( +# callExpr))`) rather than from the call outwards. `hasAncestor(...).bind()` is rejected outright +# — "Matcher does not support binding" — so the kind cannot be read back from a combined query; +# running them separately is what makes the answer unambiguous. +# +# Cheap enough to do this way: measured ~1s per TU (a narrow matcher, unlike the comments rule's +# ~44s whole-subtree dump), so seven passes over the TUs holding findings add seconds to a report +# whose cost is dominated by the clean rebuild. +# +# Ordered most-specific first: the first form to claim a site keeps it, so a call in a loop body +# reads as `loop` even when an `if` also encloses it — the loop says more about how often it runs. +# `forEachDescendant`, NOT `hasDescendant`: the latter binds only the FIRST matching descendant +# per guard, so a branch containing several calls reported one and left the rest reading as +# unconditional. Measured on the `for` at DevicesModule.h:274 — `hasDescendant` bound only +# mergePacket (:277) while recvFrom (:275) in the same loop came back blank. +# +# Each matcher names the GUARDED REGION, never the whole statement. A call in an `if`'s CONDITION +# runs every time the `if` is reached — `if (!listener_.open()) return;` (DevicesModule.h:385) is +# an unconditional call to a blocking function — so matching the bare `ifStmt` labelled it `if` +# and sorted it below the unconditional rows, i.e. wrong in the reassuring direction. Same for a +# loop's condition, a `switch`'s subject, and the LHS of `&&`/`||`, which is always evaluated. +_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\"))))"), +) + +# A guarded early exit: `if (...) return;` / `continue` / `break`. These guard everything BELOW +# them in the function, which no ancestor matcher can see — the call is genuinely unnested, so +# every matcher above reports nothing and the site reads as "runs every time". +# +# That was actively wrong, not merely incomplete. HueDriver::tick calls pushOneChangedLight +# unnested (HueDriver.h:112) but four early returns above it — including a `now - lastPutMs_ < +# kPutIntervalMs` rate limit — mean it runs at most once per interval, not once per frame. +# +# `returnStmt` ONLY. `continue`/`break` leave the enclosing LOOP, not the function, so one above +# a call — in a drain loop that has already closed, say — guards nothing about that call, and +# counting it labelled an unconditional site as guarded. A call genuinely inside the loop body is +# the `loop` matcher's to claim. +_EXIT_MATCHER = ("functionDecl(forEachDescendant(" + " returnStmt(hasAncestor(ifStmt())).bind(\"c\")))") + +# The bound call's own header line in a dump, whose start position is the join key onto a +# warning: `CallExpr 0x7a0ee05e8 </abs/path/File.h:369:11, col:25> 'int'`. Three location spellings +# appear — a full path, a bare `line:369:11` when the file is unchanged from the previous node, +# and `col:11` when the line is too — so file and line are both optional and inherited when absent. +_CALL_LOC = re.compile(r"^\w+ 0x\w+ <(?:(?P<file>(?!line:|col:)[^:<>]+):)?" + r"(?:line:(?P<line>\d+)|(?P<full>\d+)|col:\d+)") + +# A rate-limiting guard: a counter compared against a constant or a `k`-prefixed limit, the +# `if (++broadcastTick_ >= kBroadcastEverySec)` shape. Also a once-only latch (`if (!inited_)`), +# which runs its body exactly once for the life of the process. +# +# `> 0` is deliberately NOT a rate limit: `if (pairTicksLeft_ > 0)` is a plain non-empty test, and +# accepting any integer literal marked four unrelated guards in HueDriver::tick1s as throttled. +# A threshold is a named `k` constant or a literal of 2 or more. +_LIMIT = r"(?:k[A-Z]\w*|[2-9]\d*|\d{2,})" +_RATE = re.compile(rf"(\+\+|--)\s*\w+\s*(>=|>|==)|" # ++n >= limit + rf"\w+\s*(>=|>)\s*{_LIMIT}\b|" # n >= kLimit / n >= 50 + rf"[-+]\s*\w+\s*(<|<=)\s*{_LIMIT}\b|" # now - last < kIntervalMs + rf"%\s*{_LIMIT}\s*==") # n % kEvery == 0 +# +# The latch word must END the identifier (`inited_`, `sawLights`, `isOpen`) rather than sit inside +# it: a trailing `\w*` let `open` match `online`, so `if (!online) return;` — a plain connectivity +# guard — marked DevicesModule's ageOut call as rate-limited. +_LATCH_WORD = r"(?:inited|ready|open|opened|started|done|valid)" +_LATCH = re.compile(rf"!\s*\w*{_LATCH_WORD}_?\b|" + rf"\w*{_LATCH_WORD}_?\s*==\s*false") + + +def _rate_on_line(rel_path, line, _cache={}): + """True when THIS one line spells a rate limiter or a once-only latch — no surrounding scan. + + Used for early exits, where the condition and the `return` share a line and any wider window + would read a neighbouring block that does not guard the call at all. + """ + src = _cache.get(rel_path) + if src is None: + f = ROOT / rel_path + src = _cache[rel_path] = (f.read_text(encoding="utf-8", errors="replace").split("\n") + if f.exists() else []) + if not 0 < line <= len(src): + return False + return bool(_RATE.search(src[line - 1]) or _LATCH.search(src[line - 1])) + + +def _matcher_rejected(out): + """True when clang-query refused the matcher rather than finding nothing. + + The two are indistinguishable by exit code — a rejected matcher still exits 0 with no matches. + + Matched on clang-query's OWN diagnostic shapes, anchored to the start of the line. A substring + search for `error: ` finds one in the dumped source too: `snprintf(…, "error: apply failed")` + is a string literal in a matched node, and treating that as a rejection blanked the whole COND + column for a matcher that had in fact produced 255 matches. + """ + # A rejection is reported as `<line>:<col>: <complaint>` against the QUERY text, before any + # matching happens, and never alongside a match. So: complaint present AND nothing matched. + rejected = ("Input value has unresolved overloaded type", + "Error parsing argument", + "Error building matcher", + "Matcher does not support binding", + "Invalid matcher") + if "Match #" in out: + return False # it ran and produced matches — not a rejection + return any(phrase in out for phrase in rejected) + + +def guard_forms(rows, build_dir, tool): + """`(file, line) -> guard form` for every flagged site, from the AST. + + Runs one clang-query pass per branch kind over only the TUs that actually contain findings, + then joins on the call's start position — the same key the warnings carry. + + Returns None when the answer could not be obtained — no clang-query, or a matcher it refused. + The caller then renders the column as `?`, because a tool that could not look must never read + as "nothing is guarded", which is the comfortable answer and the wrong one. + """ + if not tool: + return None + # Every TU that includes a file with findings. Headers have no TU of their own — they are + # analyzed through whichever .cpp includes them — so the set is resolved, not guessed. + tus = check_clang_query.including_tus({r["file"] for r in rows}, build_dir) + if not tus: + return None + + def run(form, matcher): + """Bound sites for one matcher, or None when clang-query could not answer.""" + try: + out = check_clang_query._run_rule( + {"output": "dump", "matcher": matcher}, tus, build_dir, tool) + except Exception as exc: + print(f"clang-query failed on the {form} matcher ({exc}); COND cannot be reported.", + file=sys.stderr) + return None + # A matcher clang-query cannot resolve yields ZERO matches and exit 0 — indistinguishable + # from "no site is guarded". Same detection as check_clang_query.main; without it a single + # matcher-syntax drift silently blanks the column and every row reads as unconditional. + if _matcher_rejected(out): + print(f"clang-query rejected the {form} matcher; COND cannot be reported.", + file=sys.stderr) + return None + return list(_bound_sites(out)) + + found = {} + for form, matcher in _GUARD_MATCHERS: + sites = run(form, matcher) + if sites is None: + return None + for rel, line in sites: + found.setdefault((rel, line), form) + + # Early exits last: only a site that no enclosing branch claimed can be guarded this way, + # and being INSIDE an `if` says more about how often a call runs than sitting after one. + sites = run("ret", _EXIT_MATCHER) + if sites is None: + return None + exits = {} + for rel, line in sites: + exits.setdefault(rel, set()).add(line) + for r in rows: + if (r["file"], r["line"]) in found: + continue + # An exit guards this call only if it sits ABOVE it in the SAME function, so the search + # is bounded by the enclosing method's own first line. + start = function_start(r["file"], r["line"]) + if not start: + continue + above = [ln for ln in exits.get(r["file"], ()) if start < ln < r["line"]] + if above: + # Test the EXIT lines themselves, not a window around them: an early exit's condition + # is on its own line (`if (now - last < kInterval) return;`), and walking up from it + # would pick up whatever unrelated block sits above — which marked ageOut (:286) as + # rate-limited from a `++n >= k` block that had already closed at :284. + found[(r["file"], r["line"])] = ("ret·rate" if any( + _rate_on_line(r["file"], ln) for ln in above) else "ret") + return found + + +def _bound_sites(out): + """`(repo-relative file, line)` for every node bound as "c" in a clang-query dump. + + `dump`, not `print`: print emits the SOURCE TEXT of a match with no location, and the join + onto a warning needs file:line. The bound node's own header line carries it. + + The file is tracked across blocks because a dump states a path once and then spells later + positions `line:274:9` against it. Two traps that cost real coverage here: + - it must hold the RAW path, not `_rel()` of it. `_rel` returns None outside src/, so + filtering at assignment made one SDK match blank the file for every `line:`-only match + after it — silently dropping our own sites (DevicesModule.h:275 read as unconditional + while sitting inside a `for`). + - it must reset per TU. `_run_rule` concatenates every TU's output, so a path carried across + the seam labels one TU's matches with another's file. + """ + for chunk in out.split("Match #1:"): + cur = None + for block in chunk.split('Binding for "c":')[1:]: + head = next((ln for ln in block.split("\n") if ln.strip()), "") + m = _CALL_LOC.search(head) + if not m: + continue + if m["file"]: + cur = m["file"] + # A bare `col:` form inherits BOTH file and line from the enclosing context, so it + # carries no line of its own and is skipped — guessing one would join the guard onto + # the wrong warning, worse than leaving the column blank. + line = m["line"] or m["full"] + rel = check_clang_query._rel(cur) if cur else None + if rel and line: + yield rel, int(line) + + +def rate_hint(rel_path, line, _cache={}): + """True when the guard above `line` looks like a rate limiter or a once-only latch. + + Read from the source text, not the AST: this asks what the condition MEANS, and a counter + compared to a constant is a spelling question, not a structural one. Heuristic by nature — + it can miss a rate limiter written another way, so it only ever ADDS a hint to a site the + AST already called conditional. + """ + src = _cache.get(rel_path) + if src is None: + f = ROOT / rel_path + src = _cache[rel_path] = (f.read_text(encoding="utf-8", errors="replace").split("\n") + if f.exists() else []) + if not 0 < line <= len(src): + return False + # Walk UP out of the call's own block, stopping at the line that opened it — the guard. + # `if (++n >= k) {` / `n = 0;` / `send();` is a common shape, so a fixed one-line window misses + # the throttle (measured: DevicesModule.h:281 guards the call at :283, two lines down). + # + # Bounded by INDENTATION rather than a line count: only lines indented at least as far as the + # call belong to its block, so the scan cannot wander into a sibling guard above. A fixed wider + # window did exactly that — HueDriver::tick1s line 123's `++reportTick_ >= kReportEverySec` + # marked the four unrelated guards above it as rate-limited. + indent = len(src[line - 1]) - len(src[line - 1].lstrip()) + scan = [line - 1] + for i in range(line - 2, max(-1, line - 12), -1): + if not src[i].strip(): + continue + scan.append(i) + if len(src[i]) - len(src[i].lstrip()) < indent: + break # this line opened the block — the guard itself; stop here + for i in scan: + if i >= 0 and (_RATE.search(src[i]) or _LATCH.search(src[i])): + return True + return False + + +def _enclosing_def(rel_path, line, _cache={}): + """`(name, 1-based line)` of the definition a call site sits in, or `("", 0)`.""" src = _cache.get(rel_path) if src is None: f = ROOT / rel_path @@ -121,8 +402,20 @@ def enclosing_function(rel_path, line, _cache={}): for i in range(min(line, len(src)) - 1, -1, -1): m = _DEF.match(src[i]) if m and len(m.group("indent")) <= 4 and m.group("name") not in _KEYWORDS: - return m.group("name") - return "" + return m.group("name"), i + 1 + return "", 0 + + +def enclosing_function(rel_path, line): + """The method a call site sits in, or "" when it cannot be determined.""" + return _enclosing_def(rel_path, line)[0] + + +def function_start(rel_path, line): + """The line the enclosing definition opens on, or 0 — the lower bound when asking whether an + early exit sits above a call. Without it, a `return` from the function ABOVE this one would + read as guarding a call it cannot reach.""" + return _enclosing_def(rel_path, line)[1] def build_output(build_dir, clean=True): @@ -240,6 +533,26 @@ def main(): print(f"Filtered to {args.module}: {', '.join(only)}\n") rows = [r for r in rows if r["file"] in only] + # Annotate each site with the branch that guards it. Separate pass, separate tool: the + # compiler says WHAT can block, the AST says WHETHER it is reached every tick. + tool = check_clang_tidy._find_tool("clang-query") + guards = guard_forms(rows, build_dir, tool) + for r in rows: + if guards is None: + # Unknown, NOT unconditional. A missing or refused tool is not an answer, and `—` + # would read as one. + r["cond"] = "?" + else: + form = guards.get((r["file"], r["line"]), "") + # `ret` is already final — guard_forms tested the exit lines themselves and appended + # `·rate` if one of THEM throttles. Re-testing here would scan upward from the call + # instead and pick up any unrelated block above it (measured: DevicesModule's ageOut + # read `ret·rate` from a `++n >= k` block that had already closed two lines earlier). + if not form or form.startswith("ret") or "·" in form: + r["cond"] = form or NO_CAUSE + else: + r["cond"] = f"{form}·rate" if rate_hint(r["file"], r["line"]) else form + n_new = sum(1 for r in rows if r.get("new")) print(f"{len(rows)} call(s) from the render path that can block or allocate.") if has_baseline: @@ -263,6 +576,14 @@ def tier_of(r): def table(title, subset, note): if not subset: return + # Unconditional first: a call that runs every time its tick does is strictly worse than + # the same call behind a branch. (file, line) breaks ties, so the order stays stable + # between runs and findings in one file stay together — that is how they get fixed. + subset = sorted(subset, key=lambda r: (r["cond"] not in (NO_CAUSE, "?"), + r["file"], r["line"])) + # Never narrower than the header: a tier where every row is `—` is width 1, and the dashes + # then stop lining up under "COND". + cond_w = max(min(max(len(r["cond"]) for r in subset), 9), len("COND")) callee_w = min(max(len(r["callee"]) for r in subset), 36) fn_w = min(max(len(r["fn"] or "?") for r in subset), 18) why_w = min(max((len(r["why"]) for r in subset), default=0), 44) or 1 @@ -272,18 +593,32 @@ def clip(s, w): return s if len(s) <= w else s[: w - 1] + "\u2026" print(f"\n{title} \u2014 {len(subset)} ({note})") - print(f" {'CALLS':<{callee_w}} {'IN':<{fn_w}} {'WHY IT BLOCKS':<{why_w}} FILE:LINE") - print(f" {'-' * callee_w} {'-' * fn_w} {'-' * why_w} {'-' * loc_w}") + print(f" {'COND':<{cond_w}} {'CALLS':<{callee_w}} {'IN':<{fn_w}} " + f"{'WHY IT BLOCKS':<{why_w}} FILE:LINE") + print(f" {'-' * cond_w} {'-' * callee_w} {'-' * fn_w} " + f"{'-' * why_w} {'-' * loc_w}") for r in subset[:MAX_ROWS]: why = r["why"] or NO_CAUSE mark = "NEW " if r.get("new") else " " - print(f" {mark}{clip(r['callee'], callee_w):<{callee_w}} " + print(f" {mark}{clip(r['cond'], cond_w):<{cond_w}} " + f"{clip(r['callee'], callee_w):<{callee_w}} " f"{clip(r['fn'] or '?', fn_w):<{fn_w}} " f"{clip(why, why_w):<{why_w}} " f"{r['file']}:{r['line']}") if len(subset) > MAX_ROWS: print(f" \u2026 {len(subset) - MAX_ROWS} more. Use --module <name> to scope.") + if guards is not None: + print(f"\nCOND = the branch guarding the call ({NO_CAUSE} = none, runs every time). " + f"`\u00b7rate` marks a\nrate limiter or once-only latch. Guarded is not rare: " + f"`if (enabled_)` still runs every frame.") + elif not tool: + print("\nCOND = ? \u2014 clang-query not found, so no site could be checked for a guard. " + "Install\nLLVM (brew install llvm) for the column. Unknown is not unconditional.") + else: + print("\nCOND = ? \u2014 the guard query did not complete (reason above), so no site could " + "be\nchecked. Unknown is not unconditional.") + for tier, note in (("tick", "every frame \u2014 the hot path"), ("tick20ms", "50x a second"), ("tick1s", "once a second \u2014 sub-hot path"), diff --git a/moondeck/docs/mkdocs_hooks.py b/moondeck/docs/mkdocs_hooks.py index e668dea6..e60e0165 100644 --- a/moondeck/docs/mkdocs_hooks.py +++ b/moondeck/docs/mkdocs_hooks.py @@ -15,6 +15,7 @@ /install/ so the local preview mirrors production's single-origin layout. """ +import logging import os import re from pathlib import Path @@ -53,7 +54,8 @@ # Repo top-level dirs/files a doc may link into but the site doesn't host. _OUT_OF_DOCS = ("src/", "moondeck/", "test/", "esp32/", "web-installer/", - ".github/", "CLAUDE.md", "README.md", "library.json", "CMakeLists.txt") + ".github/", "CLAUDE.md", "README.md", "library.json", "CMakeLists.txt", + ".clang-tidy", ".clangd") # A markdown link into a repo file the site doesn't host. Two authored shapes, both # common in the transient history/plans + backlog notes: @@ -247,7 +249,7 @@ def _render_catalog_table(markdown: str) -> str: passes through verbatim, so a table only ever contains genuine module blocks (a `##` heading closes the current table). Source .md stays authored as prose.""" lines = markdown.split("\n") - details_names = {_DETAILS_RE.match(l).group("name") for l in lines if _DETAILS_RE.match(l)} + details_names = {_DETAILS_RE.match(ln).group("name") for ln in lines if _DETAILS_RE.match(ln)} out = [] rows = [] # accumulated table rows for the current run @@ -368,6 +370,13 @@ def _add(src_uri: str, content: str): # ride along untouched. _DOCS_PREFIXED_HREF_RE = re.compile(r'href="docs/([^"#]+?)(\.md)?(#[^"]*)?"') +# The same borrowed text also links to files OUTSIDE docs/ (`moondeck/MoonDeck.md`), which +# have no page on the site at all. `_rewrite_out_of_docs_links` already sends those to the +# GitHub blob, but it runs at markdown stage where this page is still an unexpanded +# `--8<--` directive — so the embedded ones need the same treatment here. +_OUT_OF_DOCS_HREF_RE = re.compile( + rf'href="({"|".join(re.escape(p) for p in _OUT_OF_DOCS)})([^"#]*)(#[^"]*)?"') + # The page's first `<h1 ...>text</h1>`, split so the tag and its attributes (the id the # table of contents anchors on) survive and only the text is replaced. _FIRST_H1_RE = re.compile(r"(<h1[^>]*>)(.*?)(</h1>)", re.S) @@ -393,7 +402,32 @@ def _rebase_repo_root_doc_links(html): def _fix(m): path, _, anchor = m.group(1), m.group(2), m.group(3) or "" return f'href="{path}.html{anchor}"' if _ else f'href="{path}{anchor}"' - return _DOCS_PREFIXED_HREF_RE.sub(_fix, html) + html = _DOCS_PREFIXED_HREF_RE.sub(_fix, html) + return _OUT_OF_DOCS_HREF_RE.sub( + lambda m: f'href="{_BLOB_BASE}/{m.group(1)}{m.group(2)}{m.group(3) or ""}"', html) + + +class _MuteRebasedLinkWarnings(logging.Filter): + """Drop the link warnings for the page whose links this hook rebases after validation. + + `principles-and-process.md` embeds CLAUDE.md, whose links (`docs/architecture.md`, + `moondeck/MoonDeck.md`) are correct where that file is actually read — the repo root and + GitHub — and `_rebase_repo_root_doc_links` turns them into working site links. But + validation is a markdown treeprocessor inside `Page.render()`, so it judges the + pre-rebase text and reports 13 broken links the built site does not have. + + Filtering the log is the narrow fix. `validation.links.not_found: ignore` in mkdocs.yml + would hide genuinely missing pages across every other doc, and MkDocs has no per-page + validation setting; the `inclusion` levels that do suppress warnings (`DRAFT` and below) + also drop the page from the built site. + """ + + def filter(self, record): + msg = record.getMessage() + return not any(f"Doc file '{p}' contains a link" in msg for p in _EMBEDS_REPO_ROOT_FILE) + + +logging.getLogger("mkdocs.structure.pages").addFilter(_MuteRebasedLinkWarnings()) def on_page_content(html, page, config, files): diff --git a/moondeck/docs/update_module_docs.py b/moondeck/docs/update_module_docs.py index a672ed78..9f176d0c 100644 --- a/moondeck/docs/update_module_docs.py +++ b/moondeck/docs/update_module_docs.py @@ -74,6 +74,26 @@ def type_name_from_md(md_file: Path) -> str: return md_file.stem +def resolve_asset(path: Path) -> Path | None: + """`path` as it is ACTUALLY spelled on disk, or None when no such file exists. + + `Path.exists()` answers through the filesystem, and macOS's is case-insensitive: it reports + True for `assets/core/layouts.png` when the file is `Layouts.png`. The link written from that + path then resolves locally and 404s on the case-sensitive Linux runner that builds the + published site — a failure that cannot reproduce on the machine that produced it. + + So the parent directory is listed and matched case-insensitively, and the REAL name is + returned. Catalog pages are the case that needs it: `layouts.md` has a lowercase stem while + the screenshot follows the `<TypeName>.png` convention. + """ + if path.exists() and path.name in {p.name for p in path.parent.iterdir()}: + return path # exact match, including case + if not path.parent.is_dir(): + return None + lowered = path.name.lower() + return next((p for p in sorted(path.parent.iterdir()) if p.name.lower() == lowered), None) + + def insert_extra_shot(doc_path: Path, filename: str, anchor: str, dry_run: bool) -> bool: """Insert an image reference for filename into doc_path after anchor. @@ -205,14 +225,16 @@ def main() -> int: for md_file in sorted(DOCS_DIR.rglob("*.md")): type_name = type_name_from_md(md_file) - png = asset_dir_for(type_name) / f"{type_name}.png" - gif = asset_dir_for(type_name) / f"{type_name}.gif" + # Resolved through the directory listing, not Path.exists(), so the link carries the name + # as it is really spelled — see resolve_asset. + png = resolve_asset(asset_dir_for(type_name) / f"{type_name}.png") + gif = resolve_asset(asset_dir_for(type_name) / f"{type_name}.gif") - if not png.exists(): + if png is None: skipped_no_screenshot.append(md_file.relative_to(ROOT)) continue - changed = insert_assets(md_file, png, gif if gif.exists() else None, args.dry_run) + changed = insert_assets(md_file, png, gif, args.dry_run) if changed: rel = md_file.relative_to(ROOT) verb = "would update" if args.dry_run else "updated" diff --git a/moondeck/moondeck.py b/moondeck/moondeck.py index 31175e50..01050562 100644 --- a/moondeck/moondeck.py +++ b/moondeck/moondeck.py @@ -9,6 +9,7 @@ import sys import tempfile import threading +import time from contextlib import suppress from datetime import datetime from pathlib import Path @@ -27,6 +28,13 @@ # so once the cap is hit the writer stops and says so — truncating the end would throw away # the part you came for. LOG_MAX_BYTES = 1_000_000 +# How many times each script has been run, so a card's footer can say "run #7" — the question +# "have I actually re-run this since the fix, or am I reading a stale number?" is otherwise +# unanswerable from a report that looks identical either way. Alongside the logs and equally +# derived: deleting build/ resets the counts, which is the right blast radius for a convenience +# record. One small JSON for every script rather than a file each — it is read and rewritten +# once per run, and a dict of ints stays tiny. +RUNS_FILE = LOG_DIR / "run-counts.json" UI_DIR = SCRIPTS_DIR / "moondeck_ui" ASSETS_DIR = ROOT / "docs" / "assets" STATE_FILE = SCRIPTS_DIR / "moondeck.json" @@ -104,6 +112,43 @@ def load_scripts(): SCRIPTS = _scripts_data["scripts"] FIRMWARES = _load_firmwares() + +def _duration(seconds): + """A run's wall time, read at a glance: `4.2s`, `1m12s`, `1h04m`. + + Seconds alone stop being readable somewhere around a minute — `312s` has to be divided in + your head — and a clean rebuild here runs into the minutes. + """ + s = int(seconds) + if s < 60: + return f"{seconds:.1f}s" + if s < 3600: + return f"{s // 60}m{s % 60:02d}s" + return f"{s // 3600}h{(s % 3600) // 60:02d}m" + + +def bump_run_count(script_id): + """Record one more run of `script_id` and return the new total. + + Best-effort throughout: this is a footer on a report, so a missing or corrupt counts file + must never fail the run that produced the report. A read error starts from zero rather than + raising, and a write error is dropped — the count is the only thing lost. + """ + counts = {} + with suppress(OSError, ValueError): + loaded = json.loads(RUNS_FILE.read_text(encoding="utf-8")) + if isinstance(loaded, dict): + counts = loaded + # A hand-edited or half-written file can hold anything; a bad value restarts the count for + # that script rather than raising in the middle of reporting a successful run. + prev = counts.get(script_id, 0) + n = (prev if isinstance(prev, int) else 0) + 1 + counts[script_id] = n + with suppress(OSError): + RUNS_FILE.parent.mkdir(parents=True, exist_ok=True) + RUNS_FILE.write_text(json.dumps(counts, indent=1, sort_keys=True), encoding="utf-8") + return n + # --------------------------------------------------------------------------- # Device discovery # --------------------------------------------------------------------------- @@ -255,8 +300,6 @@ def _push_device(ip: str, model: str) -> bool: """ if not model: return True # nothing to push; not a failure - import urllib.request - import urllib.error # Look up the catalog entry. DEVICE_MODELS is loaded at module init; we don't # re-read deviceModels.json per push so a tight discover-refresh cycle # doesn't hammer the disk. If the user edits deviceModels.json, restart @@ -1800,6 +1843,10 @@ def _handle_stream(self, script_id: str): # EOF, same as the pipe's b"" sentinel. stream = getattr(proc, "_mm_read_stream", None) or proc.stdout + # Wall clock, from the first read to the child's exit. Monotonic, so an NTP step or a DST + # change mid-run cannot report a negative or wildly wrong duration. + started = time.monotonic() + # Tee to disk as we stream, rather than buffering and writing at the end: a long run # killed with Stop (or a crashed server) still leaves everything it printed up to # that point, which is exactly when you want the log. @@ -1839,7 +1886,8 @@ def _handle_stream(self, script_id: str): self.wfile.flush() proc.wait() - exit_msg = f"[exit code: {proc.returncode}]" + exit_msg = (f"[exit code: {proc.returncode}] [{_duration(time.monotonic() - started)}]" + f" [run #{bump_run_count(script_id)}]") if log: with suppress(OSError): log.write(exit_msg + "\n") # always recorded, even past the cap diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json index 9e085312..736cceae 100644 --- a/moondeck/moondeck_config.json +++ b/moondeck/moondeck_config.json @@ -73,6 +73,78 @@ "long_running": true, "process_name": "preview_installer.py" }, + { + "id": "check_module", + "tab": "desktop", + "group": "tools", + "label": "All Tools on Module", + "speed": "medium", + "help": "check_module", + "script": "check/check_module.py", + "needs_module": true + }, + { + "id": "check_clang_tidy", + "tab": "desktop", + "group": "tools", + "label": "clang-tidy", + "speed": "slow", + "help": "check_clang_tidy", + "script": "check/check_clang_tidy.py", + "long_running": true + }, + { + "id": "check_clang_query", + "tab": "desktop", + "group": "tools", + "label": "clang-query", + "speed": "medium", + "help": "check_clang_query", + "script": "check/check_clang_query.py" + }, + { + "id": "check_nonblocking", + "tab": "desktop", + "group": "tools", + "label": "clang-hotpath", + "speed": "slow", + "help": "check_nonblocking", + "script": "check/check_nonblocking.py" + }, + { + "id": "check_lizard", + "tab": "desktop", + "group": "tools", + "label": "Lizard Complexity", + "speed": "medium", + "help": "check_lizard", + "script": "check/check_lizard.py", + "flags": [ + { + "id": "all", + "label": "All", + "arg": "--all", + "default": false + } + ] + }, + { + "id": "check_codeql", + "tab": "desktop", + "group": "tools", + "label": "CodeQL (CI alerts)", + "speed": "instant", + "help": "check_codeql", + "script": "check/check_codeql.py", + "flags": [ + { + "id": "all", + "label": "All states", + "arg": "--all", + "default": false + } + ] + }, { "id": "check_specs", "tab": "desktop", @@ -91,6 +163,16 @@ "help": "check_platform_boundary", "script": "check/check_platform_boundary.py" }, + { + "id": "check_esp32_built", + "tab": "esp32", + "group": "build", + "label": "Firmware Up To Date", + "speed": "instant", + "help": "check_esp32_built", + "script": "check/check_esp32_built.py", + "needs_firmware": true + }, { "id": "check_devices", "tab": "desktop", @@ -136,16 +218,6 @@ "help": "history_report", "script": "report/history_report.py" }, - { - "id": "check_esp32_built", - "tab": "esp32", - "group": "build", - "label": "Firmware Up To Date", - "speed": "instant", - "help": "check_esp32_built", - "script": "check/check_esp32_built.py", - "needs_firmware": true - }, { "id": "event_precommit", "tab": "desktop", @@ -173,61 +245,6 @@ "help": "event_prerelease", "script": "event/prerelease.py" }, - { - "id": "check_module", - "tab": "desktop", - "group": "tools", - "label": "All Tools on Module", - "speed": "medium", - "help": "check_module", - "script": "check/check_module.py", - "needs_module": true - }, - { - "id": "check_clang_tidy", - "tab": "desktop", - "group": "tools", - "label": "clang-tidy", - "speed": "slow", - "help": "check_clang_tidy", - "script": "check/check_clang_tidy.py", - "long_running": true - }, - { - "id": "check_clang_query", - "tab": "desktop", - "group": "tools", - "label": "clang-query", - "speed": "medium", - "help": "check_clang_query", - "script": "check/check_clang_query.py" - }, - { - "id": "check_nonblocking", - "tab": "desktop", - "group": "tools", - "label": "clang-hotpath", - "speed": "slow", - "help": "check_nonblocking", - "script": "check/check_nonblocking.py" - }, - { - "id": "check_lizard", - "tab": "desktop", - "group": "tools", - "label": "Lizard Complexity", - "speed": "medium", - "help": "check_lizard", - "script": "check/check_lizard.py", - "flags": [ - { - "id": "all", - "label": "All", - "arg": "--all", - "default": false - } - ] - }, { "id": "install_playwright", "tab": "desktop", diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index b5e1371e..26e931ca 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -1499,7 +1499,7 @@ void HttpServerModule::applyWledState(const char* body) { const char* segStart = std::strstr(body, "\"seg\":"); const char* palStart = segStart ? std::strstr(segStart, "\"pal\":") : nullptr; if (palStart) { - int pal = std::atoi(palStart + 6); + int pal = mm::json::parseIntStr(palStart + 6); if (pal < 0) pal = 0; if (pal >= mm::palettes::kCount) pal = mm::palettes::kCount - 1; char valueJson[24]; diff --git a/src/core/JsonUtil.h b/src/core/JsonUtil.h index de9ad328..746be022 100644 --- a/src/core/JsonUtil.h +++ b/src/core/JsonUtil.h @@ -19,6 +19,8 @@ // Any malformed / truncated input fails cleanly (parse() returns false, accessors // return safe defaults) and never reads OOB. Off the hot path (boot load, control writes). +#include <cerrno> // ERANGE — parseIntStr's overflow signal on 32-bit `long` +#include <climits> // INT_MIN/INT_MAX — parseIntStr's range check #include <cstdint> #include <cstdio> #include <cstdlib> @@ -109,6 +111,34 @@ inline bool hasKey(const char* json, const char* key) { return std::strstr(json, search) != nullptr; } +/// The integer `s` starts with, or `fallback` when it does not start with one or the value does +/// not fit in `int`. The one string→int conversion these readers use. +/// +/// `strtol`, not `atoi`: atoi cannot report a failure, so "no digits at all" and a genuine `"0"` +/// are indistinguishable, and a value too large for `long` is undefined behavior rather than a +/// detectable error. Callers that then narrow the result (a `uint16_t` id, a `uint8_t` percent) +/// would silently store a DIFFERENT valid number — `"65537"` becoming 1. Out-of-range is reported +/// as the fallback here, which is the only place it can still be seen. +/// +/// Overflow needs BOTH checks, because they cover different targets. `long` is 64-bit on the +/// desktop, so a huge value lands inside `long` and only the INT_MAX compare rejects it; `long` is +/// 32-bit on ESP32 and Windows, where `LONG_MAX == INT_MAX` makes that compare dead code and +/// strtol's own saturation-to-LONG_MAX plus `ERANGE` is the only signal. Testing one alone passes +/// on the desktop and silently returns INT_MAX on the target this exists to protect. +/// +/// Trailing text is deliberately allowed: these values are read out of a JSON body, so digits are +/// followed by `,` or `}`. Only the LEADING characters decide. +inline int parseIntStr(const char* s, int fallback = 0) { + if (!s) return fallback; + char* end = nullptr; + errno = 0; // strtol only ever SETS it on error + const long v = std::strtol(s, &end, 10); + if (end == s) return fallback; // no digits — not a number at all + if (errno == ERANGE) return fallback; // saturated: outside `long` + if (v < INT_MIN || v > INT_MAX) return fallback; // fits `long`, would not survive `int` + return static_cast<int>(v); +} + inline int parseInt(const char* json, const char* key) { if (!json || !key) return 0; char search[kSearchLen]; @@ -119,7 +149,7 @@ inline int parseInt(const char* json, const char* key) { start = std::strstr(json, search); } if (!start) return 0; - return std::atoi(start + std::strlen(search)); + return parseIntStr(start + std::strlen(search)); } inline bool parseBool(const char* json, const char* key) { diff --git a/src/core/MqttModule.cpp b/src/core/MqttModule.cpp index 93e0c002..b590992a 100644 --- a/src/core/MqttModule.cpp +++ b/src/core/MqttModule.cpp @@ -660,7 +660,7 @@ void MqttModule::routePublish(const char* topic, const uint8_t* payload, size_t setControlValue("on", on ? "{\"value\":true}" : "{\"value\":false}"); } else if (std::strcmp(suffix, "brightness/set") == 0) { // mqttthing sends 0..100; rescale to 0..255. - int pct = std::atoi(value); + int pct = mm::json::parseIntStr(value); if (pct < 0) pct = 0; if (pct > 100) pct = 100; const int bri = (pct * 255) / 100; diff --git a/src/light/drivers/HueDriver.h b/src/light/drivers/HueDriver.h index 9d452148..fe3111a0 100644 --- a/src/light/drivers/HueDriver.h +++ b/src/light/drivers/HueDriver.h @@ -228,10 +228,10 @@ class HueDriver : public DriverBase { /// fetchLights has run, so the list is trustworthy. bool sawLights_ = false; /// Friendly light names for the dropdown — `kMaxLights × kNameLen`, indexed by lightNameAt(). - /// Heap, NOT inline: a fixed array would reserve 768 B whether the bridge has 4 lights or 32 - /// (and cap at 32). One contiguous block is allocated to the ACTUAL count when the fetch runs - /// and freed in release(), so memory scales to the real bridge and sizeof(HueDriver) stays - /// small (the lightsBuf_ stack-overflow lesson, applied to the names). + /// Heap, NOT inline: the block is allocated lazily on first parse and freed in release(), so + /// an unconfigured driver pays nothing and sizeof(HueDriver) stays small (the lightsBuf_ + /// stack-overflow lesson, applied to the names). The capacity is the kMax bound, not the live + /// count — the parser fills it incrementally and the count is not known until it finishes. char* lightNames_ = nullptr; /// Friendly room names, same shape — `kMaxRooms × kNameLen`, indexed by roomNameAt(). char* roomNames_ = nullptr; @@ -414,6 +414,15 @@ class HueDriver : public DriverBase { return len > 0 && body[len - 1] == '}'; } + /// 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; + } + /// --- Learn the bridge's light ids (window index → hue id, in id order). void fetchLights() { char host[16]; bridgeStr(host); @@ -493,7 +502,7 @@ class HueDriver : public DriverBase { while (true) { const char* q = std::strchr(p, '"'); // next key open-quote if (!q) break; - int id = std::atoi(q + 1); // light id is a quoted integer key + int id = parseId(q + 1); // light id is a quoted integer key const char* close = std::strchr(q + 1, '"'); // A top-level light-id key: a quoted positive integer followed by ':'. if (id > 0 && close && close[1] == ':') { @@ -556,7 +565,7 @@ class HueDriver : public DriverBase { while (true) { const char* q = std::strchr(p, '"'); if (!q) break; - int id = std::atoi(q + 1); + int id = parseId(q + 1); const char* close = std::strchr(q + 1, '"'); if (id > 0 && close && close[1] == ':') { commit(q); // the PREVIOUS group's object ends here @@ -581,7 +590,7 @@ class HueDriver : public DriverBase { uint32_t mask = 0; for (const char* q = s; q < end && *q != ']'; ) { if (*q == '"') { - const int id = std::atoi(q + 1); + const int id = parseId(q + 1); for (uint8_t i = 0; i < lightCount_; i++) // map the id to its color-light bit if (hueId_[i] == id) { mask |= (1u << i); break; } const char* c = std::strchr(q + 1, '"'); // skip to the value's closing quote diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index ade6cd6d..a7363ee0 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -17,12 +17,25 @@ namespace mm { /// studied, not copied). Byte layouts live in ArtNetPacket.h / E131Packet.h / DdpPacket.h, shared /// with the receiver so the two sides cannot drift. /// -/// **Addressing: unicast to a LIST of receivers, and why that is the default.** +/// **Addressing:** one driver feeds N receivers (`ips`), each taking a contiguous run of the window +/// (`lightsPerIp`, the same broadcasting idiom as an LED driver's `ledsPerPin`) and each addressed +/// only by itself. So a wall of tubes is one driver, not N — the driver-level twin of one LED driver +/// fanning out to N GPIO lanes. Unicast is the default, not an option among equals; broadcast +/// survives only as legacy compatibility. /// -/// One driver feeds N receivers (`ips`), each taking a contiguous run of the window (`lightsPerIp`, -/// the same broadcasting idiom as an LED driver's `ledsPerPin`) and each addressed only by itself. -/// So a wall of tubes is one driver, not N — the driver-level twin of one LED driver fanning out to -/// N GPIO lanes. +/// **Synchronous send:** the whole frame goes out inline in tick() (~35 ms Ethernet / ~90 ms WiFi at +/// 128×128 ArtNet; DDP less). A decoupling send task is a PSRAM-gated backlog item. Added per board +/// via the catalog like the LED drivers; applies the same shared Correction, so network and wired +/// outputs show identical colors. +/// +/// The deep dives are under *More info*, below the attribute/method lists: +/// @xref{why-unicast-is-the-default|why unicast is the default}, +/// @xref{liveness-and-what-is-not-here|liveness, multicast and E1.31 framing}. +/// @card NetworkSendDriver.png +/// +/// @moreinfo +/// +/// ## Why unicast is the default /// /// The Art-Net 4 spec leaves no room here: *"ArtDmx packets must be unicast to subscribers of the /// specific universe contained in the ArtDmx packet… There are no conditions in which broadcast is @@ -50,20 +63,16 @@ namespace mm { /// costs the sender the same and costs every other receiver nothing. Mirroring is the one case where /// a broadcast address is genuinely the better tool. /// -/// **Liveness.** UDP is fire-and-forget, so a dead receiver is invisible to the sender: the send -/// loop simply tolerates it (a failed send drops that packet and moves on, so one dark tube cannot -/// stall the others) rather than pretending to detect it. Real detection is ArtPoll/ArtPollReply -/// discovery — the spec's own mechanism, and the next increment (backlog). +/// ## Liveness, and what is not here +/// +/// UDP is fire-and-forget, so a dead receiver is invisible to the sender: the send loop simply +/// tolerates it (a failed send drops that packet and moves on, so one dark tube cannot stall the +/// others) rather than pretending to detect it. Real detection is ArtPoll/ArtPollReply discovery — +/// the spec's own mechanism, and the next increment (backlog). /// /// NOT multicast (no IGMP join yet — backlogged; it is sACN's native mode and the best answer on a /// switch with IGMP snooping, but degrades to a flood without it). E1.31 framing: CID stable per /// device (from the MAC), source name `projectMM`, priority 100, one frame-level sequence per frame. -/// -/// **Synchronous send:** the whole frame goes out inline in tick() (~35 ms Ethernet / ~90 ms WiFi at -/// 128×128 ArtNet; DDP less). A decoupling send task is a PSRAM-gated backlog item. Added per board -/// via the catalog like the LED drivers; applies the same shared Correction, so network and wired -/// outputs show identical colors. -/// @card NetworkSendDriver.png class NetworkSendDriver : public DriverBase { public: /// DMX-over-network fixtures (ArtNet / E1.31 / DDP) are RGB by convention — the @@ -82,6 +91,9 @@ class NetworkSendDriver : public DriverBase { /// destinations, so the driver idles (never falls back to broadcasting — see the class note). /// Capped at a wall of tubes, not a subnet scan. static constexpr uint8_t kMaxDestinations = 32; + /// Receiver addresses, comma-separated (`192.168.1.10,192.168.1.11`). Each takes a contiguous + /// run of the window per `lightsPerIp`, so the list order is the fan-out order. A broadcast + /// address works but is not the default — see the unicast rationale above. char ips[64] = {}; /// Lights per destination — the same idiom as an LED driver's `ledsPerPin`, and literally the /// same helper (`assignCounts`): blank = even split of the window, one number = that many each, @@ -328,9 +340,9 @@ class NetworkSendDriver : public DriverBase { // light/ArtNetPacket.h, light/E131Packet.h and light/DdpPacket.h, shared // with NetworkReceiveEffect — each wire format exists in exactly one place. - // Test-only accessor for the correction-applied buffer. Lets the unit - // tests pin the no-allocation-in-loop contract (size set in prepare - // / onCorrectionChanged, never in loop). Not part of any runtime API. + /// Test-only accessor for the correction-applied buffer, letting the unit tests pin the + /// no-allocation-in-loop contract (sized in prepare / onCorrectionChanged, never in the send + /// loop). Not part of any runtime API. const Buffer& correctedBuffer() const { return corrected_; } /// The destination table prepare() derived: how many receivers, each one's address, and how many @@ -342,24 +354,34 @@ class NetworkSendDriver : public DriverBase { nrOfLightsType lightsAt(uint8_t i) const { return destCounts_[i]; } private: + /// The send socket, opened once in setup() and reused for every destination. platform::UdpSocket socket_; + /// The shared frame this driver reads its window from; borrowed, not owned. Buffer* sourceBuffer_ = nullptr; - Buffer corrected_; // owned: source bytes after brightness/order/white + /// Owned: source bytes after brightness/order/white. Sized off the hot path (resizeCorrected). + Buffer corrected_; + /// Art-Net/E1.31 per-frame sequence counter; wraps at 255, which both protocols expect. uint8_t sequence_ = 0; + /// millis() of the last frame sent — the `fps` rate limiter's reference point. uint32_t lastSendTime_ = 0; - uint8_t cid_[E131_CID_LENGTH] = {}; // E1.31 component id, built once in setup() - // Destinations + each one's slice of the window, both derived in prepare() (never in tick()). + /// E1.31 component id, built once in setup() from the MAC so it is stable per device. + uint8_t cid_[E131_CID_LENGTH] = {}; + /// Destination addresses, derived in prepare() (never in tick()). uint8_t dest_[kMaxDestinations][4] = {}; + /// Each destination's slice of the window, index-aligned with `dest_`. nrOfLightsType destCounts_[kMaxDestinations] = {}; + /// How many entries of `dest_` / `destCounts_` are live. uint8_t nDest_ = 0; + /// The UDP port for a protocol index — each wire format has its own registered port. static uint16_t protocolPort(uint8_t p) { return p == 1 ? E131_PORT : p == 2 ? DDP_PORT : ARTNET_PORT; } - // Called off the hot path (prepare, onCorrectionChanged, setSourceBuffer) to make - // sure corrected_ is sized for the current source + this driver's correction. Skips - // when no source is wired yet, or when the existing allocation already fits. + /// Size `corrected_` for the current source and this driver's correction. Called only off the + /// hot path (prepare, onCorrectionChanged, setSourceBuffer) — that placement IS the + /// no-allocation-in-the-send-loop contract. Skips when no source is wired yet, or when the + /// existing allocation already fits. void resizeCorrected() { if (!sourceBuffer_) return; // Size for the window slice this sender actually transmits, not the whole diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 42ab8ca6..593ec496 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -1367,6 +1367,7 @@ bool moonI80Ws2812Init(MoonI80Ws2812Handle& h, const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, uint16_t /*wrGpio*/, size_t bufferBytes, bool wantSecondBuffer, uint8_t /*clockMultiplier*/) { + if (bufferBytes == 0) return false; // refuse before allocating, as the other seams do return hostBus(h.impl)->init(bufferBytes, wantSecondBuffer); } // Ring mode is a GDMA construct with no host equivalent — inert here, bench-verified on the S3, exactly @@ -1417,6 +1418,7 @@ RmtLoopbackResult moonI80Ws2812LoopbackRide(uint16_t /*rxGpio*/, const uint8_t* bool parlioWs2812Init(ParlioWs2812Handle& h, const uint16_t* /*dataPins*/, uint8_t /*laneCount*/, uint32_t /*pclkHz*/, size_t bufferBytes, bool wantSecondBuffer) { + if (bufferBytes == 0) return false; // refuse before allocating, as the other seams do return hostBus(h.impl)->init(bufferBytes, wantSecondBuffer); } uint8_t* parlioWs2812Buffer(const ParlioWs2812Handle& h, uint8_t buffer) { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 3e61d10d..88d27114 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(mm_tests unit/core/unit_ImprovFrame.cpp unit/core/unit_ImprovOpReassembler.cpp unit/core/unit_JsonUtil_parse.cpp + unit/core/unit_JsonUtil_parseint.cpp unit/core/unit_JsonSink_detach.cpp unit/core/unit_MappingLUT.cpp unit/core/unit_ModuleFactory.cpp @@ -80,6 +81,7 @@ add_executable(mm_tests unit/light/unit_WaveEffect.cpp unit/light/unit_FireEffect.cpp unit/light/unit_GridLayout.cpp + unit/light/unit_SingleColumnLayout.cpp unit/light/unit_SphereLayout.cpp unit/light/unit_WheelLayout.cpp unit/light/unit_Layer_extrude.cpp diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index 9f2ba908..6cf1a220 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -1055,7 +1055,7 @@ }, "desktop-macos": { "tick_us": [ - 273, + 271, 945 ], "free_heap": [ @@ -1068,7 +1068,7 @@ ], "at": [ "2026-07-26", - "2026-07-28" + "2026-07-30" ] } } diff --git a/test/unit/core/unit_JsonUtil_parseint.cpp b/test/unit/core/unit_JsonUtil_parseint.cpp new file mode 100644 index 00000000..60f32ae1 --- /dev/null +++ b/test/unit/core/unit_JsonUtil_parseint.cpp @@ -0,0 +1,55 @@ +// @module JsonUtil + +// Pins the string→int conversion the flat readers share (mm::json::parseIntStr, and parseInt +// through it). The interesting half is what a NON-number does: every caller treats the result as +// a value to clamp or compare, so "not a number" has to arrive as the fallback rather than as a +// plausible-looking integer. + +#include "doctest.h" +#include "core/JsonUtil.h" + +#include <climits> + +using namespace mm; + +TEST_CASE("a JSON integer value is read up to the character that ends it") { + // Digits run until the JSON punctuation that follows them — the reader is handed a pointer + // into the middle of a body, not a clean NUL-terminated number. + CHECK(json::parseIntStr("7,\"next\":1") == 7); + CHECK(json::parseIntStr("42}") == 42); + CHECK(json::parseIntStr("-5,") == -5); + CHECK(json::parseIntStr("0}") == 0); +} + +TEST_CASE("text that does not start with a number reads as the fallback, not as zero-by-accident") { + // The distinction atoi cannot make: it returns 0 for both "0" and "abc". A caller that treats + // 0 as "absent" needs the two to be separable, so the fallback is explicit. + CHECK(json::parseIntStr("abc") == 0); + CHECK(json::parseIntStr("") == 0); + CHECK(json::parseIntStr(nullptr) == 0); + CHECK(json::parseIntStr("abc", -1) == -1); + CHECK(json::parseIntStr("", -1) == -1); + // A real zero still reads as zero — the fallback must not swallow the valid value. + CHECK(json::parseIntStr("0", -1) == 0); +} + +TEST_CASE("a value too large to represent reads as the fallback instead of wrapping") { + // The case that made this worth sharing: atoi on an out-of-range value is undefined + // behavior, and a caller narrowing the result would store a DIFFERENT valid number — + // a Hue id of "65537" becoming light 1. Out-of-range must be visible, not silently wrapped. + CHECK(json::parseIntStr("99999999999999999999") == 0); + CHECK(json::parseIntStr("99999999999999999999", -1) == -1); + CHECK(json::parseIntStr("-99999999999999999999", -1) == -1); + // The boundaries themselves still convert. + CHECK(json::parseIntStr("2147483647") == INT_MAX); + CHECK(json::parseIntStr("-2147483648") == INT_MIN); +} + +TEST_CASE("parseInt reads a key's integer value, and absent keys read as zero") { + CHECK(json::parseInt("{\"a\":1,\"b\":22}", "b") == 22); + CHECK(json::parseInt("{\"a\": 7}", "a") == 7); // space after the colon (json.dumps) + CHECK(json::parseInt("{\"a\":1}", "missing") == 0); + CHECK(json::parseInt(nullptr, "a") == 0); + // A non-numeric value for a present key is not a number: 0, same as absent. + CHECK(json::parseInt("{\"a\":\"text\"}", "a") == 0); +} diff --git a/test/unit/light/unit_SingleColumnLayout.cpp b/test/unit/light/unit_SingleColumnLayout.cpp new file mode 100644 index 00000000..30c00dd1 --- /dev/null +++ b/test/unit/light/unit_SingleColumnLayout.cpp @@ -0,0 +1,108 @@ +// @module SingleColumnLayout +// @also GridLayout + +// Pins the vertical-column layout: index order, spatial offset, and reversed wiring. +// +// The cross-check against GridLayout is the load-bearing one. A 1-wide, N-high grid and an N-high +// column describe the SAME strip, so the two must emit identical coordinates; anything else means +// one of them is wrong, and only a direct comparison catches a layout that is self-consistently +// wrong. GridLayout had a test and this had none, which is why the pair is asserted here rather +// than each in isolation. + +#include "doctest.h" +#include "light/layouts/GridLayout.h" +#include "light/layouts/SingleColumnLayout.h" + +#include <vector> + +namespace { + +struct CoordEntry { + mm::nrOfLightsType idx; + mm::lengthType x, y, z; +}; + +void collectCoord(void* ctx, mm::nrOfLightsType idx, mm::lengthType x, mm::lengthType y, mm::lengthType z) { + static_cast<std::vector<CoordEntry>*>(ctx)->push_back({idx, x, y, z}); +} + +std::vector<CoordEntry> coordsOf(const mm::LayoutBase& layout) { + std::vector<CoordEntry> out; + layout.forEachCoord(mm::CoordSink{collectCoord, nullptr, &out}); + return out; +} + +} // namespace + +// Indices are contiguous 0..N-1 and every light sits at the configured x — a gap or a repeat here +// is a light the driver would never write, so the strip would have a permanently dark pixel. +TEST_CASE("SingleColumnLayout of height 10 emits ten consecutively indexed lights") { + mm::SingleColumnLayout column; + column.height = 10; + + CHECK(column.lightCount() == 10); + + const auto coords = coordsOf(column); + REQUIRE(coords.size() == 10); + for (mm::nrOfLightsType i = 0; i < 10; i++) { + CHECK(coords[i].idx == i); // contiguous, no holes + CHECK(coords[i].x == 0); // the default X position + CHECK(coords[i].y == static_cast<mm::lengthType>(i)); + CHECK(coords[i].z == 0); + } +} + +// The two layouts must agree: a 1×10×1 grid and a 10-high column are the same physical strip, so +// they produce identical coordinates in identical order or one of them is wrong. +TEST_CASE("A 10-high column emits the same coordinates as a 1x10x1 grid") { + mm::SingleColumnLayout column; + column.height = 10; + + mm::GridLayout grid; + grid.width = 1; + grid.height = 10; + grid.depth = 1; + + CHECK(column.lightCount() == grid.lightCount()); + + const auto a = coordsOf(column); + const auto b = coordsOf(grid); + REQUIRE(a.size() == b.size()); + for (size_t i = 0; i < a.size(); i++) { + CHECK(a[i].idx == b[i].idx); + CHECK(a[i].x == b[i].x); + CHECK(a[i].y == b[i].y); + CHECK(a[i].z == b[i].z); + } +} + +// `starting Y` offsets the coordinates but NOT the indices: the driver writes light 0 first +// whatever the column's position in space, so an offset that shifted indices would leave the +// first `start_y` lights of the strip unwritten. +TEST_CASE("starting Y moves the column in space without renumbering its lights") { + mm::SingleColumnLayout column; + column.start_y = 5; + column.height = 4; + + const auto coords = coordsOf(column); + REQUIRE(coords.size() == 4); + CHECK(coords[0].idx == 0); + CHECK(coords[0].y == 5); + CHECK(coords[3].idx == 3); + CHECK(coords[3].y == 8); +} + +// Reversed wiring flips which end of the strip is light 0 — the y values run high to low while +// the indices still start at 0 and stay contiguous. +TEST_CASE("reversed order walks the column from the far end while keeping indices contiguous") { + mm::SingleColumnLayout column; + column.height = 4; + column.reversed_order = true; + + const auto coords = coordsOf(column); + REQUIRE(coords.size() == 4); + CHECK(coords[0].idx == 0); + CHECK(coords[0].y == 3); + CHECK(coords[3].idx == 3); + CHECK(coords[3].y == 0); +} From 1aff3df2fd7b40f241c9de9d447cc66c50dc69bb Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Thu, 30 Jul 2026 16:16:19 +0200 Subject: [PATCH 3/5] Name the LED peripheral by its silicon; shrink the JSON int parse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- CMakeLists.txt | 1 + docs/MIGRATING.md | 18 ++++++++- docs/backlog/backlog-light.md | 1 + docs/history/README.md | 2 +- docs/history/lessons.md | 22 +++++++++++ docs/metrics/repo-health.json | 42 ++++++++++----------- docs/metrics/repo-health.md | 22 +++++------ esp32/main/CMakeLists.txt | 1 + moondeck/MoonDeck.md | 2 +- moondeck/check/check_clang_query.py | 5 ++- moondeck/check/check_codeql.py | 23 +++++++++-- moondeck/check/check_nonblocking.py | 40 +++++++++++--------- moondeck/moondeck.py | 13 +++++-- src/core/JsonUtil.cpp | 28 ++++++++++++++ src/core/JsonUtil.h | 18 +++------ src/light/drivers/HueDriver.h | 5 +++ src/light/drivers/MoonLedDriver.h | 8 +++- src/light/drivers/MultiPinLedDriver.h | 15 +++++++- src/light/drivers/NetworkSendDriver.h | 6 ++- test/unit/core/unit_JsonUtil_parseint.cpp | 7 ++++ test/unit/light/unit_SingleColumnLayout.cpp | 15 +++++--- web-installer/deviceModels.json | 8 ++-- 22 files changed, 213 insertions(+), 89 deletions(-) create mode 100644 src/core/JsonUtil.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index dc2facf0..c7cda635 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -85,6 +85,7 @@ find_program(UV_EXECUTABLE NAMES uv REQUIRED # here so both the application and the test binary link them. add_library(mm_core STATIC src/core/Control.cpp + src/core/JsonUtil.cpp src/core/ScratchBuffer.cpp src/core/HttpServerModule.cpp src/core/FilesystemModule.cpp diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index a6a844be..473d2194 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -20,14 +20,28 @@ projectMM ships **no migration code**: the persistence layer is robust by defaul ## Unreleased (`next-iteration`) +### The `peripheral` options are renamed to name the peripheral, not the bus protocol (2026-07-30) + +The `peripheral` dropdown no longer says `i80` / `MoonI80`. "i80" is the Intel 8080 bus shape `esp_lcd` speaks — it is not a peripheral any ESP32 datasheet lists, and it matched nothing a user could look up: on the classic ESP32 that backend **is the I2S peripheral**, on the S3/P4/S31 it is the **LCD** peripheral. The new labels name the silicon block plus who drives it, which is the actual choice being made. + +| Old | New (classic ESP32) | New (S3 / P4 / S31) | +|---|---|---| +| `i80` | `I2S-IDF` | `LCD-IDF` | +| `MoonI80` | — (not available) | `LCD-MM` | +| `Parlio` | — | `Parlio` (unchanged — it *is* the peripheral's name) | + +`-IDF` = driven through ESP-IDF's `esp_lcd`; `-MM` = driven by our own GDMA layer below it, which is what buys the streaming ring and the 74HCT595 pin expander. + +**Action: re-set the `peripheral` control** — but only on a device that already holds a persisted parallel driver AND had a non-default peripheral selected. The stored string no longer matches any option, so the loader falls back to the board's default backend; if that was already your choice, nothing changes. The web installer's board catalog ships the new names, so a fresh install or catalog re-inject is correct without action. + ### The three parallel LED drivers merge into one `ParallelLedDriver` with a `peripheral` selector (2026-07-23) `MultiPinLedDriver`, `MoonLedDriver`, and `ParlioLedDriver` are now one registered module, **`ParallelLedDriver`**, whose `peripheral` control picks which DMA peripheral drives the parallel WS2812 bus. They were always the same driver with a different bus backend; the merge makes that one card with a dropdown, offering only the peripherals the chip supports. | Old registered type | New | |---|---| -| `MultiPinLedDriver` | `ParallelLedDriver` + `peripheral` = `i80` (esp_lcd: LCD_CAM on S3/P4, I2S on classic) | -| `MoonLedDriver` | `ParallelLedDriver` + `peripheral` = `MoonI80` (own-GDMA below esp_lcd, LCD_CAM) | +| `MultiPinLedDriver` | `ParallelLedDriver` + `peripheral` = `i80` (esp_lcd: LCD_CAM on S3/P4, I2S on classic) — renamed again below | +| `MoonLedDriver` | `ParallelLedDriver` + `peripheral` = `MoonI80` (own-GDMA below esp_lcd, LCD_CAM) — renamed again below | | `ParlioLedDriver` | `ParallelLedDriver` + `peripheral` = `Parlio` (P4) | **Action: re-add the driver.** A persisted module whose type is one of the three old names no longer resolves (the type isn't registered), so the robust loader drops it on boot — the driver, and its pins/settings, vanish from the tree. Add a **Parallel LED** driver again, choose the `peripheral` your board uses (the same backend the old type named — see the table), and re-enter its `pins` / `ledsPerPin` plus whatever the chosen peripheral needs: `i80` has `clockPin`/`dcPin`, `MoonI80` has `clockPin` + the ring/expander controls, `Parlio` has no clock or DC pins at all. The web installer's board catalog already names the new type, so a fresh install or a catalog re-inject wires it correctly; only a device carrying an OLD persisted tree needs the manual re-add. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index b28f6e74..f84147b3 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -9,6 +9,7 @@ Forward-looking to-build items for the **light domain** (`src/light/`: drivers, The ring's two regimes ship and are wall-verified through 48 strands × 256 (12,288 lights): prime-only when the frame fits the pool, the clock-oracle lapping ring above it (the near-prime pool — the ISR encodes only `nSlices − ringBufs` slices per frame), with `ringAuto` deriving the geometry per config and `shiftOverclock` trading the fps ceiling against '595 shift margin. The mechanism lives in the code + the technical page; the design arc in `docs/history/plans/` (the MoonI80 plans, all marked). Open items: - **Last-8-panels white flash — the "44-46 flash" (OPEN, cause not yet found; 6 theories ruled out).** On the 48×256 wall, brief WHITE/bright flashes (not wrong colors) over an otherwise-correct image, confined to strands 40-47 (the last 8 panels = the last physical data pin, GPIO 17 = bus bit 5 = the 6th and final 74HC595 in the daisy chain), mostly panels 44-46, wandering within the last 8. **Brightness-gated: clean below 5, flashes at ≥5** — since brightness scales pixel values through a LUT, below 5 the frame collapses to near-all-zero bits, so the gate really means *the flash needs SET (one) data bits on that pin*. **The same physical wall runs clean on hpwit's driver, so it is OUR driver, not the hardware.** Ruled out by live hardware tests (do NOT re-chase): (1) the encode source (PSRAM vs internal — the ISR-source staging fix did nothing, reverted); (2) the ISR/lapping regime (the flash is on prime-encoded rows too); (3) it being a tail-*rows* phenomenon (it is per-strand-group); (4) shift-clock margin via `shiftOverclock` (already at the 20 MHz OFF setting, still flashes); (5) the encoder itself (`ParallelSlots.h` proven byte-for-byte correct for this geometry by a host compile, incl. a pin-5 walking-one test — no all-HIGH "white" value ever appears in a pin-5 data word); (6) bus-bit latch adjacency (a `latchBitHigh` diagnostic moved the latch off bit-5's neighbour to bit 7 — still flashes). **Reopened clue:** the shift-register analysis doc records hpwit running the '595 SRCLK at **19.2 MHz**, *slower* than our `shiftOverclock`-OFF **20 MHz** — we treated 20 MHz as "the slow floor" but it is above his proven-good rate; the ~4% could be the deepest chip's margin. Untested angles to try next: driving the '595 clock below 20 MHz (needs a new divider — 16 MHz is all-white, so the window is narrow); GPIO-17 drive-strength / edge-rate specifically; whether it follows bit-5-*position* or GPIO-17 (swap which strands ride bit 5 via the pin order); the `ringPad` inter-slice settle window; and a direct A/B of our per-slice frame timing vs hpwit's for the last chip. All diagnostics from this investigation (the `latchBitHigh` toggle, the ISR-staging code) are reverted — the tree is clean. +- **MoonI80 direct mode flickers below ~30 lights on a PSRAM frame buffer (OPEN, cause unproven).** Ten GRBW lights on an SE16 (S3, octal PSRAM, one strand) flicker continuously and periodically report `no LED output` — the dead-frame guard after 8 consecutive `busWait` timeouts, i.e. the transfer never signalled completion. Above ~30 lights it is clean, and **i80 and RMT drive the same wiring perfectly**, so it is not the encoder, the layout, the wire, or the strip. **Established:** moving the buffer from PSRAM to internal RAM makes it stop outright (bench-verified). **Not established:** why only small frames — the stall-to-frame ratio (2.9% at 10 lights), the wait budget (*more* generous at small sizes: 55x the wire time vs 2.8x at 1000 lights) and PSRAM alignment padding (zero; the frame is already 64-byte aligned) were all checked and all fail to explain it. Leading untested theory: a short frame gives the DMA no runway to prefetch through a PSRAM/cache-contention stall. **An internal-RAM fallback for small frames was written, measured and deliberately reverted** — internal RAM is the scarce pool, MoonI80 targets large fixtures, and a short strand is the i80 backend's job; a patch that spends scarce RAM to hide an unexplained cause is worse than the open bug. Next instrument: `loopbackTest` + `loopbackIntrusive`, which captures what the peripheral actually emitted and separates a corrupt frame from a stalled transfer (needs the RX jumper pin — the SE16 routes its LED outputs, so pick one that can read back). Full write-up in [lessons.md](../history/lessons.md). - **Ring bus init hard-fails instead of stepping down when `ringBufs` is raised past what RAM allocates** (wall went dark until the control was lowered again; the pool alloc steps down but a later allocation, likely the descriptor link list, does not). A control change must degrade, never dark the output. - **~1-frame white/colored flash every ~5 s** seen at some configs — plausibly fixed by the frame-close latch word (a strand whose last data bit ended HIGH missed its reset that frame); soak-observe on the wall before closing. - **Prime barrier fps cost.** The ring's prime holds a busy-wait until the previous frame's deterministic wire end (`waitWireDrained` in `primeRingRange` — the barrier that keeps the next prime off buffers the DMA is still draining; the frame's last slices lap into the FIRST buffers, so the prime hits them first and no counter sees the repaint). The wait is ~0 when the snapshot + render gap already span the wire, but on fast frames it serializes wire → prime and caps fps at 1/(wire + snapshot + prime). If the fps work wants that overlap back, the barrier can go finer-grained (per-buffer: buffer b is safe once the drain passes slice `b + nSlices − ringBufs`) — measure first. Do NOT drain-gate `done` in the ISR instead (deadlocks; wall-measured as flicker-then-"no LED output"). diff --git a/docs/history/README.md b/docs/history/README.md index 1a307bda..d9c50209 100644 --- a/docs/history/README.md +++ b/docs/history/README.md @@ -33,7 +33,7 @@ One-time surveys of earlier projects, used to decide what to harvest into projec ### The plan archive -[`plans/`](plans/README.md) holds 89 approved feature plans from before plans became temporary. Under the current rule ([CLAUDE.md § Branch](../../CLAUDE.md#branch)) a plan's text goes into its PR description and the file is deleted once the plan ships, so nothing new is added here. These files predate that: they follow the older kept-forever convention, with the outcome marked in the filename (`… (shipped).md`, `… (attempted, abandoned).md`, unmarked = never finished). Reference only, and a candidate for the same subtraction the rest of `history/` gets — the merged PRs are the permanent record of what these describe. +[`plans/`](plans/README.md) holds 89 approved feature plans from before plans became temporary. Under the current rule ([CLAUDE.md § Branch](../../CLAUDE.md#branch)) a plan's text goes into its PR description and the product owner may delete the file once the plan is realized, so nothing new is added here. These files predate that: they follow the older kept-forever convention, with the outcome marked in the filename (`… (shipped).md`, `… (attempted, abandoned).md`, unmarked = never finished). Reference only, and a candidate for the same subtraction the rest of `history/` gets — the merged PRs are the permanent record of what these describe. ### Our own lessons diff --git a/docs/history/lessons.md b/docs/history/lessons.md index 35df5c89..870c5fcc 100644 --- a/docs/history/lessons.md +++ b/docs/history/lessons.md @@ -377,3 +377,25 @@ Both bugs presented identically ("output stalled, `wireUs=—`, reboot to fix"), - **A global hook keyed off a static "active" seat is invisible at its call sites.** `notifyQuiesceRender()` reads innocent — but it always hits whatever `Drivers::active()` currently points at, which is never the caller when the caller is a probe. A hook that reaches "the live one" needs an attached/owned check at the fire site, or it fires for instances that have no business touching the live system. - **"Board-specific" was a timing artifact, not a hardware fact.** The freeze was identical on every board; only the *visibility* differed with core speed. Chasing "what's different about the S31" (clock, IDF-beta SMP, RMT refill) burned days. The break came from a deterministic, board-agnostic trigger (`GET /api/types` alone kills the worker) captured with a `this`-pointer printf that showed the teardown hitting the *live* Drivers, not the probe. **When "only board X does it" resists every hardware theory, find the software action that reproduces it on demand and instrument identity (which instance), not just occurrence (that it happened).** - **The switch state is not the engaged state.** `multicore == true` while the split was actually disengaged sent the whole investigation sideways more than once. A control that can read ON while its mechanism is OFF is its own trap; the trustworthy signals were the FreeRTOS task list (`mmEncode` present?) and `renderWait` (a number vs `—`). + +## MoonI80 flickers below ~30 lights on a PSRAM frame buffer — OPEN, cause unproven + +Ten GRBW lights on an SE16 (S3, octal PSRAM, one strand, direct mode) flicker continuously and periodically report `no LED output — the driver is not sending frames`. Above ~30 lights it is clean. **i80 and RMT drive the same wiring perfectly**; only MoonI80 fails. Still open — recorded so the next attempt starts from the evidence rather than from scratch. + +**What is established.** Moving the direct-mode frame buffer from PSRAM to internal RAM makes the flicker stop outright (bench-verified: symptom gone, PSRAM in use dropped by exactly one frame). So the failure is tied to the buffer's memory pool, not to the encoder, the layout, the wire, or the strip. The give-up message means `busWait` timed out 8 times in a row: the transfer never signalled completion at all, which is a hard failure, not marginal timing. + +**What is NOT established: why only small frames.** Three explanations were checked against arithmetic and all three fail: +- *Stall-to-frame ratio* — a ~20 µs PSRAM stall is 2.9% of a 10-light frame. Far too small to break it. +- *Wait budget too tight* — the budget is **more** generous at small sizes (55x the wire time at 10 lights vs 2.8x at 1000). Backwards. +- *PSRAM alignment padding* — zero at every size; the frame is already 64-byte aligned. + +The leading untested theory is that a short frame gives the DMA no runway to prefetch through a PSRAM/cache-contention stall, where a long frame's DMA runs far enough ahead of the wire to ride it out. The instrument that would settle it is `loopbackTest` with `loopbackIntrusive` — it captures what the peripheral actually emitted, separating a corrupt frame from a stalled transfer. + +**Why no workaround shipped.** An internal-RAM fallback for small frames was written, measured, and then deliberately reverted: internal RAM is the scarce pool (WiFi/HTTP/stacks share it), MoonI80 exists for large fixtures, and a short strand is the i80 backend's job anyway. A patch that spends scarce RAM to hide a cause nobody understands is worse than the open bug. + +**General lessons:** +- **"Sustains the rate" and "completes a short transfer" are different claims.** The comment justifying PSRAM-primary is right about throughput at 2.67 MHz and says nothing about a latency-dominated short frame — the sort of authoritative-sounding comment that ends an investigation early. +- **A backend that works everywhere except one path is a configuration difference, not silicon.** i80 clean + MoonI80 broken on identical wiring narrowed this to code the two do not share. +- **Read a control's applicability before drawing conclusions from its value.** `doubleBuffer`, `useRing` and `ringSnapshot` all appear in `/api/state` while being inapplicable to this path (`supportsDoubleBuffer()` is hard `false` here; the ring controls are hidden unless `pinExpanderMode()`, and `wantsRing()` returns false in direct mode outright). Two hypotheses were built on those values and both were dead ends — the UI hides them for a reason, the API does not. +- **A frame-time KPI that does not scale with the frame is measuring a timeout, not a wire.** `frameTime` read ~741 µs flat from 10 to 600 lights. Flat where it should scale is a signal in itself. +- **A fix that works is not a cause that is understood.** The pool swap reliably removes the symptom, which is tempting to write up as a root cause; the arithmetic says the mechanism is still unknown. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 2d8d2e14..bd54c3a1 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,18 +1,18 @@ { - "commit": "8d491cbc", + "commit": "a1ef9ec2", "flash": { - "esp32": 1678368, - "esp32p4-eth": 1498160, + "esp32": 1678528, + "esp32p4-eth": 1497488, "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1668336, + "esp32s3-n16r8": 1666768, "esp32s3-n8r8": 1666592, "esp32s31": 1919136, - "desktop": 942648 + "desktop": 942680 }, "perf": { "desktop": { - "tick_us": 126, - "fps": 7936 + "tick_us": 128, + "fps": 7812 }, "esp32": { "tick_us": 4164, @@ -20,21 +20,21 @@ } }, "loc": { - "core": 14857, - "light": 19571, + "core": 14879, + "light": 19593, "platform": 12056, "ui": 5811, - "test": 34719, - "moondeck": 19477 + "test": 34731, + "moondeck": 19506 }, "comments": { "core": { - "lines": 5566, - "ratio": 0.408 + "lines": 5578, + "ratio": 0.409 }, "light": { - "lines": 7508, - "ratio": 0.423 + "lines": 7525, + "ratio": 0.424 }, "platform": { "lines": 3994, @@ -45,12 +45,12 @@ "ratio": 0.278 }, "test": { - "lines": 5966, + "lines": 5972, "ratio": 0.199 }, "moondeck": { - "lines": 3097, - "ratio": 0.182 + "lines": 3112, + "ratio": 0.183 } }, "tests": { @@ -59,10 +59,10 @@ }, "docs": { "md_files": 169, - "md_lines": 22536, + "md_lines": 22573, "plans_files": 89, - "backlog_lines": 3113, - "lessons_lines": 379, + "backlog_lines": 3114, + "lessons_lines": 401, "claude_md_lines": 135 }, "complexity": { diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 74b19f41..08ee42a4 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `8d491cbc`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run — **do not edit by hand**. +Measured at `a1ef9ec2`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run — **do not edit by hand**. Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -10,9 +10,9 @@ Current state only; the trend is this file's git history (`git log -p docs/metri |---|---:| | desktop | 921 KB | | esp32 | 1,639 KB | -| esp32p4-eth | 1,463 KB | +| esp32p4-eth | 1,462 KB | | esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,629 KB (+2 KB) ⚠ | +| esp32s3-n16r8 | 1,628 KB | | esp32s3-n8r8 | 1,628 KB | | esp32s31 | 1,874 KB | @@ -20,19 +20,19 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Tick | FPS | |---|---:|---:| -| desktop | 126 µs (−14 µs) ✓ | 7,936 (+794) ✓ | +| desktop | 128 µs | 7,812 | | esp32 | 4,164 µs | 240 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 14,857 | 5,566 | 40.8 % | -| light | 19,571 | 7,508 | 42.3 % | +| core | 14,879 | 5,578 | 40.9 % | +| light | 19,593 | 7,525 | 42.4 % | | platform | 12,056 | 3,994 | 36.7 % | | ui | 5,811 | 1,518 | 27.8 % | -| test | 34,719 | 5,966 | 19.9 % | -| moondeck | 19,477 | 3,097 | 18.2 % | +| test | 34,731 | 5,972 | 19.9 % | +| moondeck | 19,506 | 3,112 | 18.3 % | ## Tests @@ -54,9 +54,9 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Metric | Value | |---|---:| | markdown files | 169 | -| markdown lines | 22,536 | +| markdown lines | 22,573 | | plan files | 89 | -| backlog lines | 3,113 | -| lessons lines | 379 | +| backlog lines | 3,114 | +| lessons lines | 401 | | CLAUDE.md lines | 135 | diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index df305a7b..f2853fcf 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -3,6 +3,7 @@ idf_component_register( "main.cpp" "../../src/main.cpp" "../../src/core/Control.cpp" + "../../src/core/JsonUtil.cpp" "../../src/core/ScratchBuffer.cpp" "../../src/core/HttpServerModule.cpp" "../../src/core/FilesystemModule.cpp" diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 26d0edd2..1788b0a1 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -486,7 +486,7 @@ not be resolved from source. | Column | | |---|---| -| **COND** | the branch guarding the call: `—` none (runs every time its tick does), `if`, `loop`, `switch`, `?:`, `&&`, and `ret` for a call reached only past an `if (…) return;` above it. `·rate` marks a rate limiter (`if (++n >= kEvery)`, `if (now - last < kIntervalMs) return;`) or a once-only latch (`if (!inited_)`). `?` means clang-query was unavailable, which is *unknown*, not unguarded | +| **COND** | the branch guarding the call: `—` none (runs every time its tick does), `if`, `loop`, `switch`, `?:`, `&&`, and `ret` for a call reached only past an `if (…) return;` above it. `·rate` marks a rate limiter (`if (++n >= kEvery)`, `if (now - last < kIntervalMs) return;`) or a once-only latch (`if (!inited_)`). `?` means the guard analysis did not run — clang-query missing, a matcher it refused, or the query erroring — which is *unknown*, not unguarded | | **CALLS** | the function that blocks — or `(static local variable)`, a violation with no callee: a static local needs a guard variable and a one-time lock on first use | | **IN** | the method the call sits in, which is what places it in a tier. Clang names the call and the callee but *not* their enclosing function, so this is read back from the source | | **WHY IT BLOCKS** | clang's own root cause, e.g. `calls mm::platform::UdpSocket::sendTo`. `—` means a leaf the compiler could not look inside (external or unannotated) | diff --git a/moondeck/check/check_clang_query.py b/moondeck/check/check_clang_query.py index af9c897f..3c932c78 100644 --- a/moondeck/check/check_clang_query.py +++ b/moondeck/check/check_clang_query.py @@ -982,7 +982,10 @@ def main(): # first version appended `--extra-arg`, so every transitive header resolved to the # REAL unmarked file and `//` counts silently halved. If NOTHING carries a dev # comment, the rewrite did not reach the headers — say so instead of reporting it. - if rows and not any(r["dev_words"] for r in rows): + # NO `rows and` guard: an EMPTY result is the same false zero, only more so — the + # matcher found not one declaration in a tree of thousands. Skipping the check when + # rows was empty let the worst case through as a clean report. + if not any(r["dev_words"] for r in rows): print("[comments] no `//` comment found anywhere — the shadow tree did not reach " "the headers (include order?). Refusing to report a false zero.", file=sys.stderr) diff --git a/moondeck/check/check_codeql.py b/moondeck/check/check_codeql.py index 8bc432c5..1a2acd48 100644 --- a/moondeck/check/check_codeql.py +++ b/moondeck/check/check_codeql.py @@ -92,19 +92,34 @@ def fetch(state): 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. + # + # FAIL CLOSED on anything that is not a clean run of arrays. Blank stdout, a truncated body + # or a malformed page all used to `break` out of the loop and return an empty list as + # SUCCESS — which renders as "No open alerts. ✓", the one reading this script exists to + # prevent (a tool that could not look must never report a clean answer). dec = json.JSONDecoder() text = p.stdout.strip() - i = 0 + if not text: + return None, (f"`gh` returned nothing for the {st} alerts of {repo}. That is not an " + f"empty result — the request produced no body at all.") + i, pages = 0, 0 while i < len(text): try: val, end = dec.raw_decode(text, i) except ValueError: - break - if isinstance(val, list): - out.extend(val) + return None, (f"Could not parse the {st} alerts of {repo}: the response is not " + f"valid JSON from byte {i} on (truncated or interleaved output).") + if not isinstance(val, list): + return None, (f"Unexpected {st}-alert response from {repo}: a JSON " + f"{type(val).__name__}, not the array of alerts the API returns.") + out.extend(val) + pages += 1 i = end while i < len(text) and text[i] in " \t\r\n": i += 1 + if pages == 0: + return None, (f"No alert pages parsed for {st} on {repo} — the response held no JSON " + f"array, so nothing could be read.") return out, None diff --git a/moondeck/check/check_nonblocking.py b/moondeck/check/check_nonblocking.py index bf108976..716c6152 100644 --- a/moondeck/check/check_nonblocking.py +++ b/moondeck/check/check_nonblocking.py @@ -213,17 +213,28 @@ def write_baseline(rows): rf"\w*{_LATCH_WORD}_?\s*==\s*false") -def _rate_on_line(rel_path, line, _cache={}): - """True when THIS one line spells a rate limiter or a once-only latch — no surrounding scan. +def _src_lines(rel_path, _cache={}): + """The file's lines, cached per path; `[]` when it does not exist. - Used for early exits, where the condition and the `return` share a line and any wider window - would read a neighbouring block that does not guard the call at all. + One reader for the three text heuristics (`_rate_on_line`, `rate_hint`, `_enclosing_def`) — + they each carried an identical read-and-memoize block, so a change to the caching rule had to + be made in three places to stay correct. """ src = _cache.get(rel_path) if src is None: f = ROOT / rel_path src = _cache[rel_path] = (f.read_text(encoding="utf-8", errors="replace").split("\n") if f.exists() else []) + return src + + +def _rate_on_line(rel_path, line): + """True when THIS one line spells a rate limiter or a once-only latch — no surrounding scan. + + Used for early exits, where the condition and the `return` share a line and any wider window + would read a neighbouring block that does not guard the call at all. + """ + src = _src_lines(rel_path) if not 0 < line <= len(src): return False return bool(_RATE.search(src[line - 1]) or _LATCH.search(src[line - 1])) @@ -261,7 +272,10 @@ def guard_forms(rows, build_dir, tool): The caller then renders the column as `?`, because a tool that could not look must never read as "nothing is guarded", which is the comfortable answer and the wrong one. """ - if not tool: + if not tool or not rows: + # No findings means nothing to annotate. Bail BEFORE resolving TUs: `including_tus` falls + # back to every TU when handed an empty set, so this would otherwise run the full matcher + # sweep to label a table with no rows in it. return None # Every TU that includes a file with findings. Headers have no TU of their own — they are # analyzed through whichever .cpp includes them — so the set is resolved, not guessed. @@ -355,7 +369,7 @@ def _bound_sites(out): yield rel, int(line) -def rate_hint(rel_path, line, _cache={}): +def rate_hint(rel_path, line): """True when the guard above `line` looks like a rate limiter or a once-only latch. Read from the source text, not the AST: this asks what the condition MEANS, and a counter @@ -363,11 +377,7 @@ def rate_hint(rel_path, line, _cache={}): it can miss a rate limiter written another way, so it only ever ADDS a hint to a site the AST already called conditional. """ - src = _cache.get(rel_path) - if src is None: - f = ROOT / rel_path - src = _cache[rel_path] = (f.read_text(encoding="utf-8", errors="replace").split("\n") - if f.exists() else []) + src = _src_lines(rel_path) if not 0 < line <= len(src): return False # Walk UP out of the call's own block, stopping at the line that opened it — the guard. @@ -392,13 +402,9 @@ def rate_hint(rel_path, line, _cache={}): return False -def _enclosing_def(rel_path, line, _cache={}): +def _enclosing_def(rel_path, line): """`(name, 1-based line)` of the definition a call site sits in, or `("", 0)`.""" - src = _cache.get(rel_path) - if src is None: - f = ROOT / rel_path - src = _cache[rel_path] = (f.read_text(encoding="utf-8", errors="replace").split("\n") - if f.exists() else []) + src = _src_lines(rel_path) for i in range(min(line, len(src)) - 1, -1, -1): m = _DEF.match(src[i]) if m and len(m.group("indent")) <= 4 and m.group("name") not in _KEYWORDS: diff --git a/moondeck/moondeck.py b/moondeck/moondeck.py index 01050562..1a54ca26 100644 --- a/moondeck/moondeck.py +++ b/moondeck/moondeck.py @@ -113,13 +113,15 @@ def load_scripts(): FIRMWARES = _load_firmwares() -def _duration(seconds): +def _duration(seconds: float) -> str: """A run's wall time, read at a glance: `4.2s`, `1m12s`, `1h04m`. Seconds alone stop being readable somewhere around a minute — `312s` has to be divided in your head — and a clean rebuild here runs into the minutes. """ - s = int(seconds) + # Decide the sub-minute branch on the value AS DISPLAYED, not on int(): 59.96 truncates to 59 + # but renders "60.0s", a reading that does not exist on this scale. + s = int(round(seconds, 1)) if s < 60: return f"{seconds:.1f}s" if s < 3600: @@ -140,9 +142,12 @@ def bump_run_count(script_id): if isinstance(loaded, dict): counts = loaded # A hand-edited or half-written file can hold anything; a bad value restarts the count for - # that script rather than raising in the middle of reporting a successful run. + # that script rather than raising in the middle of reporting a successful run. `bool` is + # excluded explicitly — it IS an int in Python, so `True` would count as run #2 — and a + # negative restarts rather than counting up from below zero. prev = counts.get(script_id, 0) - n = (prev if isinstance(prev, int) else 0) + 1 + valid = type(prev) is int and prev >= 0 # noqa: E721 — bool must NOT satisfy this + n = (prev if valid else 0) + 1 counts[script_id] = n with suppress(OSError): RUNS_FILE.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/core/JsonUtil.cpp b/src/core/JsonUtil.cpp new file mode 100644 index 00000000..c64fcace --- /dev/null +++ b/src/core/JsonUtil.cpp @@ -0,0 +1,28 @@ +// Out-of-line half of JsonUtil.h. Only what must not be inlined lives here. +// +// The rest of the header is deliberately header-only (see its own comment): small first-match +// readers whose callers benefit from inlining. `parseIntStr` is the exception — its three +// validity checks are duplicated into every caller when inline, which measured 1712 bytes of +// flash on the S3 across the ~10 call sites. Every caller is off the hot path, so the call is +// free in practice. + +#include "core/JsonUtil.h" + +#include <cerrno> +#include <climits> +#include <cstdlib> + +namespace mm::json { + +int parseIntStr(const char* s, int fallback) { + if (!s) return fallback; + char* end = nullptr; + errno = 0; // strtol only ever SETS it on error + const long v = std::strtol(s, &end, 10); + if (end == s) return fallback; // no digits — not a number at all + if (errno == ERANGE) return fallback; // saturated: outside `long` + if (v < INT_MIN || v > INT_MAX) return fallback; // fits `long`, would not survive `int` + return static_cast<int>(v); +} + +} // namespace mm::json diff --git a/src/core/JsonUtil.h b/src/core/JsonUtil.h index 746be022..73b3c266 100644 --- a/src/core/JsonUtil.h +++ b/src/core/JsonUtil.h @@ -19,8 +19,6 @@ // Any malformed / truncated input fails cleanly (parse() returns false, accessors // return safe defaults) and never reads OOB. Off the hot path (boot load, control writes). -#include <cerrno> // ERANGE — parseIntStr's overflow signal on 32-bit `long` -#include <climits> // INT_MIN/INT_MAX — parseIntStr's range check #include <cstdint> #include <cstdio> #include <cstdlib> @@ -128,16 +126,12 @@ inline bool hasKey(const char* json, const char* key) { /// /// Trailing text is deliberately allowed: these values are read out of a JSON body, so digits are /// followed by `,` or `}`. Only the LEADING characters decide. -inline int parseIntStr(const char* s, int fallback = 0) { - if (!s) return fallback; - char* end = nullptr; - errno = 0; // strtol only ever SETS it on error - const long v = std::strtol(s, &end, 10); - if (end == s) return fallback; // no digits — not a number at all - if (errno == ERANGE) return fallback; // saturated: outside `long` - if (v < INT_MIN || v > INT_MAX) return fallback; // fits `long`, would not survive `int` - return static_cast<int>(v); -} +/// +/// OUT OF LINE, unlike its neighbours here. As an inline the three checks were duplicated into +/// every caller and cost **1712 bytes of flash on the S3** — measured per symbol: parseLights +552, +/// applyControlValue +560, parseGroups +249. One call instead is free in practice: every user is +/// off the hot path (boot load, control writes, a 1 Hz bridge poll). +int parseIntStr(const char* s, int fallback = 0); inline int parseInt(const char* json, const char* key) { if (!json || !key) return 0; diff --git a/src/light/drivers/HueDriver.h b/src/light/drivers/HueDriver.h index fe3111a0..d9de312e 100644 --- a/src/light/drivers/HueDriver.h +++ b/src/light/drivers/HueDriver.h @@ -419,6 +419,11 @@ class HueDriver : public DriverBase { /// "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) { + // Digits only. `strtol` skips leading whitespace and accepts a sign, so `" 7"` and `"+7"` + // would both read as light 7 — a key shape the bridge never emits, and accepting it means + // a malformed response silently addresses a REAL light. The scan hands us the character + // right after the opening quote, so the first one must already be a digit. + if (!s || *s < '0' || *s > '9') return 0; const int v = json::parseIntStr(s); return (v > 0 && v <= 0xFFFF) ? static_cast<uint16_t>(v) : 0; } diff --git a/src/light/drivers/MoonLedDriver.h b/src/light/drivers/MoonLedDriver.h index 40395e58..9436710e 100644 --- a/src/light/drivers/MoonLedDriver.h +++ b/src/light/drivers/MoonLedDriver.h @@ -271,7 +271,7 @@ class MoonI80Peripheral : public LedPeripheral { /// Status text when the bus will not come up, so the cause is on screen rather than in a serial log. /// The two real causes are named: a pin the peripheral cannot route, or no DMA-reachable memory for /// the frame (or the ring's pool). - const char* initFailMsg() const override { return "MoonI80 bus init failed — check pins / memory"; } + const char* initFailMsg() const override { return "LCD-MM: bus init failed — check pins / memory"; } /// The expander needs a backend that can stream its ×8 frame; LCD_CAM is it, and this backend is /// LCD_CAM-only, so the answer is simply "wherever this backend runs at all". bool supportsPinExpander() const override { return platform::hasLcdCam; } @@ -714,7 +714,11 @@ class MoonI80Peripheral : public LedPeripheral { // Register the MoonI80 (own-GDMA below esp_lcd) backend into the peripheral registry once, at // static-init. Gated by this header's CONFIG_SOC include in main.cpp (LCD_CAM chips only). No separate // driver class — the one ParallelLedDriver drives it, chosen via the `peripheral` control. +// +// "LCD-MM": the same LCD peripheral the IDF backend uses, driven by our own GDMA layer instead of +// esp_lcd — which is the whole difference a user is choosing between. No chip conditional here: this +// backend only ever registers on LCD_CAM silicon, so the peripheral is always the LCD one. inline const bool kMoonI80PeripheralRegistered = - ParallelLedDriver::registerPeripheral("MoonI80", []() -> LedPeripheral* { return new MoonI80Peripheral(); }); + ParallelLedDriver::registerPeripheral("LCD-MM", []() -> LedPeripheral* { return new MoonI80Peripheral(); }); } // namespace mm diff --git a/src/light/drivers/MultiPinLedDriver.h b/src/light/drivers/MultiPinLedDriver.h index c86083c5..f03b5685 100644 --- a/src/light/drivers/MultiPinLedDriver.h +++ b/src/light/drivers/MultiPinLedDriver.h @@ -129,7 +129,11 @@ class I80Peripheral : public LedPeripheral { if constexpr (platform::i2sLanes > 0) return LedHwBlock::I2s; else return LedHwBlock::LcdCam; } - const char* initFailMsg() const override { return "i80 bus init failed — check pins / memory"; } + const char* initFailMsg() const override { + // Names the same peripheral the label does, so the error and the dropdown agree. + return (platform::i2sLanes > 0) ? "I2S-IDF: bus init failed — check pins / memory" + : "LCD-IDF: bus init failed — check pins / memory"; + } /// Spare bus lanes (shift mode, when the board has fewer data pins than the bus is wide) park on /// WR: the peripheral already drives it and the board already wires it, so the lane is inert. @@ -272,7 +276,14 @@ class I80Peripheral : public LedPeripheral { // The label is what the `peripheral` Select shows. Gated by this header's own CONFIG_SOC include in // main.cpp, so it only registers on i80-capable silicon. There is no separate driver class: the one // registered ParallelLedDriver drives every backend, selected by the `peripheral` control. +// +// It names the PERIPHERAL, not the bus protocol. "i80" is the Intel 8080 bus shape esp_lcd speaks, and +// it matches nothing a user can look up: on the classic ESP32 this backend IS the I2S peripheral, on +// LCD_CAM chips it is the LCD peripheral (see hwBlock()). So the label follows the silicon — and says +// `-IDF` because this backend drives it through esp_lcd, against the MoonI80 backend's `-MM` (our +// own GDMA layer below esp_lcd). Peripheral + who drives it is the whole choice a user is making. +inline constexpr const char* kI80Label = (platform::i2sLanes > 0) ? "I2S-IDF" : "LCD-IDF"; inline const bool kI80PeripheralRegistered = - ParallelLedDriver::registerPeripheral("i80", []() -> LedPeripheral* { return new I80Peripheral(); }); + ParallelLedDriver::registerPeripheral(kI80Label, []() -> LedPeripheral* { return new I80Peripheral(); }); } // namespace mm diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index a7363ee0..1ae5a4a6 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -23,8 +23,10 @@ namespace mm { /// fanning out to N GPIO lanes. Unicast is the default, not an option among equals; broadcast /// survives only as legacy compatibility. /// -/// **Synchronous send:** the whole frame goes out inline in tick() (~35 ms Ethernet / ~90 ms WiFi at -/// 128×128 ArtNet; DDP less). A decoupling send task is a PSRAM-gated backlog item. Added per board +/// **Synchronous send:** this driver's WINDOW (`start`/`count`, not necessarily the whole buffer) +/// goes out inline in tick() — ~35 ms Ethernet / ~90 ms WiFi for a full-buffer window at 128×128 +/// ArtNet, less for DDP and proportionally less for a narrower window. A decoupling send task is a +/// PSRAM-gated backlog item. Added per board /// via the catalog like the LED drivers; applies the same shared Correction, so network and wired /// outputs show identical colors. /// diff --git a/test/unit/core/unit_JsonUtil_parseint.cpp b/test/unit/core/unit_JsonUtil_parseint.cpp index 60f32ae1..4181ab62 100644 --- a/test/unit/core/unit_JsonUtil_parseint.cpp +++ b/test/unit/core/unit_JsonUtil_parseint.cpp @@ -43,6 +43,12 @@ TEST_CASE("a value too large to represent reads as the fallback instead of wrapp // The boundaries themselves still convert. CHECK(json::parseIntStr("2147483647") == INT_MAX); CHECK(json::parseIntStr("-2147483648") == INT_MIN); + // ONE past each boundary is the case that separates the two overflow checks, and the one that + // differs by target: where `long` is 64-bit (desktop) these fit `long` and only the INT_MAX + // compare rejects them; where it is 32-bit (ESP32, Windows) strtol saturates and sets ERANGE. + // Both must reach the fallback, or a Hue id of "2147483648" would land as INT_MAX. + CHECK(json::parseIntStr("2147483648", -1) == -1); + CHECK(json::parseIntStr("-2147483649", -1) == -1); } TEST_CASE("parseInt reads a key's integer value, and absent keys read as zero") { @@ -50,6 +56,7 @@ TEST_CASE("parseInt reads a key's integer value, and absent keys read as zero") CHECK(json::parseInt("{\"a\": 7}", "a") == 7); // space after the colon (json.dumps) CHECK(json::parseInt("{\"a\":1}", "missing") == 0); CHECK(json::parseInt(nullptr, "a") == 0); + CHECK(json::parseInt("{\"a\":1}", nullptr) == 0); // no key to look for = absent // A non-numeric value for a present key is not a number: 0, same as absent. CHECK(json::parseInt("{\"a\":\"text\"}", "a") == 0); } diff --git a/test/unit/light/unit_SingleColumnLayout.cpp b/test/unit/light/unit_SingleColumnLayout.cpp index 30c00dd1..4ceef001 100644 --- a/test/unit/light/unit_SingleColumnLayout.cpp +++ b/test/unit/light/unit_SingleColumnLayout.cpp @@ -39,6 +39,7 @@ std::vector<CoordEntry> coordsOf(const mm::LayoutBase& layout) { TEST_CASE("SingleColumnLayout of height 10 emits ten consecutively indexed lights") { mm::SingleColumnLayout column; column.height = 10; + column.xposition = 3; // non-zero: `x == 0` would pass on the default whatever the code did CHECK(column.lightCount() == 10); @@ -46,7 +47,7 @@ TEST_CASE("SingleColumnLayout of height 10 emits ten consecutively indexed light REQUIRE(coords.size() == 10); for (mm::nrOfLightsType i = 0; i < 10; i++) { CHECK(coords[i].idx == i); // contiguous, no holes - CHECK(coords[i].x == 0); // the default X position + CHECK(coords[i].x == 3); // every light on the configured column CHECK(coords[i].y == static_cast<mm::lengthType>(i)); CHECK(coords[i].z == 0); } @@ -96,13 +97,17 @@ TEST_CASE("starting Y moves the column in space without renumbering its lights") // the indices still start at 0 and stay contiguous. TEST_CASE("reversed order walks the column from the far end while keeping indices contiguous") { mm::SingleColumnLayout column; + column.start_y = 5; // reversal must compose with the offset, not ignore it column.height = 4; column.reversed_order = true; + // Assert EVERY entry, not just the ends: an interior swap or a repeat leaves the first and + // last correct while the middle of the strip is wrong. const auto coords = coordsOf(column); REQUIRE(coords.size() == 4); - CHECK(coords[0].idx == 0); - CHECK(coords[0].y == 3); - CHECK(coords[3].idx == 3); - CHECK(coords[3].y == 0); + const mm::lengthType expectY[4] = {8, 7, 6, 5}; // start_y + height - 1 down to start_y + for (mm::nrOfLightsType i = 0; i < 4; i++) { + CHECK(coords[i].idx == i); // indices still ascend from 0 + CHECK(coords[i].y == expectY[i]); // coordinates descend + } } diff --git a/web-installer/deviceModels.json b/web-installer/deviceModels.json index 0532d633..25ac2a7e 100644 --- a/web-installer/deviceModels.json +++ b/web-installer/deviceModels.json @@ -872,7 +872,7 @@ "id": "ParallelLed", "parent_id": "Drivers", "controls": { - "peripheral": "i80", + "peripheral": "LCD-IDF", "pins": "47,21,14,9,8,16,15,7,1,2,42,41,40,39,38,48", "clockPin": 5, "dcPin": 6 @@ -931,7 +931,7 @@ "id": "ParallelLed", "parent_id": "Drivers", "controls": { - "peripheral": "i80", + "peripheral": "LCD-IDF", "pins": "47,48,21,38,14,39,13,40,12,41,11,42,10,2,3,1", "clockPin": 8, "dcPin": 9 @@ -1271,7 +1271,7 @@ "id": "ParallelLed", "parent_id": "Drivers", "controls": { - "peripheral": "MoonI80", + "peripheral": "LCD-MM", "pins": "9,10,12,8,18,17", "pinExpander": true, "latchPin": 46, @@ -1325,7 +1325,7 @@ "id": "ParallelLed", "parent_id": "Drivers", "controls": { - "peripheral": "MoonI80", + "peripheral": "LCD-MM", "pins": "9,10", "pinExpander": true, "latchPin": 46, From 5c8b208ab9f7b44fb93914f04f1aeb0da4f8794a Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Thu, 30 Jul 2026 23:11:00 +0200 Subject: [PATCH 4/5] Drive LED panel cards over raw Ethernet (PanelCardDriver) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- ...0260730 - Panel cards over raw Ethernet.md | 166 ++++++++ docs/metrics/repo-health.json | 56 +-- docs/metrics/repo-health.md | 36 +- docs/moonmodules/light/drivers.md | 23 ++ esp32/main/CMakeLists.txt | 6 + moondeck/MoonDeck.md | 74 +++- moondeck/build/build_esp32.py | 14 +- moondeck/check/check_clang_query.py | 49 ++- moondeck/check/check_footprint.py | 347 ++++++++++++++++ moondeck/check/check_module.py | 24 +- moondeck/check/check_nonblocking.py | 6 +- moondeck/moondeck_config.json | 9 + src/core/NetworkModule.h | 10 + src/light/ColorLight5A75Packet.h | 153 +++++++ src/light/drivers/MoonLedDriver.h | 173 ++++---- src/light/drivers/MultiPinLedDriver.h | 8 +- src/light/drivers/NetworkSendDriver.h | 14 +- src/light/drivers/PanelCardDriver.h | 373 ++++++++++++++++++ src/main.cpp | 13 + src/platform/desktop/platform_config.h | 5 + src/platform/desktop/platform_desktop.cpp | 150 +++++++ src/platform/esp32/platform_config.h | 4 + src/platform/esp32/platform_esp32.cpp | 72 ++++ src/platform/esp32/platform_esp32_i2s.cpp | 28 +- src/platform/esp32/platform_esp32_ota.cpp | 30 +- src/platform/esp32/platform_esp32_tasks.cpp | 38 +- src/platform/platform.h | 73 ++++ test/CMakeLists.txt | 2 + test/unit/light/unit_PanelCardDriver.cpp | 361 +++++++++++++++++ .../light/unit_PanelCardDriver_packet.cpp | 115 ++++++ 30 files changed, 2273 insertions(+), 159 deletions(-) create mode 100644 docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md create mode 100644 moondeck/check/check_footprint.py create mode 100644 src/light/ColorLight5A75Packet.h create mode 100644 src/light/drivers/PanelCardDriver.h create mode 100644 test/unit/light/unit_PanelCardDriver.cpp create mode 100644 test/unit/light/unit_PanelCardDriver_packet.cpp diff --git a/docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md b/docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md new file mode 100644 index 00000000..5bdd557d --- /dev/null +++ b/docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md @@ -0,0 +1,166 @@ +# Plan: driving LED panel cards over raw Ethernet (`PanelCardDriver`) + +## Context + +[Issue #58](https://github.com/MoonModules/projectMM/issues/58) asks to drive ColorLight receiver +cards from one of our boards. Today that rig is a Raspberry Pi running FPP. + +**The board renders and sends.** Effects, layers, MoonLive and the preset system are already on the +device, so the primary case is a self-contained panel controller: our own render output goes +straight out to the cards. Taking ArtNet or E1.31 in and forwarding it is one application of the +same driver, and it comes last. + +The two facts that shape the driver: the cards need a **1 Gbit link** (a wire-time constraint, +rather than bandwidth), and the transport is **raw Ethernet frames** rather than UDP. + +**Target: ESP32-S31.** It is the only board we ship with RGMII gigabit. The P4 is RMII/100 Mbit +(`platform_esp32.cpp:562`). + +### The open S31 Ethernet issue, and why this feature sits below it + +S31 Ethernet currently negotiates 1000M full duplex and transmits (activity LED, frames on the +wire), while DHCP does not complete: the signature points at the RGMII **RX** path needing +board-specific delay tuning (`Plan-20260726`). + +**This driver is TX-only over raw L2**, which is the half that already works, and it skips the two +layers the bug lives in: no DHCP, no lwIP, no IP address. `esp_eth_transmit` takes a frame with our +own destination MAC and puts it on the wire. + +So it is plausible this works on an S31 that still cannot get a DHCP lease, which also makes it a +useful bisect: **panels lighting up confirms TX and the 1 Gbit link independently of the IP stack.** +Two caveats keep this from being a free pass. Sustained gigabit TX is a heavier exercise of the +RGMII timing than DHCP's few frames, so a marginal link may show up here as corrupt pixels rather +than as no output. And step 4 needs RX, so the bug is a hard prerequisite there. + +**Step 1 therefore starts with a TX-only spike on the current firmware:** send a hand-built frame, +confirm it on the wire with a capture. That answers the risk before any driver code is written. + +## How this is written + +The wire format is documented by open implementations. We write our own code against the +documented byte layout, the same way our ArtNet, DDP and E1.31 support is built: the layout is a +fact about the format, and the code that acts on it is ours. Where a byte's purpose is +undocumented, the code marks it unknown. + +The driver is `PanelCardDriver` with a format selector, because the category is panel cards and +ColorLight 5A-75 is the first format it speaks. + +## Design + +One driver, one format per card family: the shape `NetworkSendDriver` already uses for +ArtNet/E1.31/DDP. + +``` +src/light/drivers/PanelCardDriver.h window, correction, chunking, sync +src/light/ColorLight5A75Packet.h the first wire format +src/platform/platform.h + ethSendRaw declaration +src/platform/esp32/platform_esp32.cpp + ethSendRaw implementation +src/platform/desktop/platform_desktop.cpp + ethSendRaw stub (host build runs everything) +``` + +**Reuse, do not reinvent:** `DriverBase` (window, preset, correction), `windowSlice`, the catalog +registration, the `Correction` pipeline, and the `*Packet.h` convention. The new code is the packet +builder and one platform seam. + +Linsn, Novastar and DBstar are candidates for later formats. Whether their layouts fit this +window/chunk model is unverified, so the architecture leaves room and the plan commits to one +format. + +## Steps + +### Step 1: the platform seam + +`platform::ethSendRaw(const uint8_t* frame, size_t len)`, ~20 lines, wrapping `esp_eth_transmit` +on the `esp_eth_handle_t` the Ethernet init already holds. Desktop gets a stub that records frames +so the host build and its tests still run everything (the desktop-runs-everything rule). + +**Exit:** a unit test that the desktop stub receives what the driver emits; `check_platform_boundary` +clean, with `esp_eth_*` confined to the platform layer. + +### Step 2: the packet builder + +`ColorLight5A75Packet.h`, written from the documented layout: + +- row data (per row, chunked at 497 px) +- sync/latch +- brightness + +Pure functions over a caller-supplied buffer, host-testable and platform-independent: the same +shape as `ArtNetPacket.h`. + +**Exit:** unit tests pinning each packet's bytes against the documented layout, including the +chunk boundary at 497 px and a row that needs two packets. + +### Step 3: the driver + +`PanelCardDriver.h`, sibling of `NetworkSendDriver`. Window from `DriverBase`, correction from the +shared pipeline, one row packet per row, then the sync. + +At this point the feature is complete for the primary case: **effects rendering on the board light +a real panel.** Send-only, with a manual layout. Card discovery (which needs raw L2 *receive*, a +separate seam) and multi-receiver layout are follow-ups. + +**Exit:** a scenario test driving a known pattern through the desktop stub; a real card showing the +correct image on the bench (**PO judgement, the gate that matters**). + +### Step 4: ArtNet in over the same link (application) + +With the driver working, a board on one network segment takes ArtNet or E1.31 in and forwards it to +the panels. + +Before building it, **measure**: feed an S31 ArtNet at increasing universe counts and find where it +breaks. We have no measured ArtNet-receive numbers on any board. The adjacent measurement that +makes this worth doing first: a ~19 ms encode on core 0 **starved the network stack** on an LC16, +with the link dropping and HTTP timing out while the render loop kept ticking (`lessons.md`). A +bridge is network-in *and* network-out, so both halves want the same core. + +That measurement decides two things: whether the input protocol wants to be DDP (480 px/packet) +rather than ArtNet (170 px/packet), and whether the dual-core split is required. + +**Exit:** a table of universes/s vs tick budget on real S31 hardware, and a decision on input +protocol and core placement. + +### Step 5: a separate input link, so the gigabit carries panels only + +**WiFi is already on the board, and it is the cheap version of this.** ArtNet in over WiFi, panels +out over RGMII: two interfaces, no new transport code, available the day step 4 works. + +``` + ArtNet/E1.31 in ──► [WiFi] S31 [RGMII 1 Gbit] ──► raw L2 ──► cards +``` + +This suits a controller running its own effects with occasional live input, and the UI stays +reachable over WiFi while the wire is busy. The limit is WiFi itself: shared airtime and jitter, +which matter for a full-rate ArtNet feed at high universe counts. Step 4's measurement says whether +the intended pixel count fits. + +**USB-Ethernet is the wired version, for when it does not.** The S31 has `SOC_USB_OTG_SUPPORTED` +with a UTMI PHY, so USB high-speed host is possible. The CDC-ECM/NCM host class driver comes from a +managed component or from writing one, which makes it the biggest unknown in the plan. **Spike +before committing**, and only once a measurement shows WiFi is the constraint. + +Steps 1-3 stand alone, and step 4 works over a single shared link. + +## Verification + +1. Host tests: packet bytes, chunk boundaries, the desktop stub round-trip. +2. `check_platform_boundary` clean; `clang-hotpath` shows no new blocking call on the render path. +3. `check_footprint`: the driver holds **zero static RAM** when not enabled. +4. **A real ColorLight card renders a correct image from an effect running on the board, judged by + the PO on the bench.** This is the gate. +5. For step 4: the throughput table exists and the input-protocol decision is recorded. + +## Risks + +| risk | mitigation | +|---|---| +| S31 RX path unresolved (`Plan-20260726`) | this driver is TX-only; step 1 spikes a raw frame first | +| Sustained gigabit TX stresses RGMII timing harder than DHCP does | capture the wire in step 1; corrupt pixels read as a timing fault | +| USB-Ethernet host driver comes from outside IDF | WiFi covers the input link first; USB only if measured short | +| Card firmware variance (brightness, repeated sync) | calibrate against a real card; keep those out of the first cut | +| ArtNet packet rate exceeds the S31 | step 4 measures it; DDP is the lever | + +## Follow-ups + +- **Card discovery**, once the raw L2 receive seam exists; a manual layout works until then. +- **Other vendor formats**, as the architecture allows. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index bd54c3a1..5f76ce9e 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,18 +1,18 @@ { - "commit": "a1ef9ec2", + "commit": "1aff3df2", "flash": { - "esp32": 1678528, - "esp32p4-eth": 1497488, + "esp32": 1678960, + "esp32p4-eth": 1502192, "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1666768, - "esp32s3-n8r8": 1666592, - "esp32s31": 1919136, - "desktop": 942680 + "esp32s3-n16r8": 1667216, + "esp32s3-n8r8": 1666992, + "esp32s31": 1923856, + "desktop": 945336 }, "perf": { "desktop": { - "tick_us": 128, - "fps": 7812 + "tick_us": 125, + "fps": 8000 }, "esp32": { "tick_us": 4164, @@ -20,54 +20,54 @@ } }, "loc": { - "core": 14879, - "light": 19593, - "platform": 12056, + "core": 14889, + "light": 20150, + "platform": 12428, "ui": 5811, - "test": 34731, - "moondeck": 19506 + "test": 35207, + "moondeck": 19914 }, "comments": { "core": { - "lines": 5578, + "lines": 5583, "ratio": 0.409 }, "light": { - "lines": 7525, - "ratio": 0.424 + "lines": 7783, + "ratio": 0.426 }, "platform": { - "lines": 3994, - "ratio": 0.367 + "lines": 4149, + "ratio": 0.369 }, "ui": { "lines": 1518, "ratio": 0.278 }, "test": { - "lines": 5972, - "ratio": 0.199 + "lines": 6035, + "ratio": 0.198 }, "moondeck": { - "lines": 3112, + "lines": 3182, "ratio": 0.183 } }, "tests": { - "cases": 982, + "cases": 1002, "scenarios": 22 }, "docs": { - "md_files": 169, - "md_lines": 22573, - "plans_files": 89, + "md_files": 170, + "md_lines": 22762, + "plans_files": 90, "backlog_lines": 3114, "lessons_lines": 401, "claude_md_lines": 135 }, "complexity": { - "functions": 2137, - "over_threshold": 136, + "functions": 2179, + "over_threshold": 138, "worst_ccn": 93 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 08ee42a4..8a83c97b 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `a1ef9ec2`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run — **do not edit by hand**. +Measured at `1aff3df2`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run — **do not edit by hand**. Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,54 +8,54 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 921 KB | -| esp32 | 1,639 KB | -| esp32p4-eth | 1,462 KB | +| desktop | 923 KB (−0 KB) ✓ | +| esp32 | 1,640 KB | +| esp32p4-eth | 1,467 KB (+1 KB) ⚠ | | esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,628 KB | +| esp32s3-n16r8 | 1,628 KB (+0 KB) ⚠ | | esp32s3-n8r8 | 1,628 KB | -| esp32s31 | 1,874 KB | +| esp32s31 | 1,879 KB (−0 KB) ✓ | ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 128 µs | 7,812 | +| desktop | 125 µs (−3 µs) ✓ | 8,000 (+188) ✓ | | esp32 | 4,164 µs | 240 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 14,879 | 5,578 | 40.9 % | -| light | 19,593 | 7,525 | 42.4 % | -| platform | 12,056 | 3,994 | 36.7 % | +| core | 14,889 (−1) ✓ | 5,583 | 40.9 % | +| light | 20,150 (+3) ⚠ | 7,783 | 42.6 % | +| platform | 12,428 (−8) ✓ | 4,149 | 36.9 % | | ui | 5,811 | 1,518 | 27.8 % | -| test | 34,731 | 5,972 | 19.9 % | -| moondeck | 19,506 | 3,112 | 18.3 % | +| test | 35,207 | 6,035 | 19.8 % | +| moondeck | 19,914 | 3,182 | 18.3 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 982 | +| unit cases | 1,002 | | scenarios | 22 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,137 | -| over threshold | 136 | +| functions | 2,179 (−3) ⚠ | +| over threshold | 138 | | worst CCN | 93 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 169 | -| markdown lines | 22,573 | -| plan files | 89 | +| markdown files | 170 | +| markdown lines | 22,762 | +| plan files | 90 | | backlog lines | 3,114 | | lessons lines | 401 | | CLAUDE.md lines | 135 | diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 8a2bb2cd..94ac8417 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -87,6 +87,29 @@ Origin: MoonLight D_NetworkOut; Art-Net 4 / E1.31 / DDP specs Detail: [technical](moxygen/NetworkSendDriver.md) +<a id="panelcard"></a> + +### Panel Send 💫 · raw Ethernet + +Streams the buffer to **LED panel cards** as raw Ethernet frames, compatible with **ColorLight 5A-75** cards. In vendor terms (ColorLight, NovaStar, Linsn) these are *receiving cards*, and this driver takes the place of the *sending card* that normally feeds them. These take a sender-card feed rather than a pixel protocol, so the driver sends row-addressed data followed by a sync frame that latches the image. + +The board renders and sends: effects, layers and MoonLive run on the device, so one board replaces a host PC driving the same panels. Add a Network Receive effect to take Art-Net in as well. + +- `format` — the card's wire format (ColorLight 5A-75). +- **No geometry controls** — the wall comes from the [Layout](layouts.md). A `PanelsLayout` already states how many panels there are, their size, wiring order and snaking; this driver reads the finished picture and cuts it into card rows. A row wider than 497 pixels goes out as several packets. +- `interface` — which NIC to send from on desktop/Raspberry Pi (`eth0`, `en0`). **Ignored on ESP32**, which has one MAC. Raw sending needs root; without it the driver records frames instead and says so. +- `fps` — frame-rate limit (default 40, 1–120). + +**These cards need a 1 Gbit link.** Not for bandwidth — a 256×256 panel at 40 fps is only ~65 Mbit/s — but for wire time: the cards have no buffering and latch on the sync frame, so a whole frame must arrive inside the inter-frame window. At 100 Mbit the same bytes take ten times as long, which breaks that timing and shows up as tearing or wrong rows rather than as an error. The driver reads the negotiated speed and warns, but still sends: a small panel may be fine, and a measurement beats a refusal. + +No IP is involved — no address, no port, no DHCP — so the driver works on a link that never got a lease. + +Origin: ColorLight 5A-75 documented byte layout + +[Tests](../../tests/unit-tests.md#panelcarddriver) + +Detail: [technical](moxygen/PanelCardDriver.md) + ## Smart light drivers <a id="hue"></a> diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index f2853fcf..6c977df6 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -106,6 +106,12 @@ endif() if(MM_ETH_ONLY) target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_NO_WIFI) endif() +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_NO_ETH) target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_NO_ETH) endif() diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 1788b0a1..23d744f4 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -202,7 +202,7 @@ the site. Every static-analysis tool, on ONE module — the repo-wide reports turned around. -**The stack, and why it is five tools.** Each answers a question the others structurally cannot, +**The stack, and why it is six tools.** Each answers a question the others structurally cannot, which is what keeps them from being redundant: | | sees | answers | @@ -212,6 +212,7 @@ which is what keeps them from being redundant: | **`-Wfunction-effects`** | the whole call graph, from the compiler | can the render path block? | | **lizard** | tokens, no types | is this function getting more complex over time? | | **CodeQL** | a queryable database of the program | can LAN bytes reach a `memcpy`? | +| **footprint** | the linked ELF | how many bytes does this cost, and in which memory? | One rule, one owner: where two tools could report the same thing, one is switched off (lizard owns complexity, so clang-tidy's `readability-function-*` checks stay disabled). The individual cards @@ -578,6 +579,77 @@ group declare `needs_module`, which moves it out of *All Tools on Module* where the reason on stderr. An empty list and an unreachable API look identical in a table, and only one of them means the code is clean. +### check_footprint + +Where a module's bytes land on the device: flash, RAM, or strings. + +```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 +``` + +**What the tool is.** No tool of its own — it reads the ESP32 **ELF** the build already produced, +through the toolchain's `nm` and `objdump`. The linker's accounting is ground truth: it has +already resolved every inline, template instantiation and dead-stripped symbol, so this measures +what actually ships rather than what the source suggests. + +**What it does for us.** The other size checks answer "how big is the binary"; this answers +**which module made it that big, and which memory it spends** — because those bytes are not +interchangeable. Flash is ~1.5 MB and cheap; internal RAM is ~320 KB shared with WiFi, the HTTP +stack and every task stack. A report that adds them together hides the only distinction that +matters. + +| 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 | + +**Sorted STATIC-first**, then by size within each group: every file holding static RAM appears +above every file holding none. A pure size sort buried the rows that matter — `platform_esp32_tasks.cpp` +is 230 B of code against 1440 B of static, so it sat below files with more code and could fall off +a capped table entirely. + +**The columns are not interchangeable, and the ratio is why.** Flash is ~1.5 MB and only read when +the code runs; internal RAM is ~320 KB shared with WiFi, the HTTP stack and every task stack. So a +byte of STATIC costs roughly five times what a byte of CODE does, and a module reading STATIC 0 is +the healthy case rather than an empty measurement. The report deliberately does not total the +columns together — a single "size" number would hide the only distinction worth having. + +Attribution comes from **DWARF** (`nm --line-numbers`), so a symbol is credited to the source file +that defines it — including free functions and file-statics no name-parsing heuristic could place. + +**Strings ride along in the same run** — one report, both halves, no flag: they answer the same +question and a separate mode is one you forget to run. + +**Ours are separated from ESP-IDF's**, which is the difference between a number you can act on and +one you cannot. The string table is read from the per-source **object files** under +`__idf_main.dir`, not from the linked image — the link merges every `.rodata.*.str` input section +into one `.flash.rodata` and a literal carries no symbol, so from the ELF alone a string cannot be +traced to who wrote it. Reading the objects also excludes IDF by construction: only our sources +have objects there. + +`-ffunction-sections` is what makes the attribution exact: each function's literals land in +`.rodata.<mangled>.str1.N` inside its own object, so **the section name is the owning function and +the object path is the source file**. Both are printed next to each literal, so a long one can be +found and judged rather than just counted. + +Measured: **28 KB of the 106 KB** in `.flash.rodata` is ours; the rest is ESP-IDF, WiFi, lfs and +FreeRTOS — which is why the whole-image view was misleading (its longest entries are all IDF +assert expressions). Of our share, prose and identifiers are ~35% each, wire formats 17%, and +error text 13% — so trimming error messages is the smallest of the four levers. + +**Architecture-matched binutils.** `.espressif/tools` also holds `esp32ulp-elf-*` (the ULP +coprocessor's) and, on a P4 machine, the RISC-V set. The ULP `nm` reads an Xtensa ELF happily and +returns a plausible symbol list with every `.bss`/`.data` symbol silently unattributed — the +STATIC column read 0 tree-wide until the tool was picked by architecture. A wrong-tool answer that +looks right is worse than no answer. + +**Needs a built firmware**, and exits 2 when the ELF is missing or carries no DWARF rather than +printing an empty table — an absent measurement is not a zero. + ### check_lizard Complexity gate: fail on **new** over-complex functions, not the ones already there. diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py index 0127af2f..e886cf3f 100644 --- a/moondeck/build/build_esp32.py +++ b/moondeck/build/build_esp32.py @@ -117,6 +117,10 @@ def check_idf_pin(idf_path: Path) -> None: # which PHY/pins a given board uses is runtime config (deviceModels.json → # NetworkModule → ethInit), so one binary per chip serves every board. # +# `panel_cards`: True links PanelCardDriver (panel receiver cards over raw Ethernet). Opt-in +# because the cards need a gigabit link: the S31 has one, and the P4 is included to measure what +# 100 Mbit actually does. Absent = the driver is not compiled in. +# # `ships`: True for variants the release matrix builds + publishes. A variant can # exist here (buildable from the CLI) yet be held out of CI with ships=False. # This dict is the SINGLE source of truth — generate_firmwares.py projects it to @@ -192,6 +196,7 @@ def check_idf_pin(idf_path: Path) -> None: "description": "Waveshare ESP32-P4-NANO — Ethernet only (IP101 PHY). The " "WiFi-less fallback; esp32p4-eth-wifi adds the C6 radio.", "ships": True, + "panel_cards": True, }, "esp32p4-eth-wifi": { "chip": "esp32p4", @@ -211,6 +216,7 @@ def check_idf_pin(idf_path: Path) -> None: # CI doesn't boot-test. NOT a usable firmware: the board's deviceModels entry # flags it experimental so the installer warns before flashing. "ships": True, + "panel_cards": True, }, "esp32s31": { "chip": "esp32s31", @@ -222,6 +228,7 @@ def check_idf_pin(idf_path: Path) -> None: "when a cable is present, WiFi otherwise. esp32s31 is a preview " "target on the v6.1 IDF line.", "ships": True, + "panel_cards": True, }, } @@ -465,6 +472,11 @@ def fragment_enables_eth(frag: str) -> bool: if not any(fragment_enables_eth(frag) for frag in spec["fragments"]): args.append("-DMM_NO_ETH=1") + # Panel receiver cards over raw L2 (PanelCardDriver). Opt-in per firmware rather than per chip: + # the cards want a gigabit link, and classic ESP32 / P4 / S31 all report an internal MAC while + # only the S31 is gigabit. Firmwares that don't set it save ~2.8 KB of flash. + if spec.get("panel_cards"): + args.append("-DMM_PANEL_CARDS=1") return args @@ -533,7 +545,7 @@ def stale_feature_cache(build_dir: Path, extra: list[str], chip: str) -> str | N # MM_TASK_CPU_STATS is here too: toggling --task-cpu-stats on an existing dir must wipe, or the # sdkconfig fragment (GENERATE_RUN_TIME_STATS) never re-seeds — CPU% would read all-0 with the flag # on, or leave a hidden ~5% tick tax with it off. Same stale-cache trap as MM_NO_ETH. - for flag in ("MM_NO_ETH", "MM_ETH_ONLY", "MM_NO_WIFI", "MM_TASK_CPU_STATS"): + for flag in ("MM_NO_ETH", "MM_ETH_ONLY", "MM_NO_WIFI", "MM_TASK_CPU_STATS", "MM_PANEL_CARDS"): wanted = any(a.startswith(f"-D{flag}") for a in extra) cached = f"{flag}:" in text # CMake writes `MM_NO_ETH:UNINITIALIZED=1` if wanted != cached: diff --git a/moondeck/check/check_clang_query.py b/moondeck/check/check_clang_query.py index 3c932c78..2a69e12b 100644 --- a/moondeck/check/check_clang_query.py +++ b/moondeck/check/check_clang_query.py @@ -44,6 +44,7 @@ import argparse import collections +import hashlib import json import os import re @@ -412,8 +413,22 @@ def _shadow_tree(build_dir): # cannot change mid-run. A stale tree from a PREVIOUS run would be wrong, so the marker file # records which source state it was built from. stamp = shadow / ".built-from" - newest = max((f.stat().st_mtime for f in (ROOT / "src").rglob("*") if f.is_file()), default=0) - if stamp.exists() and stamp.read_text(encoding="utf-8").strip() == str(newest): + # A FINGERPRINT of every source path + its size and mtime, not just the newest mtime. Max-mtime + # cannot see a DELETION (deleting anything but the newest leaves the max unchanged) nor an edit + # that restores an older timestamp — in both cases the shadow tree stays stale and the report + # describes source that is no longer there. Path and size are in the digest too, so a rename or + # a same-mtime content change also invalidates. + # + # Hashing metadata rather than file CONTENT is deliberate: it is one stat() per file instead of + # reading all of src/, and the failure it must catch is a stale tree between runs, not a + # deliberate forgery. + digest = hashlib.sha256() + for f in sorted((ROOT / "src").rglob("*")): + if f.is_file(): + st = f.stat() + digest.update(f"{f.relative_to(ROOT)}|{st.st_size}|{st.st_mtime_ns}\n".encode()) + fingerprint = digest.hexdigest() + if stamp.exists() and stamp.read_text(encoding="utf-8").strip() == fingerprint: return shadow if shadow.exists(): shutil.rmtree(shadow) @@ -423,7 +438,7 @@ def _shadow_tree(build_dir): marked = _LEADING_DEV.sub(rf"\1/// {_DEV_MARK}\2", text) if marked != text: f.write_text(marked, encoding="utf-8") - stamp.write_text(str(newest), encoding="utf-8") + stamp.write_text(fingerprint, encoding="utf-8") return shadow @@ -907,6 +922,20 @@ def clip(s, w): return L + table("ALLOCATE", alloc) + table("FREE", free) +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 + + def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--rule", choices=sorted(RULES), help="Run one rule only.") @@ -967,7 +996,7 @@ def main(): # result for these rules on this codebase. bad = [ln for ln in out.splitlines() if "error: " in ln or "unresolved overloaded type" in ln or "not found" in ln] - if bad and "binds here" not in out and "Match #" not in out: + if _matcher_rejected(out): print(f"[{name}] clang-query rejected the matcher: {bad[0].strip()}", file=sys.stderr) return 2 @@ -992,9 +1021,15 @@ def main(): return 2 # Second pass for visibility — see collect_nonpublic. Cheap next to the main run: # same TUs, a matcher with no comment traversal. - nonpub = collect_nonpublic( - _run_rule({"output": "dump", "matcher": _NONPUBLIC_MATCHER, "shadow": True}, - tus, build_dir, tool)) + vis_out = _run_rule({"output": "dump", "matcher": _NONPUBLIC_MATCHER, "shadow": True}, + tus, build_dir, tool) + # Fail closed, exactly as the primary query does: an empty visibility result would mark + # EVERY row `pub`, which reads as a fully-public API rather than as a failed query. + if _matcher_rejected(vis_out): + print("[comments] clang-query rejected the visibility matcher; VIS cannot be " + "reported. Refusing to mark every declaration public.", file=sys.stderr) + return 2 + nonpub = collect_nonpublic(vis_out) for r in rows: r["vis"] = "priv" if (r["file"], r["decl_line"]) in nonpub else "pub" else: diff --git a/moondeck/check/check_footprint.py b/moondeck/check/check_footprint.py new file mode 100644 index 00000000..99e583d5 --- /dev/null +++ b/moondeck/check/check_footprint.py @@ -0,0 +1,347 @@ +#!/usr/bin/env python3 +"""Where a module's bytes land on the device: flash, RAM, or strings. + +The other size checks answer "how big is the binary". This one answers **which module made it +that big, and which memory it spends** — the question a footprint budget actually needs, because +those bytes are not interchangeable. Flash is ~1.5 MB and cheap; internal RAM is ~320 KB shared +with WiFi, the HTTP stack and every task stack, so a byte of `.bss` costs far more than a byte of +`.text`. A report that adds them together hides the only distinction that matters. + +Reads the ESP32 **ELF**, not the source: the linker's own accounting is the ground truth, and it +already resolved every inline, template instantiation and dead-stripped symbol. Attribution comes +from DWARF (`nm --line-numbers`), so a symbol is credited to the source FILE that defines it — +including free functions and file-statics that no name-parsing heuristic could place. + +**An unused module should cost nothing.** The STATIC column is that check: `.bss` + `.data` are +held from boot whether or not the module is ever enabled, so anything there is a standing tax. +Code and rodata are only read when called, so they cost flash but not headroom. + +Needs a built firmware — `uv run moondeck/build/build_esp32.py --firmware <fw>` first. Exits 2 +when the ELF is missing rather than reporting an empty table (an absent measurement is not a +zero). + +Usage: + uv run moondeck/check/check_footprint.py # every module, biggest first + uv run moondeck/check/check_footprint.py --module HueDriver + uv run moondeck/check/check_footprint.py --firmware esp32 # another target +""" + +import argparse +import collections +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import check_clang_query # noqa: E402 — the module→files resolver, one owner + +DEFAULT_FW = "esp32s3-n16r8" +MAX_ROWS = 40 + +# nm's symbol-type letters, grouped by the memory they occupy. Uppercase = global, lowercase = +# file-local; both cost the same bytes, so they are folded together. +# T/t .text — code in flash (or IRAM) +# W/V — weak/COMDAT: an inline or template body, emitted once and merged by the linker +# R/r .rodata — NAMED constants only. String literals and most constexpr tables are anonymous +# (no symbol at all), so `.flash.rodata` is ~427 KB while barely any of it is +# attributable — which is why RODATA is reported but usually near zero, and why +# `--strings` measures that section wholesale instead. +# D/d .data — initialised globals: RAM at runtime AND a flash copy to initialise from +# B/b .bss — zero-init globals: RAM only +_AREA = {"T": "code", "t": "code", "W": "code", "V": "code", + "R": "rodata", "r": "rodata", + "D": "data", "d": "data", + "B": "bss", "b": "bss"} + +# `<addr> <size> <type> <name>\t<abs path>:<line>` — the DWARF file is what credits the bytes. +_SRC = re.compile(r"projectMM/(src/[^:\t]+):\d+\s*$") + + +def _tool(name, firmware): + """The binutil for the firmware's ARCHITECTURE, or None. + + The arch has to be selected, not globbed: `.espressif/tools` also holds `esp32ulp-elf-*` (the + ULP coprocessor's binutils) and, on a P4 machine, the RISC-V set. The ULP `nm` reads an Xtensa + ELF happily and returns a plausible-looking symbol list — with every `.bss`/`.data` symbol + silently unattributed, so the STATIC column read 0 across the whole tree. A wrong-tool answer + that looks right is worse than no answer. + """ + arch = "riscv32-esp-elf" if ("p4" in firmware or "c6" in firmware or "s31" in firmware) \ + else "xtensa-esp-elf" + hits = sorted(Path.home().glob(f".espressif/tools/{arch}/**/*-{name}")) + # Newest toolchain last: several IDF versions can be installed side by side. + return str(hits[-1]) if hits else None + + +def elf_path(firmware): + return ROOT / "build" / f"esp32-{firmware}" / "projectMM.elf" + + +def collect(elf, nm): + """One row per source file: bytes per memory area, from the linker's own symbol table.""" + p = subprocess.run([nm, "--print-size", "--line-numbers", "-C", str(elf)], + capture_output=True, text=True, check=False) + if p.returncode != 0: + return None + + per = collections.defaultdict(collections.Counter) + for ln in p.stdout.splitlines(): + src = _SRC.search(ln) + if not src: + continue # SDK, managed component, or no debug info — not ours + parts = ln.split(None, 3) + if len(parts) < 4: + continue + area = _AREA.get(parts[2]) + if not area: + continue + try: + size = int(parts[1], 16) # absent on a symbol with no size — skip it + except ValueError: + continue + per[src.group(1)][area] += size + return per + + +def collect_strings(elf, objdump): + """Every string literal in `.flash.rodata`, longest first. + + Counted from the section bytes rather than the source, so a literal that the compiler merged + or dropped is counted exactly once — which is the number that reaches the device. + """ + p = subprocess.run([objdump, "-s", "-j", ".flash.rodata", str(elf)], + capture_output=True, text=True, check=False) + if p.returncode != 0: + return None + raw = bytearray() + for ln in p.stdout.splitlines(): + m = re.match(r"^\s+[0-9a-f]+\s((?:[0-9a-f]{2,8}\s){1,4})", ln) + if m: + for group in m.group(1).split(): + raw += bytes.fromhex(group) + # 4+ printable bytes then a NUL: short runs are usually data that happens to look like text. + return [s[:-1].decode("ascii", "replace") for s in re.findall(rb"[\x20-\x7e]{4,}\x00", bytes(raw))] + + +def render(per, only, firmware): + rows = [] + for f, c in per.items(): + if only is not None and f not in only: + continue + static = c["bss"] + c["data"] # held from boot, enabled or not + rows.append({"file": f, "code": c["code"], "rodata": c["rodata"], + "static": static, "total": c["code"] + c["rodata"] + static}) + # ANY file holding static RAM sorts above every file that holds none, and only then by size. + # The two columns are not the same currency: internal RAM is ~320 KB shared with WiFi and the + # task stacks, flash is ~1.5 MB and cheap. On a pure TOTAL sort a file with a big buffer and + # little code — platform_esp32_tasks.cpp, 230 B of code against 1440 B of static — sinks below + # files that merely have more code, and can fall off the end of a capped table entirely. A + # weighting factor would only postpone that; the two-key sort makes it impossible. + rows.sort(key=lambda r: (r["static"] == 0, -r["static"], -r["total"])) + + L = [f"Footprint on {firmware} — {len(rows)} source file(s), " + f"{sum(r['total'] for r in rows)} B attributed."] + if not rows: + return L + [" Nothing attributed. Build the firmware, or the ELF carries no debug info."] + + # What the columns MEAN lives in MoonDeck.md, not in every run's output: it is reference text + # that does not change between runs, so printing it each time buries the numbers it explains. + L += [""] + w = min(max(len(r["file"]) for r in rows), 52) + L += [f" {'TOTAL':>8} {'CODE':>8} {'RODATA':>8} {'STATIC':>8} FILE", + f" {'-' * 8} {'-' * 8} {'-' * 8} {'-' * 8} {'-' * w}"] + for r in rows[:MAX_ROWS]: + L.append(f" {r['total']:>8} {r['code']:>8} {r['rodata']:>8} {r['static']:>8} " + f"{r['file'][:w]}") + if len(rows) > MAX_ROWS: + # Say what the cut was by: with STATIC leading the sort, "smallest" would be misleading. + L.append(f" … {len(rows) - MAX_ROWS} more — all with STATIC 0, ordered by total.") + + # The one line worth repeating: it is a RESULT (a number that moves), not an explanation. + held = [r for r in rows if r["static"]] + L += ["", f" {len(held)} file(s) hold static RAM; {sum(r['static'] for r in held)} B total."] + return L + + +def defining_site(elf, nm): + """`mangled name -> "src/path.h:line"` for every function, from the ELF's DWARF. + + The object file only names the TRANSLATION UNIT, so a function defined in a header is credited + to whichever `.cpp` included it — `MoonI80Peripheral::refreshBusKpi` read as `main.cpp`, which + is where it was compiled but not where it lives. `nm --line-numbers` on the LINKED image gives + the defining file AND line, which is what a reader needs to go and look at it. + """ + p = subprocess.run([nm, "--line-numbers", str(elf)], capture_output=True, text=True, check=False) + out = {} + for ln in p.stdout.splitlines(): + m = re.match(r"\S+\s+\S+\s+(\S+)\t.*?projectMM/(src/[^:]+):(\d+)", ln) + if m: + out.setdefault(m.group(1), f"{m.group(2)}:{m.group(3)}") + return out + + +def our_strings(firmware, objdump, cxxfilt, sites=None): + """Every string literal OUR code defines: `(bytes, text, source file, function)`. + + Read from the per-source **object files**, not the linked image. The link merges every + `.rodata.*.str` input section into one `.flash.rodata`, and a literal carries no symbol — so + from the ELF alone a string cannot be traced back to who wrote it, which is what made the + earlier whole-image report impossible to act on (its longest entries were all ESP-IDF assert + expressions). + + `-ffunction-sections` is what makes this work: each function's literals land in + `.rodata.<mangled>.str1.N` inside its own object, so the section name IS the owning function + and the object path IS the source file. The name is demangled for reading. + """ + objdir = ROOT / "build" / f"esp32-{firmware}" / "esp-idf" / "main" / "CMakeFiles" / "__idf_main.dir" + if not objdir.is_dir(): + return None + objs = sorted(objdir.rglob("*.obj")) + if not objs: + return None + + rows, mangled = [], [] + for obj in objs: + src = str(obj) + src = "src/" + src.split("/src/", 1)[1][:-4] if "/src/" in src else obj.name[:-4] + head = subprocess.run([objdump, "-h", str(obj)], capture_output=True, text=True, check=False) + for ln in head.stdout.splitlines(): + m = re.search(r"(\.rodata\.(\S+?)\.str[\d.]*)\s+([0-9a-f]{8})", ln) + if not m or int(m.group(3), 16) == 0: + continue + dump = subprocess.run([objdump, "-s", "-j", m.group(1), str(obj)], + capture_output=True, text=True, check=False) + raw = bytearray() + for dl in dump.stdout.splitlines(): + h = re.match(r"^\s+[0-9a-f]+\s((?:[0-9a-f]{2,8}\s){1,4})", dl) + if h: + for g in h.group(1).split(): + raw += bytes.fromhex(g) + for lit in re.findall(rb"[\x20-\x7e]{2,}", bytes(raw)): + site = (sites or {}).get(m.group(2), src) + rows.append([len(lit) + 1, lit.decode("ascii", "replace"), site, m.group(2)]) + mangled.append(m.group(2)) + + # One c++filt for every name: a call per string would dominate the runtime. + if mangled: + out = subprocess.run([cxxfilt], input="\n".join(mangled), capture_output=True, + text=True, check=False).stdout.splitlines() + for row, name in zip(rows, out): + row[3] = name + return rows + + +def render_strings(strs, ours, only): + """Two tables: OUR literals (per file + function, actionable) and the image total (context).""" + image_total = sum(len(s) + 1 for s in strs) + L = [] + + if ours is None: + L.append("Strings: object files not found — build the firmware to attribute them.") + return L + + # `r[2]` is "file:line", so compare on the file half — `only` holds bare paths. + scoped = [r for r in ours if only is None or r[2].rsplit(":", 1)[0] in only] + mine = sum(r[0] for r in scoped) + # OURS first and IDF's second, because only one of them is a decision. The image total is + # context — its longest entries are IDF assert expressions we cannot shorten, and reporting + # them mixed together made the whole table unactionable. + L += [f"Strings we define: {len(scoped)}, {mine} B " + f"(the whole .flash.rodata is {image_total} B including ESP-IDF, which we cannot change).", + ""] + if not scoped: + return L + [" None in scope."] + + # Grouped by what they are FOR: the trimming answer differs per kind — prose can be shortened, + # an identifier is an API contract, and a wire template is a protocol. + cat, size = collections.Counter(), collections.Counter() + for n, s, _f, _fn in scoped: + low = s.lower() + if s.startswith(("{", "[")) or '":' in s: + k = "JSON / wire format" + elif any(word in low for word in ("fail", "error", "invalid", "cannot", "unable")): + k = "error / status text" + elif " " not in s: + k = "identifiers / keys" + else: + k = "other prose" + cat[k] += 1 + size[k] += n + for k, _ in size.most_common(): + L.append(f" {k:<22} {size[k]:>7} B {100 * size[k] / mine:5.1f}% ({cat[k]} strings)") + + L += ["", " LONGEST — file and function, so a long literal can be found and judged:"] + fw = min(max(len(r[2]) for r in scoped), 42) + L += [f" {'BYTES':>5} {'FILE:LINE':<{fw}} {'FUNCTION':<30} TEXT", + f" {'-' * 5} {'-' * fw} {'-' * 30} {'-' * 44}"] + for n, s, f, fn in sorted(scoped, key=lambda r: -r[0])[:20]: + # Just the member name: the FILE column already says which class, so `mm::Class::` is + # repetition that pushes the readable part of a long signature off the row. + # GCC's outlined variants (`…$part$0`, `…$isra$0`) are not valid mangled names, so + # c++filt hands them back untouched; strip the suffix and retry the readable tail. + base = fn.split("$")[0] + short = base.split("(")[0].split("::")[-1] or base + if short.startswith("_ZN"): + short = re.sub(r"^_ZN?[0-9]+", "", short) # last resort: drop the mangling prefix + L.append(f" {n:>5} {f[-fw:]:<{fw}} {short[:30]:<30} {s[:64]}") + return L + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--firmware", default=DEFAULT_FW, help=f"Built firmware (default {DEFAULT_FW}).") + ap.add_argument("--module", help="Only this module's source files.") + args = ap.parse_args() + + elf = elf_path(args.firmware) + if not elf.exists(): + print(f"No ELF at {elf.relative_to(ROOT)} — build it first:\n" + f" uv run moondeck/build/build_esp32.py --firmware {args.firmware}", file=sys.stderr) + return 2 + + nm = _tool("nm", args.firmware) + if not nm: + print("No nm in the ESP-IDF toolchain — install/activate ESP-IDF.", file=sys.stderr) + return 2 + per = collect(elf, nm) + if per is None: + print(f"nm could not read {elf.name}.", file=sys.stderr) + return 2 + # An empty result means the ELF carried no usable debug info — say so rather than print a + # clean-looking zero (the silent-zero rule the other checks follow). + if not per: + print(f"No symbol mapped back to src/ in {elf.name}. The build carries no DWARF, so " + f"nothing can be attributed — this is a broken measurement, not a small one.", + 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") + + print("\n".join(render(per, only, args.firmware))) + + # The string table rides along in the SAME run: both halves answer one question ("what does + # this cost"), they read the same ELF, and the whole report is under a second. A separate mode + # meant remembering to run it, which is how a report goes unread. + # The string half rides along in the SAME run: both answer "what does this cost", they read + # the same build, and a separate mode is one you forget to run. + objdump = _tool("objdump", args.firmware) + cxxfilt = _tool("c++filt", args.firmware) + if objdump and cxxfilt: + strs = collect_strings(elf, objdump) or [] + ours = our_strings(args.firmware, objdump, cxxfilt, defining_site(elf, nm)) + print() + print("\n".join(render_strings(strs, ours, only))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/check_module.py b/moondeck/check/check_module.py index 145c7d08..b59f187e 100644 --- a/moondeck/check/check_module.py +++ b/moondeck/check/check_module.py @@ -15,6 +15,7 @@ clang-tidy bug patterns, performance, portability clang-query our own AST rules — RAM-costing arrays, heap allocation sites lizard complexity (CCN / NLOC), baseline-filtered + footprint bytes on the device, split flash / static RAM (needs a built ESP32 ELF) A module is resolved to `src/**/<Module>.h` and `.cpp` (see check_clang_query.module_files) — the same names the MoonDeck dropdown offers. @@ -43,10 +44,15 @@ # reasoning, but the comments rule made a single module's report unreadable — HttpServerModule # alone prints 212 declarations, and a wall of rows is skimmed rather than read. The cap is per # TABLE and always announces what it dropped, so the tail is one `--max-rows=0` away. +# +# `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"]), - ("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), ] @@ -54,7 +60,7 @@ def main(): ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--module", required=True, help="Module name, e.g. Control or ParallelLedDriver.") ap.add_argument("--skip", action="append", default=[], - help="Skip a tool by name (repeatable): clang-tidy, clang-query, lizard.") + help="Skip a tool by name (repeatable): clang-tidy, clang-query, lizard, footprint.") args = ap.parse_args() files = check_clang_query.module_files(args.module) @@ -68,7 +74,7 @@ def main(): print() failed = [] - for name, argv in TOOLS: + for name, argv, optional in TOOLS: if name in args.skip: print(f"--- {name}: skipped ---\n") continue @@ -82,7 +88,13 @@ def main(): sys.stdout.write(proc.stdout) if proc.returncode not in (0, 1): # 1 = findings, which is a result not a failure sys.stderr.write(proc.stderr) - failed.append(name) + # An optional tool says WHY it could not run (no ELF built, no toolchain). Echo that + # onto STDOUT too: the card streams stdout, so a stderr-only reason reads as a tool + # that produced nothing at all — a silent skip, which is what this must never be. + if optional: + print(f" (not run: {proc.stderr.strip().splitlines()[0] if proc.stderr.strip() else 'no reason given'})") + else: + failed.append(name) print(f"[{name}: {time.time() - started:.0f}s]\n") if failed: diff --git a/moondeck/check/check_nonblocking.py b/moondeck/check/check_nonblocking.py index 716c6152..825cdaf8 100644 --- a/moondeck/check/check_nonblocking.py +++ b/moondeck/check/check_nonblocking.py @@ -585,8 +585,10 @@ def table(title, subset, note): # Unconditional first: a call that runs every time its tick does is strictly worse than # the same call behind a branch. (file, line) breaks ties, so the order stays stable # between runs and findings in one file stay together — that is how they get fixed. - subset = sorted(subset, key=lambda r: (r["cond"] not in (NO_CAUSE, "?"), - r["file"], r["line"])) + # Only NO_CAUSE leads: `?` is UNKNOWN, not proven-unconditional, so ranking it as urgently + # would assert something the run did not establish. (It is also all-or-nothing — when the + # guard query fails every row reads `?` — so it only ever affects a mixed table.) + subset = sorted(subset, key=lambda r: (r["cond"] != NO_CAUSE, r["file"], r["line"])) # Never narrower than the header: a tier where every row is `—` is width 1, and the dashes # then stop lining up under "COND". cond_w = max(min(max(len(r["cond"]) for r in subset), 9), len("COND")) diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json index 736cceae..411697c5 100644 --- a/moondeck/moondeck_config.json +++ b/moondeck/moondeck_config.json @@ -145,6 +145,15 @@ } ] }, + { + "id": "check_footprint", + "tab": "desktop", + "group": "tools", + "label": "Footprint (ESP32)", + "speed": "instant", + "help": "check_footprint", + "script": "check/check_footprint.py" + }, { "id": "check_specs", "tab": "desktop", diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h index 4b8e6019..2102b2e9 100644 --- a/src/core/NetworkModule.h +++ b/src/core/NetworkModule.h @@ -409,6 +409,8 @@ class NetworkModule : public MoonModule { // is not buried under the WiFi-fallback "connected" status the cascade is about // to set: a live-but-unusable Ethernet cable is worth the user's attention until // they unplug it (which drops ethLinkUp → the warning clears, see tick1s/onConnected). + // Unless something is driving the link directly (raw L2, no IP wanted) — then + // writeEthDegradedStatus reports it as normal rather than as a fault. if (platform::ethLinkUp()) { ethDegraded_ = true; writeEthDegradedStatus(); @@ -1003,6 +1005,14 @@ class NetworkModule : public MoonModule { // (a partial/lost lease) so they have an IP to work with. Cleared when ethLinkUp() drops // (cable out) in tick1s, at which point updateStatusIP falls through to the normal IP line. void writeEthDegradedStatus() { + // A driver has claimed the link for direct L2 use, so it is doing its job rather than + // failing: a raw-L2 sender addresses the wire below IP and never wants a lease. Report it as Status rather than Warning — the "no address" line would otherwise + // tell the user to unplug the very cable that is driving their panels. + if (platform::ethRawL2Claimed()) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "Ethernet: link up, no IP (L2 in use)"); + setStatus(statusBuf_, Severity::Status); + return; + } uint8_t ip[4] = {}; platform::ethGetIPv4(ip); if (ip[0] || ip[1] || ip[2] || ip[3]) { diff --git a/src/light/ColorLight5A75Packet.h b/src/light/ColorLight5A75Packet.h new file mode 100644 index 00000000..ca58d364 --- /dev/null +++ b/src/light/ColorLight5A75Packet.h @@ -0,0 +1,153 @@ +#pragma once + +#include <cstdint> +#include <cstring> + +namespace mm { + +// ColorLight 5A-75 wire format — the one place the layout lives, used by PanelCardDriver to build +// frames. Same shape as ArtNetPacket.h / DdpPacket.h, with one structural difference: this format +// has no receiver in the tree (the cards are the receiver), so there is no build→parse round-trip +// test. The unit tests pin the built bytes against the layout instead. +// +// **Raw Ethernet, not UDP.** These are complete L2 frames sent below IP: no address, no port, no +// DHCP lease. That is why the driver works on a link that never got one. +// +// **The EtherType field is overloaded, and it is the format's defining quirk.** A normal frame has +// a 2-byte EtherType at offset 12-13. Here byte 12 is the PACKET TYPE and byte 13 is already the +// first payload byte, so every packet's body starts at offset 13 rather than 14. A row packet +// therefore reads on the wire as EtherType 0x55<row-MSB> — not because the row is deliberately +// encoded into the EtherType, but because the row number is simply the first payload byte and lands +// there. Getting this wrong shifts every field by one and the cards silently discard the frame. +// +// **The MACs are fixed constants, not real addresses.** Destination 11:22:33:44:55:66, source +// 22:22:33:44:55:66 — the cards filter on that destination, so a frame sent to broadcast or from +// the device's own MAC is dropped without any visible error. The source is not this device's MAC. +// +// Frame layout, all packet types: +// 0-5 destination MAC — always kColorLightDestMac +// 6-11 source MAC — always kColorLightSrcMac +// 12 packet type (0x55 row data, 0x01 sync, 0x0A brightness, 0x07 discovery) +// 13+ payload, per type below +// +// Row data (type 0x55), payload at offset 13: +// 13-14 row number, big-endian +// 15-16 pixel offset within the row, big-endian +// 17-18 pixel count in this packet, big-endian +// 19 0x08 — constant; purpose undocumented +// 20 0x88 — constant; purpose undocumented +// 21+ pixel data, 3 bytes per pixel +// +// Sync (type 0x01), 112 bytes total, body zeroed except: +// 13 0x07 — "sent from a PC/netcard" (a hardware sender writes 0x00 here) +// 35 brightness (payload byte 22) +// 36 0x05 — constant; purpose undocumented +// 38-40 brightness ×3 (payload bytes 25-27) +// +// Brightness (type 0x0A), 77 bytes total, body zeroed except: +// 13-15 brightness ×3 +// 16 0xFF +// +// Per frame the order is: brightness, then every row ascending, then sync last — the sync is what +// latches the buffered rows onto the panels. +// +// No configuration packets are sent. The cards are configured by the vendor's own tool and keep +// that in flash; a sender only streams pixels. + +// Fixed MACs. The cards filter on the destination, so these are not arbitrary. +constexpr uint8_t kColorLightDestMac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; +constexpr uint8_t kColorLightSrcMac[6] = {0x22, 0x22, 0x33, 0x44, 0x55, 0x66}; + +// Byte 12 is the type; byte 13 begins the payload (see the EtherType note above). +constexpr size_t COLORLIGHT_TYPE_OFFSET = 12; +constexpr size_t COLORLIGHT_DATA_OFFSET = 13; + +constexpr uint8_t COLORLIGHT_TYPE_ROW = 0x55; +constexpr uint8_t COLORLIGHT_TYPE_SYNC = 0x01; +constexpr uint8_t COLORLIGHT_TYPE_BRIGHTNESS = 0x0A; + +// Row header: row, offset, count, two constants — 8 bytes, so pixels start at 13 + 8 = 21. +constexpr size_t COLORLIGHT_ROW_HEADER = 8; +constexpr size_t COLORLIGHT_ROW_PREFIX = COLORLIGHT_DATA_OFFSET + COLORLIGHT_ROW_HEADER; // 21 + +constexpr uint16_t COLORLIGHT_MAX_PIXELS_PER_PACKET = 497; +constexpr uint8_t COLORLIGHT_BYTES_PER_PIXEL = 3; + +// Largest frame built: 21 + 497×3 = 1512 bytes. Above the 1500-byte MTU on purpose — these are raw +// L2 frames on a dedicated link to dumb receivers, never IP packets a router would fragment. +constexpr size_t COLORLIGHT_MAX_FRAME = + COLORLIGHT_ROW_PREFIX + COLORLIGHT_MAX_PIXELS_PER_PACKET * COLORLIGHT_BYTES_PER_PIXEL; // 1512 + +constexpr size_t COLORLIGHT_SYNC_FRAME = 112; +constexpr size_t COLORLIGHT_BRIGHTNESS_FRAME = 77; + +// Constants the cards require whose purpose is not documented anywhere verifiable. Reproduced from +// the observed layout and named to say exactly that. +constexpr uint8_t kUnknownRowConst0 = 0x08; // row payload byte 6 +constexpr uint8_t kUnknownRowConst1 = 0x88; // row payload byte 7 +constexpr uint8_t kSyncFromNetcard = 0x07; // sync payload byte 0: sender is a PC/netcard +constexpr uint8_t kUnknownSyncConst = 0x05; // sync payload byte 23 + +// Write the 12-byte MAC pair plus the packet-type byte. Everything after byte 12 is payload. +inline void buildColorLightHeader(uint8_t* out, uint8_t packetType) { + std::memcpy(out, kColorLightDestMac, 6); + std::memcpy(out + 6, kColorLightSrcMac, 6); + out[COLORLIGHT_TYPE_OFFSET] = packetType; +} + +// Build one row-data frame: up to COLORLIGHT_MAX_PIXELS_PER_PACKET pixels of one row, starting at +// `pixelOffset` within that row. Returns the total frame length. +// +// `out` must hold COLORLIGHT_ROW_PREFIX + pixelCount × 3 bytes; COLORLIGHT_MAX_FRAME covers the +// largest. Pixel bytes are copied verbatim — channel order is the caller's business (the driver's +// Correction has already applied it), so this never reorders them. +inline size_t buildColorLightRowPacket(uint8_t* out, uint16_t row, + uint16_t pixelOffset, uint16_t pixelCount, + const uint8_t* pixels) { + buildColorLightHeader(out, COLORLIGHT_TYPE_ROW); + uint8_t* data = out + COLORLIGHT_DATA_OFFSET; + data[0] = static_cast<uint8_t>(row >> 8); + data[1] = static_cast<uint8_t>(row & 0xFF); + data[2] = static_cast<uint8_t>(pixelOffset >> 8); + data[3] = static_cast<uint8_t>(pixelOffset & 0xFF); + data[4] = static_cast<uint8_t>(pixelCount >> 8); + data[5] = static_cast<uint8_t>(pixelCount & 0xFF); + data[6] = kUnknownRowConst0; + data[7] = kUnknownRowConst1; + const size_t dataBytes = static_cast<size_t>(pixelCount) * COLORLIGHT_BYTES_PER_PIXEL; + if (pixels && dataBytes) std::memcpy(out + COLORLIGHT_ROW_PREFIX, pixels, dataBytes); + return COLORLIGHT_ROW_PREFIX + dataBytes; +} + +// Build the sync frame that latches every row sent since the last one. Fixed 112 bytes, zeroed +// except for the netcard marker, one undocumented constant, and brightness in four places. +// Returns the frame length; `out` must hold COLORLIGHT_SYNC_FRAME bytes. +inline size_t buildColorLightSyncPacket(uint8_t* out, uint8_t brightness) { + std::memset(out, 0, COLORLIGHT_SYNC_FRAME); + buildColorLightHeader(out, COLORLIGHT_TYPE_SYNC); + uint8_t* data = out + COLORLIGHT_DATA_OFFSET; + data[0] = kSyncFromNetcard; + data[22] = brightness; + data[23] = kUnknownSyncConst; + data[25] = brightness; + data[26] = brightness; + data[27] = brightness; + return COLORLIGHT_SYNC_FRAME; +} + +// Build the brightness frame: one level on each of the three channels. Sent first in a frame, ahead +// of the rows. Reported not to work on older card firmware, so the driver treats it as advisory and +// never depends on it having landed. Returns the frame length; `out` must hold +// COLORLIGHT_BRIGHTNESS_FRAME bytes. +inline size_t buildColorLightBrightnessPacket(uint8_t* out, uint8_t brightness) { + std::memset(out, 0, COLORLIGHT_BRIGHTNESS_FRAME); + buildColorLightHeader(out, COLORLIGHT_TYPE_BRIGHTNESS); + uint8_t* data = out + COLORLIGHT_DATA_OFFSET; + data[0] = brightness; + data[1] = brightness; + data[2] = brightness; + data[3] = 0xFF; + return COLORLIGHT_BRIGHTNESS_FRAME; +} + +} // namespace mm diff --git a/src/light/drivers/MoonLedDriver.h b/src/light/drivers/MoonLedDriver.h index 9436710e..bf34748e 100644 --- a/src/light/drivers/MoonLedDriver.h +++ b/src/light/drivers/MoonLedDriver.h @@ -85,11 +85,58 @@ namespace mm { /// therefore writes every value ONE SLOT EARLY (the rotation in ParallelSlots.h). Get that wrong and the /// first LED survives while everything after it is noise. /// +/// **Why WR is the shift clock, and why there is no DC pin.** WR toggles once per bus word in +/// hardware, exactly what a '595's SRCLK needs — so the expander costs zero DMA bytes for its clock, +/// and the LATCH must ride a *data lane* instead (the peripheral gives only one clock output). DC +/// exists so an LCD panel can separate command bytes from data; a WS2812 strand has no such concept, +/// so the peripheral holds DC at a constant level and this backend never routes it to a pad. Unlike +/// `esp_lcd`, which mandates a valid DC GPIO, spending a pin on it would buy nothing. +/// /// **Why the expander needs the ring.** The fan-out costs 8 bus words per WS2812 bit, so a light is 576 B /// and a **48 x 256** frame is ~144 KB — more contiguous internal RAM than an S3 has, and from PSRAM the /// DMA cannot sustain the expander's shift clock (measured at 26.67 MHz). Streaming is the only route to /// that target, which is why these two features are one story. /// +/// ## Tuning the three ring controls +/// +/// A config at or under the prime-only boundary (`ceil(lights/strand ÷ ringRows) ≤ ringBufs`, ~224 +/// lights/strand at the defaults) needs none of them — the whole frame is encoded before arming, the +/// pad is not even mounted, and the defaults just work. They exist for the LAPPING frontier +/// (256+/strand), where the ISR races the wire. `ringRows`/`ringBufs` are DEVICE tuning (RAM vs +/// interrupt rate vs jitter pool); `ringPadUs` is a HARDWARE fact of the strip. The `ringDbg` +/// counters — `lt` above all — are the meter every sweep reads. +/// +/// **`ringRows` — lights per DMA buffer.** A control rather than a constant because the optimum is a +/// measurement, not a derivation. RAM is the ONLY axis that wants it small: at 1 the ring is ~18 KB +/// flat at ANY strand length (128, 256, 1024 all cost the same), which is what puts 48x256 in reach. +/// Three axes want it big: +/// +/// | axis | why big wins | +/// |---|---| +/// | per-call overhead | the encode seam's fixed cost amortises over the rows in a buffer; at 1 it is paid per light, inside the ISR | +/// | interrupt rate | one EOF per buffer: 256 lights at 100 fps is 25.6k int/s at 1 row vs 1.6k at 16 — and a busy ISR starves the network stack | +/// | refill deadline | the drain of one buffer (`ringRows × 21.6 µs` + the pad) is the slice deadline the ISR's AVERAGE encode must beat | +/// +/// The platform additionally clamps a buffer to ONE DMA descriptor node (4095 bytes — 7 rows at the +/// 16-strand row size), so values above the clamp behave as the clamp: 7 is the effective maximum +/// there, and the sweet spot measured on the bench. +/// +/// **`ringPadUs` — and why its ceiling must be measured on the wall.** Sweep it up only until +/// `ringDbg`'s `lt` counter stays 0 (hpwit's _DMA_EXTENSTION, studied then written fresh). The ceiling +/// is the STRIP's latch threshold and it is per-silicon: a WS2812 that reads a LOW gap as a reset +/// LATCHES AND RESETS ITS ADDRESS, and then every slice repaints LEDs 0..ringRows-1 — the unmistakable +/// signature (each panel shows only its first few LEDs flickering through the whole frame's colors, +/// while every ring counter stays clean). The bench wall latches at ≤60 µs (30 is safe there); other +/// strips tolerate up to ~150. That hardware dependence is why it is a user control, not a constant. +/// +/// **`ringAuto` — what it derives.** Per the wall-validated viability rule ((nSlices − ringBufs) × +/// refill ≤ frame wire time): rows = the one-descriptor-node maximum (fewest slices), bufs = as deep as +/// fits (min of nSlices+1 — which IS prime-only when it fits — the platform max, and the internal-RAM +/// budget after its reserve). Deeper is strictly better: more prime coverage, less ISR load. +/// `ringPadUs` stays manual — the strip's latch threshold is a hardware fact no derivation can know. +/// The toggle is explicit, not edit-triggered: persistence restore fires the same control-change path +/// as a user edit, so an auto-flip-on-edit would trip on every boot. +/// /// ## The `ringDbg` instrument (expert-mode read-only) /// /// A one-line raw readout of the ring's health + timing, refreshed each second — the field it backs is @@ -143,22 +190,10 @@ class MoonI80Peripheral : public LedPeripheral { // committed elsewhere. The orchestrator declares pins="" / loopbackRxPin=-1, so nothing is // needed here. - /// WR — the pixel clock — and it is needed **only by a 74HCT595 expander**, which is why it is the - /// one bus control this backend keeps. - /// - /// WR toggles once per bus word in hardware, which is exactly what a '595's SRCLK needs: the pixel - /// clock IS the shift clock. That is why the expander costs zero DMA bytes for its clock, and why - /// the LATCH has to ride a *data lane* instead (the peripheral gives only one clock output). - /// - /// **In direct mode nothing reads WR** — WS2812 is self-clocked, so the strands ignore it — and the - /// platform then leaves it unrouted, so the pin stays free for a strand. The value is still used to - /// pick which GPIO WR would land on in shift mode, so a board with an expander sets it and a plain - /// board can ignore it. - /// - /// There is no `dcPin`. DC exists so an LCD panel can separate command bytes from data bytes; a - /// WS2812 strand has no such concept, the peripheral is configured to hold DC at a constant level, - /// and (unlike `esp_lcd`, which mandates a valid DC GPIO) this backend simply never routes it to a - /// pad. Spending a GPIO on it would buy nothing. + /// WR — the pixel clock — needed **only by a 74HCT595 expander** (in direct mode nothing reads it, + /// so the platform leaves it unrouted and the pin stays free for a strand); why it doubles as the + /// shift clock, and why there is no `dcPin`, is under + /// @xref{the-74hct595-pin-expander-skip-unless-one-is-fitted|More info → the expander}. int8_t clockPin = 10; /// Stream the frame as a RING of small internal DMA buffers (on, the default), or send it WHOLE from @@ -172,41 +207,14 @@ class MoonI80Peripheral : public LedPeripheral { /// measured against, not as a fallback the ring degrades into. bool useRing = true; - /// Compute `ringRows`/`ringBufs` automatically from the live config (ON, the default), writing the - /// chosen values INTO those controls so the user always sees the real numbers — the DHCP pattern - /// (an "automatic" toggle whose fields fill with the derived values). While ON, a manual edit to - /// ringRows/ringBufs is recomputed away at the next rebuild (the value visibly snaps back) — switch - /// this OFF to tune by hand. Explicit, not edit-triggered: persistence restore fires the same - /// control-change path as a user edit, so an auto-flip-on-edit would trip on every boot. The derivation, per the - /// wall-validated viability rule ((nSlices − ringBufs) × refill ≤ frame wire time): rows = the - /// one-descriptor-node maximum (fewest slices), bufs = as deep as fits (min of nSlices+1 — which IS - /// prime-only when it fits — the platform max, and the internal-RAM budget after its reserve). Deeper - /// is strictly better: more prime coverage, less ISR load. `ringPadUs` stays manual — the strip's - /// latch threshold is a hardware fact no derivation can know. + /// Derive `ringRows`/`ringBufs` from the live config (ON, the default) and write them INTO those + /// controls so the user sees the real numbers — the DHCP pattern; switch OFF to tune by hand, and + /// see @xref{tuning-the-three-ring-controls|More info → tuning the ring} for the derivation. bool ringAuto = true; - /// Lights per DMA buffer — the ring's grain. **Who tunes the three ring controls:** a config at or - /// under the prime-only boundary (`ceil(lights/strand ÷ ringRows) ≤ ringBufs`, ~224 lights/strand at - /// the defaults) never needs any of them — the whole frame is encoded before arming, the pad is not - /// even mounted, and the defaults just work. They exist for the LAPPING frontier (256+/strand), where - /// the ISR races the wire. `ringRows`/`ringBufs` are DEVICE tuning (RAM vs interrupt rate vs jitter - /// pool); `ringPadUs` is a HARDWARE fact of the strip (see its doc). The `ringDbg` counters — `lt` - /// above all — are the meter every sweep reads. - /// - /// A control rather than a constant because the optimum is a measurement, not a derivation. RAM is - /// the ONLY axis that wants it small: at 1 the ring is ~18 KB flat at ANY strand length (128, 256, - /// 1024 all cost the same), which is what puts 48x256 in reach. Three axes want it big: - /// - /// | axis | why big wins | - /// |---|---| - /// | per-call overhead | the encode seam's fixed cost amortises over the rows in a buffer; at 1 it is paid per light, inside the ISR | - /// | interrupt rate | one EOF per buffer: 256 lights at 100 fps is 25.6k int/s at 1 row vs 1.6k at 16 — and a busy ISR starves the network stack | - /// | refill deadline | the drain of one buffer (`ringRows × 21.6 µs` + the pad) is the slice deadline the ISR's AVERAGE encode must beat | - /// - /// The platform additionally clamps a buffer to ONE DMA descriptor node (4095 bytes — 7 rows at the - /// 16-strand row size), so values above the clamp behave as the clamp: 7 is the effective maximum - /// there, and the sweet spot measured on the bench. Pin-expander mode only; a prepare trigger (the - /// buffers are sized and the DMA chain mounted at build time, so a change is a rebuild). + /// Lights per DMA buffer — the ring's grain, the main RAM-vs-interrupt-rate lever, and a prepare + /// trigger (pin-expander mode only); how to pick it is under + /// @xref{tuning-the-three-ring-controls|More info → tuning the ring}. uint8_t ringRows = platform::kRingRowsDefault; /// How many buffers the DMA circulates. Depth buys **jitter absorption**, not capacity: the pool is a @@ -217,19 +225,9 @@ class MoonI80Peripheral : public LedPeripheral { /// bench. Pin-expander mode only; a prepare trigger. uint8_t ringBufs = platform::kRingBufsDefault; - /// Inter-buffer zero-pad, µs (LAPPING only; 0 = off). Each ring buffer is followed by a shared block - /// of zeros on the wire — a strand-level PAUSE that stretches the ISR's per-slice refill deadline by - /// exactly this long, at a linear frame-time cost (every slice grows by the pad, so the fps ceiling - /// drops). Sweep it up only until `ringDbg`'s `lt` counter stays 0 (hpwit's _DMA_EXTENSTION, studied - /// then written fresh). - /// - /// **The ceiling is the STRIP's latch threshold, and it is per-silicon — measure it on the wall.** - /// A WS2812 that reads a LOW gap as a reset LATCHES AND RESETS ITS ADDRESS, and then every slice - /// repaints LEDs 0..ringRows-1 — the unmistakable signature (each panel shows only its first few - /// LEDs flickering through the whole frame's colors, while every ring counter stays clean). The - /// bench wall latches at ≤60 µs (30 is safe there); other strips tolerate up to ~150. That hardware - /// dependence is why this is a user control and not a constant. A prepare trigger (the pad is a - /// mounted DMA node). + /// Inter-buffer zero-pad, µs (LAPPING only; 0 = off) — a strand-level pause that buys refill + /// deadline at a linear frame-time cost, whose ceiling is a HARDWARE fact of the strip measured + /// under @xref{tuning-the-three-ring-controls|More info → tuning the ring}. uint8_t ringPadUs = 0; /// Overclock the '595 shift clock: OFF (default) = 20 MHz — the reliability point, wall-verified on @@ -445,6 +443,11 @@ class MoonI80Peripheral : public LedPeripheral { static_cast<uint16_t>(clockPin), frameBytes, /*wantSecondBuffer=*/false, owner_->busClockMultiplier()); } + /// The rest of the bus surface: one-line forwards to the platform seam, each carrying the + /// contract its `LedPeripheral` base already states (`busBuffer` is "DMA buffer i the encoder + /// writes into", and so on). Restating that here would give doxygen two copies of one contract + /// to drift apart — the base is the single home. Only `busDeinit` adds behaviour of its own: + /// the snapshot helper is this backend's, so it stops before the bus goes away. void busDeinit() override { stopSnapHelper(); platform::moonI80Ws2812Deinit(bus_); } uint8_t* busBuffer(uint8_t i) override { return platform::moonI80Ws2812Buffer(bus_, i); } size_t busCapacity() const override { return platform::moonI80Ws2812BufferCapacity(bus_); } @@ -519,10 +522,11 @@ class MoonI80Peripheral : public LedPeripheral { return ok; } /// Send one frame on the ring: prime the pool, fire the DMA, and let the EOF ISR refill behind it. - // The ring's per-frame kick. With the core-0 helper up (dual-core split active), fork-join the PRIME: - // ring buffers are independent (each derives its rows from its index), so the helper primes the bottom - // half of the pool on core 0 while this core primes the top half, the join fences both, then the arm - // starts the DMA. Serial fallback: the platform's prime-all-then-arm combo. + /// + /// With the core-0 helper up (dual-core split active), the PRIME is fork-joined: ring buffers are + /// independent (each derives its rows from its index), so the helper primes the bottom half of the + /// pool on core 0 while this core primes the top half, the join fences both, then the arm starts + /// the DMA. Serial fallback: the platform's prime-all-then-arm combo. bool busTransmitRing() override { if (snapHelperReady() && ringBufs >= 2) { // `done` is written-gated (leads the wire drain); the prime itself takes the wire barrier — @@ -616,6 +620,8 @@ class MoonI80Peripheral : public LedPeripheral { } + /// Publish this frame's prime job and wake the helper. Paired with `helperJoin` — never call + /// one without the other, or the next kick mutates bounds the helper is still reading. void helperKick() { if (!snapHelper_.impl) return; // Wait for the helper to be PARKED before notifying it for the next job. What actually keeps the @@ -636,6 +642,9 @@ class MoonI80Peripheral : public LedPeripheral { platform::notifyTask(snapHelper_); } + /// Block until the helper's half is primed. The fence that makes the fork-join safe: the caller + /// may not touch the pool or the bounds until this returns. A timeout latches the helper broken + /// and runs the job serially instead, so a wedged worker degrades rather than stalls the frame. void helperJoin() { if (!snapHelper_.impl) return; // A degraded kick (helper never parked → snapHelperBroken_, ran serially) already did the work and @@ -658,6 +667,8 @@ class MoonI80Peripheral : public LedPeripheral { } } + /// The job itself: prime the buffer range the kick published. Called on the helper task, and on + /// THIS task as the serial fallback when the helper is broken or absent. void runHelperJob() { platform::moonI80Ws2812PrimeRange(bus_, primeLo_, primeHi_); } /// Bring the helper task up (idempotent). Called at ring engage — see busInitRing's tail. Pinned to @@ -673,12 +684,16 @@ class MoonI80Peripheral : public LedPeripheral { platform::spawnPinnedTask(snapHelper_, "mmSnap", &MoonI80Peripheral::snapHelperTramp, this, 4096, 5, /*core=*/0); } + /// Tear the helper down: signal the stop flag, wake it, and wait for it to leave its loop. Called + /// from `busDeinit`, so the worker cannot outlive the bus it primes into. void stopSnapHelper() { if (!snapHelper_.impl) return; snapHelperStop_.store(true, std::memory_order_release); platform::stopPinnedTask(snapHelper_); } + /// The worker's entry point — a plain function, because the task API takes no member pointer. + /// Loops: park in waitNotify, run the published job, mark done, repeat until stopped. static void snapHelperTramp(void* user) { auto* self = static_cast<MoonI80Peripheral*>(user); while (!self->snapHelperStop_.load(std::memory_order_acquire)) { @@ -695,19 +710,31 @@ class MoonI80Peripheral : public LedPeripheral { } private: - static constexpr uint32_t kSnapJoinTimeoutMs = 100; // a half is single-digit ms; 100 = broken + /// Join deadline for one prime half. A half is single-digit ms, so 100 means the helper is broken, + /// not slow — the timeout latches `snapHelperBroken_` rather than retrying forever. + static constexpr uint32_t kSnapJoinTimeoutMs = 100; + /// The core-0 worker that primes the bottom half of the ring pool. Spawned on first ring init, + /// parked in waitNotify between frames. platform::WorkerTask snapHelper_{}; + /// Stored by the helper when its job is finished; `helperJoin` spins on it. Starts true so the + /// first join returns immediately. std::atomic<bool> snapHelperDone_{true}; + /// Asks the helper to exit its wait loop; set once by `stopSnapHelper` at teardown. std::atomic<bool> snapHelperStop_{false}; - // Set by the trampoline right before it blocks in waitNotify; cleared by helperKick when it publishes a - // new job. The kick waits for this before mutating the bounds (primeLo_/primeHi_) — so the helper is - // provably done reading the PREVIOUS job's inputs. Starts true: the helper parks once at spawn before - // any kick. + /// Set by the trampoline right before it blocks in waitNotify; cleared by helperKick when it + /// publishes a new job. The kick waits for this before mutating the bounds (primeLo_/primeHi_) — so + /// the helper is provably done reading the PREVIOUS job's inputs. Starts true: the helper parks once + /// at spawn before any kick. std::atomic<bool> snapHelperParked_{true}; - bool snapHelperBroken_ = false; // self-heal latch: a timed-out join disables the helper (serial from then on) - uint8_t primeLo_ = 0, primeHi_ = 0; // the helper's prime-job buffer range + /// Self-heal latch: one timed-out join disables the helper for good and the prime runs serial from + /// then on. A wedged worker must not be re-waited every frame. + bool snapHelperBroken_ = false; + /// The buffer range [lo, hi) the helper primes this frame — written only between join and kick. + uint8_t primeLo_ = 0, primeHi_ = 0; + /// The platform-side bus handle; opaque here, so no LCD_CAM type escapes the platform layer. platform::MoonI80Ws2812Handle bus_; + /// The clockPin the bus was last built with, so a change to the control can force a rebuild. int8_t lastClockPin_ = -1; }; diff --git a/src/light/drivers/MultiPinLedDriver.h b/src/light/drivers/MultiPinLedDriver.h index f03b5685..ac0ace10 100644 --- a/src/light/drivers/MultiPinLedDriver.h +++ b/src/light/drivers/MultiPinLedDriver.h @@ -211,9 +211,11 @@ class I80Peripheral : public LedPeripheral { /// ×8 frame fits), it has no single-transfer cap, and its WR pixel-clock pin IS the shift clock a /// '595 needs. The classic ESP32 shares this backend but not that silicon path — its i80 is the I2S /// peripheral, whose DMA cannot read PSRAM at all, so a 154 KB frame has nowhere to live. Keying - /// the flag on `lcdLanes` (non-zero only on the LCD_CAM chips, S3/P4/S31) makes 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". + /// the flag on `platform::hasLcdCam` makes 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". `hasLcdCam`, not `lcdLanes`: the + /// two agree on ESP32 (it is defined as `lcdLanes > 0` there) but not on the desktop, which + /// declares the capability outright so the emulated build can exercise this path. bool supportsPinExpander() const override { return platform::hasLcdCam; } /// The bus pin list comes from the orchestrator: in shift mode it appends the latch to the data pins diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index 1ae5a4a6..ac93466a 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -24,11 +24,11 @@ namespace mm { /// survives only as legacy compatibility. /// /// **Synchronous send:** this driver's WINDOW (`start`/`count`, not necessarily the whole buffer) -/// goes out inline in tick() — ~35 ms Ethernet / ~90 ms WiFi for a full-buffer window at 128×128 -/// ArtNet, less for DDP and proportionally less for a narrower window. A decoupling send task is a -/// PSRAM-gated backlog item. Added per board -/// via the catalog like the LED drivers; applies the same shared Correction, so network and wired -/// outputs show identical colors. +/// goes out inline in tick() — 38 ms Ethernet to 155 ms WiFi for a full-buffer window at 128×128 +/// ArtNet (the spread is the lwIP/WiFi buffer pool, not the protocol: performance.md § ArtNet send), +/// less for DDP and proportionally less for a narrower window. A decoupling send task is a +/// PSRAM-gated backlog item. Added per board via the catalog like the LED drivers; applies the same +/// shared Correction, so network and wired outputs show identical colors. /// /// The deep dives are under *More info*, below the attribute/method lists: /// @xref{why-unicast-is-the-default|why unicast is the default}, @@ -105,7 +105,9 @@ class NetworkSendDriver : public DriverBase { /// ArtNet / E1.31 split at 510 channels per universe (whole RGB lights, the xLights/Falcon /// convention; 170 lights/packet), consecutive universes from `universeStart`; DDP uses /// 1440-byte byte-offset chunks (480 lights/packet) and is the fast path — per-packet cost - /// dominates wire time, so a 128×128 WiFi frame drops from ~110 ms (ArtNet) to ~40 ms. + /// dominates wire time, so a 128×128 ArtNet frame drops to roughly a third on DDP. The ArtNet + /// figure itself spans 38-155 ms across boards (performance.md § ArtNet send): the buffer pool + /// dominates it, so quote a range rather than one number. uint8_t protocol = 0; /// First universe the slice maps onto (ArtNet / E1.31; DDP is byte-addressed). Emitted verbatim, /// no hidden 1-based adjust: buffer offset = `(universe − universeStart) × 510`. Strict sACN diff --git a/src/light/drivers/PanelCardDriver.h b/src/light/drivers/PanelCardDriver.h new file mode 100644 index 00000000..d063af59 --- /dev/null +++ b/src/light/drivers/PanelCardDriver.h @@ -0,0 +1,373 @@ +#pragma once + +#include "light/drivers/DriverBase.h" + +#include "light/ColorLight5A75Packet.h" // the first wire format (byte layout lives there) +#include "platform/platform.h" + +namespace mm { + +/// Output driver: streams the buffer to LED panel cards over **raw Ethernet frames**, below IP. +/// These cards take a sender-card feed rather than a pixel protocol — row-addressed data plus a +/// sync frame that latches — so they need an L2 seam (`platform::ethSendRaw`) rather than a socket. +/// The wire format lives in ColorLight5A75Packet.h; this driver owns the window, the correction and +/// the chunking, exactly as NetworkSendDriver does for ArtNet/E1.31/DDP. +/// +/// **The board renders and sends.** Effects, layers and MoonLive run here, so a device with this +/// driver is a complete panel controller. Taking ArtNet in and forwarding it is one application of +/// the same driver (add a NetworkReceiveEffect), not a prerequisite. +/// +/// **The vendor vocabulary**, since the datasheets and LEDVision use it: an LED wall is driven by a +/// *sending card* (a PC's PCI-E board, or a standalone box) that feeds *receiving cards*, one in each +/// cabinet, which decode the signal and drive the panels. This driver puts the board in the sending +/// card's place, and the cards it talks to are receiving cards. "Panel card" is the plainer name for +/// the same hardware, and the one that keeps reading correctly when a second vendor's format lands. +/// +/// **No geometry controls.** The panel arrangement belongs to the Layout — `PanelsLayout` states +/// how many panels there are, their size, their wiring order and snaking, and maps every light to +/// an (x, y). This driver reads the finished picture and cuts it into card rows, so a wall is +/// described in exactly one place. +/// +/// **No IP involved.** No address, no port, no DHCP lease: the frames carry their own destination +/// MAC and EtherType. A board whose DHCP never completes still drives panels, which is also why the +/// driver reports the link state itself rather than trusting `ethConnected()`. +/// +/// The deep dives are under *More info*, below the attribute/method lists: +/// @xref{why-a-gigabit-link|why these cards need a gigabit link}, +/// @xref{running-this-on-a-host|running this on a desktop or a Pi}. +/// +/// @moreinfo +/// +/// ## Why a gigabit link +/// +/// The cards require a **1000 Mbps** link, and the reason is wire time rather than bandwidth. Even a +/// 256×256 panel at 40 fps is only ~65 Mbit/s of payload, which 100 Mbit would seem to carry. But +/// the cards are dumb receivers with no buffering and no flow control: they latch on the sync frame, +/// so an entire frame must arrive inside the inter-frame window. At 100 Mbit the same bytes take ten +/// times as long on the wire — a 256×256 frame is ~16 ms of transmission against ~1.6 ms at gigabit +/// — which overruns the frame budget and breaks the timing the sync depends on. +/// +/// The failure mode is the confusing part: nothing errors. Frames go out, the link is up, and the +/// panels tear, show wrong rows, or never latch. That is why this driver reads the NEGOTIATED speed +/// (`platform::ethLinkSpeedMbps`) and says so in its status rather than letting a slow link look +/// like a format bug. It still SENDS at 100 Mbit — a small panel may be fine, and the measurement +/// is more useful than a refusal. +/// +/// ## Running this on a host +/// +/// The desktop build sends real frames too, via `platform::ethBindRawInterface` (Linux AF_PACKET, +/// macOS BPF) — so a Raspberry Pi or a mini-PC running projectMM is a panel controller, which is +/// the deployment this replaces. Raw L2 needs root or CAP_NET_RAW; without it, or with `interface` +/// left blank, the host records frames instead of sending them, which is what lets the unit tests +/// pin the wire format with no hardware and no privileges. +/// +/// On ESP32 `interface` is ignored: the chip has one MAC. +class PanelCardDriver : public DriverBase { +public: + /// Panel cards are RGB, so this references the "RGB" preset rather than the strips' "GRB" — + /// same per-driver default the network sinks use. The user can still pick any preset. + PanelCardDriver() { setDefaultPresetName("RGB"); } + + /// The card's own gain, held at full. Our Correction has already applied brightness to the pixel + /// bytes, so scaling again on the card would compound the two. Named once because both the + /// brightness frame and the sync frame carry it. + static constexpr uint8_t kCardGain = 0xFF; + + /// Wire formats. One entry today; the control exists because the category is panel cards rather + /// than one vendor, and a second format is a packet file plus an entry here. + static constexpr const char* kFormatOptions[] = {"ColorLight 5A-75"}; + static constexpr uint8_t kFormatCount = 1; + + /// Wire format (index into kFormatOptions). + uint8_t format = 0; + /// Host NIC to send from ("eth0", "en0"). Ignored on ESP32, which has one MAC. Blank on a host + /// means capture-only: nothing reaches the wire and the status says so. + char interface[16] = {}; + /// Send-rate ceiling (Hz); tick() rate-limits so a fast render tick doesn't saturate the link. + uint8_t fps = 40; + + /// This driver's controls, after the correction block DriverBase places at the top of every + /// driver card: format, panel geometry, the host interface, the shared window, then the cap. + void defineDriverControls() override { + controls_.addSelect("format", format, kFormatOptions, kFormatCount); + controls_.addText("interface", interface, sizeof(interface)); + // Desktop/Raspberry-Pi only: which host NIC to open a raw socket on. An ESP32 has one MAC + // and ignores it, so an empty box there would invite input that does nothing. + if constexpr (!platform::hasNamedNetInterfaces) + controls_.setHidden(controls_.count() - 1, true); + addWindowControls(); // start / count — which slice of the shared buffer this sink sends + controls_.addUint8("fps", fps, 1, 120); + } + + /// Geometry and the window change how much of the buffer is corrected, so both re-run the + /// prepare sweep; `interface` re-binds the raw socket, which also happens off the hot path. + bool affectsPrepare(const char* name) const override { + return std::strcmp(name, "interface") == 0 + || isWindowControl(name) || isCorrectionControl(name); + } + + /// Drop back to capture mode so a disabled driver holds no raw socket, then chain to the base. + void release() override { + if (claimed_) { platform::ethClaimRawL2(false); claimed_ = false; } + platform::ethBindRawInterface(nullptr); + DriverBase::release(); + } + + /// Take the shared source buffer and size the corrected_ buffer for it. + void setSourceBuffer(Buffer* buf) override { + sourceBuffer_ = buf; + resizeCorrected(); + } + + /// Pure build: bind the raw interface (host only), size corrected_, and publish the status the + /// card shows. All the checks that would otherwise be guesses in tick() happen here. + 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(); + } + + /// Re-read the link every second. A cable is plugged in, unplugged, or renegotiates long after + /// prepare() ran, so a status written once at build time goes stale and reports "no ethernet + /// link" on a link that is up — which is exactly what a user is looking at the card to find out. + /// Runs once a second on the housekeeping path, not the render path, so the status snprintf + /// here costs nothing that matters (the same trade SystemModule/NetworkModule make in their + /// own tick1s — it is flagged by -Wfunction-effects and accepted for that reason). + void tick1s() MM_NONBLOCKING override { + writeLinkStatus(); + MoonModule::tick1s(); + } + + /// A preset toggle changes correction_.outChannels without a structural rebuild. + void onCorrectionChanged() override { resizeCorrected(); } + + /// Rate-limit, apply this driver's correction, then emit the window row by row and latch it + /// with one sync frame. One packet buffer, reused; no allocation on this path. + void tick() MM_NONBLOCKING override { + if (!sourceBuffer_ || !sourceBuffer_->data()) return; + if (fps == 0) return; + + const uint32_t interval = 1000 / fps; + const uint32_t now = platform::millis(); + if (now - lastSendTime_ < interval) return; + lastSendTime_ = now; + + // The wall comes from the Layout — a PanelsLayout already states how many panels there are, + // how big each one is, and how they are wired, and it has mapped every light to an (x, y). + // So this driver needs no panel geometry of its own: it reads the finished picture and cuts + // it into card rows. Restating the panel arrangement here would be a second place to get it + // wrong, and the layout's version is richer (wiring order, snaking, per-panel offsets). + if (!layer()) return; // no dimensions to send against + const lengthType wallW = layer()->physicalWidth(); + const lengthType wallH = layer()->physicalHeight(); + if (wallW == 0 || wallH == 0) return; + + // The window slice this sink owns, then correction into corrected_ (sized off the hot path). + // Same three guards and the same passthrough fallback as the other sinks: a stale or + // unwired correction must degrade to raw bytes, never overrun the allocation. + nrOfLightsType winStart, nLights; + windowSlice(sourceBuffer_->count(), winStart, nLights); + if (nLights == 0) return; + + const uint8_t* data; + uint8_t stride; + const uint8_t outCh = correction_.outChannels; + if (outCh != 0 && corrected_.data() + && corrected_.count() >= nLights + && corrected_.channelsPerLight() >= outCh) { + const uint8_t* src = sourceBuffer_->data(); + const uint8_t srcCh = sourceBuffer_->channelsPerLight(); + uint8_t* dst = corrected_.data(); + for (nrOfLightsType i = 0; i < nLights; i++) { + correction_.apply(src + (winStart + i) * srcCh, dst + i * outCh); + } + data = dst; + stride = outCh; + } else { + const uint8_t srcCh = sourceBuffer_->channelsPerLight(); + data = sourceBuffer_->data() + static_cast<size_t>(winStart) * srcCh; + stride = srcCh; + } + + // Brightness first, ahead of the rows — the order the cards expect. Advisory: older card + // firmware ignores it, and the driver never depends on it having landed (our own Correction + // has already applied brightness to the pixel data, so this only sets the card's own gain). + { + const size_t len = buildColorLightBrightnessPacket(packet_, kCardGain); + // Sent TWICE, like the sync below. Card firmware v13+ acts on the second copy only; + // older firmware ignores the duplicate, so sending both costs one frame and works on + // every version rather than making the behaviour depend on a firmware probe we do not do. + if (platform::ethSendRaw(packet_, len)) framesSent_++; + if (platform::ethSendRaw(packet_, len)) framesSent_++; + } + + // One card row per wall row, in order. The card's own row numbering runs across its outputs + // (a stack of panels is several outputs), and that matches wall rows top to bottom — which is + // what the vendor tool configured the card to expect. + bool anyRowSent = false; + for (lengthType row = 0; row < wallH; row++) { + if (static_cast<size_t>(row) * wallW >= nLights) break; // buffer ran out before the wall + for (lengthType off = 0; off < wallW; off += COLORLIGHT_MAX_PIXELS_PER_PACKET) { + lengthType n = static_cast<lengthType>(wallW - off); + if (n > COLORLIGHT_MAX_PIXELS_PER_PACKET) n = COLORLIGHT_MAX_PIXELS_PER_PACKET; + + const size_t first = static_cast<size_t>(row) * wallW + off; + if (first >= nLights) break; // buffer ran out before the wall + if (first + n > nLights) n = static_cast<lengthType>(nLights - first); + if (n == 0) break; + + const size_t len = packRow(static_cast<uint16_t>(row), static_cast<uint16_t>(off), + static_cast<uint16_t>(n), data + first * stride, stride); + // A failed frame is dropped, not retried: the cards have no acknowledgement to wait + // for, and stalling the render tick to retry would cost the next frame too. + if (platform::ethSendRaw(packet_, len)) framesSent_++; + else framesDroppedTotal_++; + } + anyRowSent = true; + } + + // The sync frame latches everything above, so it goes LAST and only if something was sent — + // latching an empty frame would blank the panels on a misconfigured window. + if (anyRowSent) { + const size_t len = buildColorLightSyncPacket(packet_, kCardGain); + // Twice, for the same firmware reason as the brightness frame above. + if (platform::ethSendRaw(packet_, len)) framesSent_++; + if (platform::ethSendRaw(packet_, len)) framesSent_++; + } + } + + /// Report what the wire is actually doing: no link, a link too slow for the cards, or the + /// frames-per-second reaching it. The three cases are genuinely different and a user debugging + /// a dark panel needs to tell them apart. Sends regardless — see the class note on why a slow + /// link is reported rather than refused. + void writeLinkStatus() MM_NONBLOCKING { + if (!platform::ethLinkUp()) { + setStatus("no ethernet link", Severity::Warning); + return; + } + // A wedged transmit path reads as a perfectly healthy link: esp_eth_transmit refuses every + // frame while the IDF driver's own link flag is down, and that flag can outlive the PHY event + // that set it. Reporting the failure streak is what tells "0 packets/s on a 1 Gbit link" + // apart from "the effect is idle" — the two looked identical before. + // A short streak is ordinary back-pressure: the DMA ring fills while we push a whole frame + // in one tick, the frame is dropped, the next one goes. Measured on a 128x128 wall at ~1900 + // packets/s, streaks of 1-4 come and go and always clear themselves — reporting those as an + // error cries wolf and would bury the case below. + // + // A LONG streak is the wedge worth naming: esp_eth_transmit refuses every frame while the IDF + // driver's own link flag reads down, which can outlive the PHY event that set it, and then + // nothing recovers without a restart. The threshold sits above one frame's worth of packets, + // so it can only be reached by failures spanning frames rather than one burst. + static constexpr uint32_t kWedgedStreak = 500; + const uint32_t failStreak = platform::ethSendFailStreak(); + if (failStreak >= kWedgedStreak) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "transmit wedged (%u refused)", + static_cast<unsigned>(failStreak)); + setStatus(statusBuf_, Severity::Error); + framesReported_ = framesSent_; + return; + } + + const uint16_t mbps = platform::ethLinkSpeedMbps(); + // Total frames the MAC refused since boot. A dropped frame is tolerated (the cards have no + // acknowledgement, so a retry would cost the next frame instead), but it is NOT invisible: + // a rising total says the sender is outrunning the wire, which is a real thing to know when + // a wall looks like it is stuttering. Shown only once something has actually dropped. + const uint32_t dropped = framesDroppedTotal_; + // Frames actually handed to the MAC since the last report — the one number that separates + // "the driver is not sending" from "the driver sends and the card ignores it". + const uint32_t sent = framesSent_ - framesReported_; + framesReported_ = framesSent_; + if (mbps && mbps < 1000) { + std::snprintf(statusBuf_, sizeof(statusBuf_), + "%u Mbit (needs 1 Gbit) - %u packets/s", + static_cast<unsigned>(mbps), static_cast<unsigned>(sent)); + setStatus(statusBuf_, Severity::Warning); + return; + } + if (dropped) { + std::snprintf(statusBuf_, sizeof(statusBuf_), "%u Mbit - %u packets/s, %u dropped", + static_cast<unsigned>(mbps), static_cast<unsigned>(sent), + static_cast<unsigned>(dropped)); + } else { + std::snprintf(statusBuf_, sizeof(statusBuf_), "%u Mbit - %u packets/s", + static_cast<unsigned>(mbps), static_cast<unsigned>(sent)); + } + setStatus(statusBuf_, Severity::Status); + } + + /// Test-only accessor for the correction-applied buffer, pinning the no-allocation-in-loop + /// contract (same public-for-tests convention as NetworkSendDriver::correctedBuffer). + const Buffer& correctedBuffer() const { return corrected_; } + +private: + /// Build one row packet into packet_. Pixels are copied one at a time because the source stride + /// (3 for RGB, 4 for RGBW) need not match the wire's 3 bytes — the card takes RGB, so a 4-channel + /// correction contributes its first three channels and the white channel has nowhere to go. + size_t packRow(uint16_t row, uint16_t pixelOffset, uint16_t pixelCount, + const uint8_t* src, uint8_t stride) MM_NONBLOCKING { + if (stride == COLORLIGHT_BYTES_PER_PIXEL) { + // Dense already: one memcpy inside the builder. + return buildColorLightRowPacket(packet_, row, pixelOffset, pixelCount, src); + } + // Strided source: write the header with no data, then pack RGB out of each light. + buildColorLightRowPacket(packet_, row, pixelOffset, pixelCount, nullptr); + uint8_t* dst = packet_ + COLORLIGHT_ROW_PREFIX; + for (uint16_t i = 0; i < pixelCount; i++) { + dst[i * 3 + 0] = src[i * stride + 0]; + dst[i * 3 + 1] = src[i * stride + 1]; + dst[i * 3 + 2] = src[i * stride + 2]; + } + return COLORLIGHT_ROW_PREFIX + static_cast<size_t>(pixelCount) * COLORLIGHT_BYTES_PER_PIXEL; + } + + /// Size corrected_ for the current source and correction. Called only off the hot path — that + /// placement IS the no-allocation-in-the-send-loop contract. + void resizeCorrected() { + if (!sourceBuffer_) return; + nrOfLightsType winStart, n; + windowSlice(sourceBuffer_->count(), winStart, n); + const uint8_t ch = correction_.outChannels; + if (n == 0 || ch == 0) return; + if (corrected_.count() >= n && corrected_.channelsPerLight() >= ch) return; + corrected_.allocate(n, ch); + } + + /// The shared frame this driver reads its window from; borrowed, not owned. + Buffer* sourceBuffer_ = nullptr; + /// Owned: source bytes after brightness/order/white. Sized off the hot path. + Buffer corrected_; + /// One reused frame buffer, sized for the largest packet the format builds. Static per driver + /// rather than per send, which is what keeps tick() allocation-free. + uint8_t packet_[COLORLIGHT_MAX_FRAME] = {}; + /// millis() of the last frame sent — the `fps` limiter's reference. + uint32_t lastSendTime_ = 0; + /// Frames the MAC accepted since boot, and the value at the last status write — their + /// difference is the per-second rate the card shows. + uint32_t framesSent_ = 0; + uint32_t framesReported_ = 0; + /// Frames the MAC refused since boot. Cumulative on purpose: the per-second rate hides a slow + /// trickle of drops, and a rising total is the signal that the sender is outrunning the wire. + uint32_t framesDroppedTotal_ = 0; + /// Backing store for the status line (setStatus does not copy). + char statusBuf_[48] = {}; + /// Whether this driver currently holds the raw-L2 claim, so prepare/release stay balanced + /// however often the framework calls them. + bool claimed_ = false; +}; + +} // namespace mm diff --git a/src/main.cpp b/src/main.cpp index 54b8ada2..87ba9a7f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -105,6 +105,15 @@ #if defined(CONFIG_SOC_PARLIO_SUPPORTED) || MM_LINKS_ALL_LED_DRIVERS #include "light/drivers/ParlioLedDriver.h" // Parlio backend (ParlioPeripheral) #endif +// Panel receiver cards over raw Ethernet — opt-in PER FIRMWARE (MM_PANEL_CARDS), not per chip. +// The panels need a gigabit link, and no SOC capability macro separates the boards that have one +// from the boards that do not: classic ESP32 and P4 both report an internal MAC, and both are +// 100 Mbit. So the firmware catalogue names the variants that get it (build_esp32.py: today the +// S31, which is RGMII gigabit, and the P4, where the 100 Mbit wire-time limit is worth measuring). +// Everything else would carry ~2.8 KB of flash for a driver it cannot use, so it does not link it. +#if defined(MM_PANEL_CARDS) || MM_LINKS_ALL_LED_DRIVERS +#include "light/drivers/PanelCardDriver.h" +#endif #include "core/HttpServerModule.h" #include "core/SystemModule.h" #include "core/Services.h" @@ -220,6 +229,10 @@ static void registerModuleTypes() { mm::ModuleFactory::registerType<mm::HueDriver>("HueDriver", "light/drivers.md#hue"); mm::ModuleFactory::registerType<mm::NetworkSendDriver>("NetworkSendDriver", "light/drivers.md#networksend"); mm::ModuleFactory::registerType<mm::PreviewDriver>("PreviewDriver", "light/drivers.md#preview"); + // Same firmware gate as the include above. +#if defined(MM_PANEL_CARDS) || MM_LINKS_ALL_LED_DRIVERS + mm::ModuleFactory::registerType<mm::PanelCardDriver>("PanelCardDriver", "light/drivers.md#panelcard"); +#endif // Register only the LED drivers this chip's silicon can run (see the gated // includes above) — keeps the type picker honest (no MultiPinLedDriver offered on a // chip without an i80 bus) and the binary lean. diff --git a/src/platform/desktop/platform_config.h b/src/platform/desktop/platform_config.h index 8e02cc3a..f71c064d 100644 --- a/src/platform/desktop/platform_config.h +++ b/src/platform/desktop/platform_config.h @@ -79,6 +79,11 @@ struct EthPinConfig { // (shared code) `if constexpr (hasEthernet)`s the eth controls off, and seeds its // members from this default; both must exist here for that shared code to compile. constexpr bool hasEthernet = false; + +// hasNamedNetInterfaces — the host has several NICs and a raw sender must name one ("eth0", "en0"). +// True on desktop, false on a microcontroller with a single MAC, where the name would be a control +// that does nothing. Drivers use it to hide the field rather than to choose behaviour. +constexpr bool hasNamedNetInterfaces = true; // Some-IP-stack flag (WiFi OR Ethernet) — mirrors the esp32 config so shared code // (WLED audio sync, UDP interop) gates on "has network" uniformly. True on desktop // via the WiFi stubs (UdpSocket has a desktop implementation). diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 593ec496..8663f464 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -31,8 +31,15 @@ #include <unistd.h> #include <fcntl.h> #include <sys/mman.h> // mmap/munmap for allocExec (executable pages) +#include <net/if.h> // if_nametoindex / ifreq — naming the NIC for raw L2 send +#ifdef __linux__ +#include <netpacket/packet.h> // sockaddr_ll — AF_PACKET raw frames (ethSendRaw) +#include <net/ethernet.h> // ETH_P_ALL +#endif #ifdef __APPLE__ #include <pthread.h> // pthread_jit_write_protect_np — macOS arm64 W^X JIT toggle +#include <sys/ioctl.h> // BIOCSETIF — binding a BPF device to an interface (ethSendRaw) +#include <net/bpf.h> #endif #endif @@ -686,6 +693,149 @@ void ethStop() {} // no eth on desktop bool ethInit() { return false; } bool ethLinkUp() MM_NONBLOCKING { return false; } bool ethConnected() MM_NONBLOCKING { return false; } + +// Raw-frame capture: the desktop half of the ethSendRaw seam. Sending a real L2 frame from a host +// process needs a raw socket and root, which no test should ask for — so the host RECORDS what the +// driver emitted instead. That is what lets PanelCardDriver and its tests build and run everywhere +// (the desktop-runs-everything rule), with the packet bytes pinned on the host and only the wire +// itself left to the bench. +// +// Fixed capacity, no allocation: a test asserts over the first few frames of a render tick, and an +// unbounded recorder would turn a long run into unbounded memory. Frames past the cap are counted +// but not stored, so an overrun shows up as a count the test can see. +namespace { +// Sized for the largest frame sequence a test asserts over: a 128-row wall is 2 brightness + 128 +// rows + 2 sync = 132. +// +// Allocated on FIRST CAPTURE, not from boot: this is a test seam, and as a plain static array it +// cost ~195 KB of BSS in the shipped desktop/Pi binary — a deployment that binds a real interface +// never records a frame and would have paid for it anyway. Same lazy-allocation reasoning as the +// task-snapshot scratch in the ESP32 platform layer. Never freed: freeing would put the allocation +// back on a path that runs per frame, and one buffer per process is the point. +constexpr size_t kEthTestMaxFrames = 132; +uint8_t (*ethTestFrames_)[kEthTestFrameMax] = nullptr; +size_t ethTestLens_[kEthTestMaxFrames] = {}; +size_t ethTestCount_ = 0; +bool ethTestSendFails_ = false; +uint16_t ethTestLinkSpeed_ = 1000; // desktop reports gigabit unless a test says otherwise +int ethRawClaims_ = 0; // drivers holding the link for direct L2 use +uint32_t ethSendFails_ = 0; // consecutive ethSendRaw failures +// The bound raw socket, or -1 for capture mode (the default, and all any test sees). +int ethRawFd_ = -1; +unsigned ethRawIfIndex_ = 0; // Linux AF_PACKET needs the index; BPF binds by name +} // namespace + +// Open a raw L2 socket on `ifName` so a host build drives panels for real — the deployment a Pi or +// a mini-PC covers, and the same code path the ESP32 takes. Linux uses AF_PACKET, macOS BPF; both +// need root (or CAP_NET_RAW), so an ordinary test run simply stays in capture mode. +bool ethBindRawInterface(const char* ifName) { +#ifdef _WIN32 + // No raw-L2 send without a third-party driver (WinPcap/Npcap) on Windows; capture mode only. + (void)ifName; + return false; +#else + if (ethRawFd_ >= 0) { ::close(ethRawFd_); ethRawFd_ = -1; } + ethRawIfIndex_ = 0; + if (!ifName || !ifName[0]) return true; // explicit return to capture mode + +#if defined(__linux__) + const int fd = ::socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); + if (fd < 0) return false; + const unsigned idx = if_nametoindex(ifName); + if (idx == 0) { ::close(fd); return false; } + ethRawFd_ = fd; + ethRawIfIndex_ = idx; + return true; +#elif defined(__APPLE__) + // BPF has no single device: open the first free /dev/bpfN, then bind it to the interface. + for (int i = 0; i < 99; i++) { + char dev[24]; + std::snprintf(dev, sizeof(dev), "/dev/bpf%d", i); + const int fd = ::open(dev, O_RDWR); + if (fd < 0) continue; // busy or no permission — try the next + ifreq ifr = {}; + std::snprintf(ifr.ifr_name, sizeof(ifr.ifr_name), "%s", ifName); + if (::ioctl(fd, BIOCSETIF, &ifr) < 0) { ::close(fd); return false; } + // Write whole frames as given. Without this BPF supplies its OWN source MAC, overwriting + // the fixed one the cards filter on — the frames would go out well-formed and be ignored, + // which is the hardest kind of failure to diagnose. So a failure here fails the bind. + unsigned hdrComplete = 1; + if (::ioctl(fd, BIOCSHDRCMPLT, &hdrComplete) < 0) { ::close(fd); return false; } + ethRawFd_ = fd; + return true; + } + return false; +#else + (void)ifName; + return false; // no raw-L2 path on this host OS +#endif +#endif // _WIN32 +} + +// Send the frame on the bound interface, or record it when none is bound. The capture branch is +// what every unit test exercises; the raw branch is what makes a host a panel controller. +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; +} + +uint32_t ethSendFailStreak() MM_NONBLOCKING { return ethSendFails_; } + + +// See platform.h: a claim stated by the driver, reference-counted. +void ethClaimRawL2(bool claim) { + if (claim) ethRawClaims_++; + else if (ethRawClaims_ > 0) ethRawClaims_--; +} + +bool ethRawL2Claimed() MM_NONBLOCKING { return ethRawClaims_ > 0; } + +// The host has no negotiated link. Report gigabit so the driver's speed check passes on desktop and +// its tests exercise the send path rather than the too-slow branch (which has its own test via +// setTestEthLinkSpeed). +uint16_t ethLinkSpeedMbps() MM_NONBLOCKING { return ethTestLinkSpeed_; } + +size_t ethTestFrameCount() { return ethTestCount_; } +size_t ethTestFrameLength(size_t i) { return i < kEthTestMaxFrames ? ethTestLens_[i] : 0; } +const uint8_t* ethTestFrameData(size_t i) { + return (ethTestFrames_ && i < kEthTestMaxFrames) ? ethTestFrames_[i] : nullptr; +} +void ethTestClearFrames() { ethTestCount_ = 0; ethSendFails_ = 0; } +void setTestEthSendFails(bool fail) { ethTestSendFails_ = fail; } +void setTestEthLinkSpeed(uint16_t mbps) { ethTestLinkSpeed_ = mbps; } void ethGetIPv4(uint8_t out[4]) MM_NONBLOCKING { // Desktop has no real interface state, but DevicesModule needs the host's LAN // IP to scan from (otherwise a desktop projectMM instance reports "no network" and diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h index de60cbc4..ca38b2b1 100644 --- a/src/platform/esp32/platform_config.h +++ b/src/platform/esp32/platform_config.h @@ -213,6 +213,10 @@ constexpr bool hasNetwork = hasWiFi || hasEthernet; // P4 board (RMII only) ethInit() can't bring up W5500, so the live path must not // tear down the working RMII interface for a type it can't init. Mirrors the // MM_ETH_W5500 marker in platform_esp32.cpp; false on desktop (no SPI-eth there). +// hasNamedNetInterfaces — false on every ESP32: one MAC per chip, so a raw sender has nothing to +// choose between and a NIC-name control would do nothing. See the desktop config for the true case. +constexpr bool hasNamedNetInterfaces = false; + #if defined(CONFIG_ETH_USE_SPI_ETHERNET) && !defined(CONFIG_ETH_USE_ESP32_EMAC) constexpr bool hasEthW5500 = true; #else diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 46c9af6b..dcfc6f7c 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -906,6 +906,72 @@ void ethGetIPv4(uint8_t out[4]) MM_NONBLOCKING { netifIPv4(ethNetif_, out); } +// One raw frame straight to the MAC, bypassing lwIP entirely — see platform.h for the contract. +// esp_eth_transmit takes the frame as-is (destination MAC first, EtherType at offset 12) and hands +// it to the DMA; there is no netif, so no IP, no route lookup, and no DHCP lease involved. +// +// Gated on the LINK, not on ethConnected(): the IP stack is a layer above this one, and requiring +// an address here would make a board that never completes DHCP unable to drive panels it is +// perfectly capable of driving. +// +// Synchronous by contract: esp_eth_transmit returns once the frame is queued to the DMA, so the +// caller may reuse its buffer immediately (which is why the driver can hold one packet buffer and +// loop). Any error means the frame did not go out — a full TX ring under back-pressure being the +// normal case — and the caller drops it rather than retrying, the same tolerance a UDP send has. +// How many drivers have claimed the link for direct L2 use. Atomic: claimed on the render task, +// read by NetworkModule's tick. +static std::atomic<int> ethRawClaims_{0}; + +void ethClaimRawL2(bool claim) { + if (claim) { + ethRawClaims_.fetch_add(1, std::memory_order_relaxed); + } else if (ethRawClaims_.load(std::memory_order_relaxed) > 0) { + ethRawClaims_.fetch_sub(1, std::memory_order_relaxed); + } +} + +bool ethRawL2Claimed() MM_NONBLOCKING { + return ethRawClaims_.load(std::memory_order_relaxed) > 0; +} + +// Consecutive failures, so a caller can distinguish back-pressure from a wedged path (see +// platform.h). Written on the render task, read by the driver's 1 Hz status tick. +static std::atomic<uint32_t> ethSendFails_{0}; + +bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING { + if (!ethHandle_ || !frame || len == 0) return false; + if (!ethLinkUp_.load(std::memory_order_relaxed)) return false; + if (esp_eth_transmit(ethHandle_, const_cast<uint8_t*>(frame), len) != ESP_OK) { + ethSendFails_.fetch_add(1, std::memory_order_relaxed); + return false; + } + ethSendFails_.store(0, std::memory_order_relaxed); + return true; +} + +uint32_t ethSendFailStreak() MM_NONBLOCKING { + return ethSendFails_.load(std::memory_order_relaxed); +} + +// One MAC per chip, so there is no interface to choose: ethSendRaw always uses it. Accepting the +// call (rather than failing) keeps the driver's control identical on every target — the field is +// simply ignored here, which is what the driver's own comment tells the user. +bool ethBindRawInterface(const char*) { return true; } + +// Negotiated link speed, asked of the driver rather than assumed from the PHY type: a gigabit PHY +// on a 100 Mbit switch (or a bad cable) negotiates down, and that is precisely the case worth +// reporting. 0 when there is no link to describe. +uint16_t ethLinkSpeedMbps() MM_NONBLOCKING { + if (!ethHandle_ || !ethLinkUp_.load(std::memory_order_relaxed)) return 0; + eth_speed_t speed = ETH_SPEED_10M; + if (esp_eth_ioctl(ethHandle_, ETH_CMD_G_SPEED, &speed) != ESP_OK) return 0; + switch (speed) { + case ETH_SPEED_1000M: return 1000; + case ETH_SPEED_100M: return 100; + default: return 10; + } +} + #else // MM_NO_ETH — firmware excludes EMAC support (chip-side or sdkconfig fragment // wasn't layered. Provide stubs matching the desktop platform's no-eth // behaviour so NetworkModule's cascade falls straight to WiFi (or AP). @@ -916,6 +982,12 @@ bool ethInit() { return false; } bool ethLinkUp() MM_NONBLOCKING { return false; } bool ethConnected() MM_NONBLOCKING { return false; } void ethGetIPv4(uint8_t out[4]) MM_NONBLOCKING { out[0] = out[1] = out[2] = out[3] = 0; } +bool ethSendRaw(const uint8_t*, size_t) MM_NONBLOCKING { return false; } // no MAC to hand a frame to +void ethClaimRawL2(bool) {} // no link to claim +bool ethRawL2Claimed() MM_NONBLOCKING { return false; } +uint16_t ethLinkSpeedMbps() MM_NONBLOCKING { return 0; } // no link to describe +uint32_t ethSendFailStreak() MM_NONBLOCKING { return 0; } // nothing sends, nothing fails +bool ethBindRawInterface(const char*) { return true; } // no MAC, nothing to bind #endif // MM_NO_ETH diff --git a/src/platform/esp32/platform_esp32_i2s.cpp b/src/platform/esp32/platform_esp32_i2s.cpp index 9d369887..2e25fbb1 100644 --- a/src/platform/esp32/platform_esp32_i2s.cpp +++ b/src/platform/esp32/platform_esp32_i2s.cpp @@ -21,6 +21,7 @@ #if SOC_I2S_SUPPORTED #include "driver/i2s_std.h" +#include "esp_heap_caps.h" // heap_caps_malloc — the FFT scratch, internal RAM only #include "esp_log.h" #include "dsps_fft2r.h" @@ -39,11 +40,21 @@ struct MicState { }; // esp-dsp's float FFT works in place on an interleaved complex array (re, im, -// re, im, …). We keep one scratch sized to the largest block we'll see; the -// twiddle tables are initialised once, lazily, on first use. +// re, im, …). One scratch sized to the largest block we'll see; the twiddle +// tables are initialised once, lazily, on first use. +// +// ALLOCATED on first FFT, not held in .bss. As a plain array this was 8 KB of +// INTERNAL RAM reserved from boot on every board — 2.5% of the ~320 KB pool that +// WiFi, the HTTP stack and every task stack share — including the many boards +// with no microphone fitted, where nothing ever reads it. A module that is not +// used should cost nothing (surfaced by check_footprint's STATIC column, where +// this file read 561 B of code against 8193 B of static). Now a board without +// audio pays 4 bytes for the pointer, and one with audio allocates on its first +// analysed frame and keeps it for the process — no per-frame allocation on the +// audio path, which runs at block rate. constexpr size_t kMaxFftN = 1024; -float g_fftBuf[kMaxFftN * 2]; -bool g_fftReady = false; +float* g_fftBuf = nullptr; +bool g_fftReady = false; bool ensureFftInit() { if (g_fftReady) return true; @@ -53,6 +64,15 @@ bool ensureFftInit() { ESP_LOGE(I2S_TAG, "esp-dsp FFT init failed"); return false; } + // Internal RAM, not PSRAM: esp-dsp's assembly kernels run per audio block and + // a PSRAM scratch would put a cache miss in the middle of every butterfly. + g_fftBuf = static_cast<float*>( + heap_caps_malloc(kMaxFftN * 2 * sizeof(float), MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + if (!g_fftBuf) { + ESP_LOGE(I2S_TAG, "FFT scratch alloc failed (%u B internal)", + static_cast<unsigned>(kMaxFftN * 2 * sizeof(float))); + return false; // audioFft zero-fills its output, so the caller degrades to silence + } g_fftReady = true; return true; } diff --git a/src/platform/esp32/platform_esp32_ota.cpp b/src/platform/esp32/platform_esp32_ota.cpp index 9879efc4..8dc7c25d 100644 --- a/src/platform/esp32/platform_esp32_ota.cpp +++ b/src/platform/esp32/platform_esp32_ota.cpp @@ -13,6 +13,7 @@ #include "esp_crt_bundle.h" #include "esp_http_client.h" #include "esp_system.h" +#include "esp_heap_caps.h" // heap_caps_malloc/free — the upload chunk buffer #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -21,10 +22,15 @@ #include <cstdarg> #include <cstdio> #include <cstring> +#include <memory> // unique_ptr — frees the upload buffer on every exit path #include <new> // std::nothrow for the OtaTaskParams alloc below namespace mm::platform { +// One upload chunk. 4 KB matches the flash page granularity esp_ota_write prefers and is +// the size the HTTP path already streams in. +constexpr size_t kOtaChunkBytes = 4096; + namespace { // Heap-allocated task parameters. Task owns this and frees it on exit. @@ -207,11 +213,31 @@ bool otaWriteStream(FsWriteSrc src, void* user, size_t contentLen, // Pull the upload body chunk-by-chunk and write each into the partition — the same producer // callback fsWriteStream drives, here feeding esp_ota_write instead of a file. `abort` from the // caller (an incomplete/timed-out upload) fails the OTA, and esp_ota_abort discards the partial. - static uint8_t buf[4096]; // static: keep it off this call's stack (4 KB is large for a task frame) + // Heap, not static, and not the stack either. 4 KB is far too large for a task frame, but as a + // `static` it held 4 KB of INTERNAL RAM from boot to power-off for a buffer used only while a + // firmware image is uploading — minutes of the device's life at most, and never at all on a + // device that is never updated. An OTA is the one moment when spare RAM is least scarce (the + // render path is the only other big consumer), so allocating here and freeing at every exit + // costs nothing and gives the 4 KB back to WiFi and the HTTP stack for the other 99.9% of + // uptime. Surfaced by check_footprint's STATIC column: this file read 1016 B of code against + // 4096 B of static. + // + // Failing the alloc aborts the OTA cleanly rather than proceeding — a firmware write with no + // buffer is not something to degrade around. + // unique_ptr, not a raw malloc: there are six exit paths below (abort, write error, truncated + // upload, three esp_ota failures) and a leak on any of them would be a 4 KB hole per attempt. + const std::unique_ptr<uint8_t, decltype(&heap_caps_free)> owned( + static_cast<uint8_t*>(heap_caps_malloc(kOtaChunkBytes, MALLOC_CAP_8BIT)), &heap_caps_free); + uint8_t* const buf = owned.get(); + if (!buf) { + setStatus("error: out of memory for the upload buffer"); + esp_ota_abort(handle); + return false; + } uint32_t written = 0; for (;;) { bool abort = false; - const size_t n = src(reinterpret_cast<char*>(buf), sizeof(buf), user, &abort); + const size_t n = src(reinterpret_cast<char*>(buf), kOtaChunkBytes, user, &abort); if (abort) { setStatus("error: upload aborted"); esp_ota_abort(handle); diff --git a/src/platform/esp32/platform_esp32_tasks.cpp b/src/platform/esp32/platform_esp32_tasks.cpp index e4d98ee4..1e802ff9 100644 --- a/src/platform/esp32/platform_esp32_tasks.cpp +++ b/src/platform/esp32/platform_esp32_tasks.cpp @@ -19,6 +19,7 @@ #if defined(CONFIG_FREERTOS_USE_TRACE_FACILITY) && CONFIG_FREERTOS_USE_TRACE_FACILITY #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "esp_heap_caps.h" // heap_caps_calloc — the one-time snapshot scratch #endif namespace mm::platform { @@ -41,15 +42,36 @@ TaskState mapState(eTaskState s) { size_t taskSnapshot(TaskInfo* out, size_t maxTasks) { if (!out || maxTasks == 0) return 0; - // Fixed static scratch, NOT a malloc: this runs from tick1s, which the Scheduler dispatches inside - // tick() — the hot path, where the no-heap rule applies. uxTaskGetSystemState wants a buffer for - // every task (a short one returns 0), so the scratch is sized to a generous ceiling; if the system - // ever exceeds it uxTaskGetSystemState returns 0 and the snapshot is simply empty that tick. The - // walk still briefly suspends the scheduler — an accepted once-per-second cost for a diagnostic the - // user opted into by adding the module; it is not per-frame. Single-threaded access (one render - // task calls this), so a plain static needs no guard. + // Allocated ONCE, on the first snapshot — not held from boot, and not re-allocated per tick. + // + // The no-heap rule applies here: this runs from tick1s, which the Scheduler dispatches inside + // tick(). One lazy allocation satisfies it — every tick after the first is allocation-free, and + // the first one happens when the user adds TasksModule, not on the render path at steady state. + // As a plain `static TaskStatus_t raw[40]` it cost 1440 B of INTERNAL RAM from boot on every + // board, and TasksModule is opt-in: it appears in no device model, so the overwhelmingly common + // case paid for a diagnostic it never used (surfaced by check_footprint's STATIC column, where + // this file read 230 B of code against 1440 B of static). + // + // Kept for the process rather than freed per call: freeing would put the allocation back on + // every tick, which is the thing the no-heap rule forbids. A module that is removed leaves the + // buffer behind — 1440 B once used, against 1440 B always — and re-adding it reuses the same + // one. Single-threaded (one render task calls this), so no guard is needed. + // + // uxTaskGetSystemState wants room for EVERY task or it returns 0, so the ceiling is generous; + // exceeding it makes the snapshot empty that tick rather than partial. The walk also briefly + // suspends the scheduler — an accepted once-per-second cost for an opt-in diagnostic. + // INTERNAL, not PSRAM, even though 1440 B would fit PSRAM comfortably: uxTaskGetSystemState + // fills this buffer with the kernel lock held (`prvENTER_CRITICAL_OR_SUSPEND_ALL(&xKernelLock)` + // around its whole walk, FreeRTOS-Kernel/tasks.c), so every write lands inside a critical + // section. A PSRAM cache miss there stretches that section — the one place on this chip where + // a stall is most expensive. The 1440 B is worth spending to keep the walk deterministic. static constexpr size_t kScratch = 40; - static TaskStatus_t raw[kScratch]; + static TaskStatus_t* raw = nullptr; + if (!raw) { + raw = static_cast<TaskStatus_t*>( + heap_caps_calloc(kScratch, sizeof(TaskStatus_t), MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT)); + if (!raw) return 0; // no scratch, no snapshot: the table shows empty, nothing crashes + } uint32_t totalRunTime = 0; const UBaseType_t got = uxTaskGetSystemState(raw, kScratch, &totalRunTime); const size_t n = got < maxTasks ? got : maxTasks; diff --git a/src/platform/platform.h b/src/platform/platform.h index 0877215f..38e83eff 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -349,6 +349,79 @@ bool ethConnected() MM_NONBLOCKING; // IP assigned (DHCP complete) // their own boundary, callers that need bytes (ArtNet) use them directly. void ethGetIPv4(uint8_t out[4]) MM_NONBLOCKING; +// Put one complete Ethernet frame on the wire, below IP: `frame` starts at the destination MAC and +// carries its own EtherType, so nothing here has an address, a route, or a DHCP lease. Panel +// receiver cards are addressed this way: they answer to a MAC and an EtherType, +// never to an IP. +// +// Needs a link, not an IP: ethLinkUp() is the precondition, ethConnected() is not. That split is +// the point of the seam — a board whose DHCP never completes can still drive panels, and the +// driver's status says which of the two is missing. +// +// `len` is the payload as handed to the MAC: below 60 bytes the hardware pads to the Ethernet +// minimum, so callers need not. Returns false when no driver is running, the link is down, or the +// MAC rejects the frame (a full TX ring) — a dropped frame, like a dropped UDP packet, is the +// caller's to tolerate. Desktop records the frame instead of sending it, which is what lets the +// driver and its tests run on the host. +bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING; + +// Claim the Ethernet interface for direct L2 use, or release it. A driver that addresses the wire +// below IP calls this in prepare/release to STATE its intent, rather than leaving +// NetworkModule to infer it from traffic — the driver knows, and a claim made before the first frame +// cannot race the cascade's DHCP timeout. +// +// What it changes: nothing about the hardware. Ethernet keeps running and the cascade still moves on +// to WiFi for IP service (which is what a panel rig wants — panels on the wire, UI over WiFi). It +// only tells NetworkModule that a leaseless link is intended rather than broken. +// +// Reference-counted, so two drivers sharing the link both have to release before the claim drops. +void ethClaimRawL2(bool claim); + +// True while any driver holds a raw-L2 claim. NetworkModule reads this to tell "Ethernet is broken" +// apart from "Ethernet carries no IP because something is driving it directly". +bool ethRawL2Claimed() MM_NONBLOCKING; + +// Consecutive ethSendRaw() failures since the last success. A raw sender polls this to tell a +// dropped frame (normal back-pressure, count returns to 0) from a wedged transmit path: the IDF +// driver refuses every frame once ITS link flag reads down, which can outlive our own event-driven +// flag and would otherwise look like a healthy link sending nothing. +uint32_t ethSendFailStreak() MM_NONBLOCKING; + +// Negotiated link speed in Mbit/s (10 / 100 / 1000); 0 when no link or no driver. Reported rather +// than enforced: panel cards want a gigabit link, and a driver that knows the actual speed can say +// "100 Mbit, expect tearing" instead of either failing silently or refusing to run. +uint16_t ethLinkSpeedMbps() MM_NONBLOCKING; + +// Bind raw sending to a host network interface by name ("eth0", "en0"). ESP32 ignores this — it has +// one MAC and ethSendRaw always uses it. On desktop it opens the raw socket (Linux AF_PACKET, macOS +// BPF) that makes a host a real panel controller: the same driver on a Pi or a mini-PC does what an +// FPP host does, which is worth having both as a product and as the way to test the wire format +// without an ESP32 in the loop. +// +// Returns false when the interface is unknown or the process lacks the privilege (raw L2 is +// root/CAP_NET_RAW on every OS). Failure is not fatal: sending falls back to CAPTURE mode, where +// frames are recorded for the tests and the driver reports why nothing reached the wire. Call with +// nullptr or "" to return to capture mode. +bool ethBindRawInterface(const char* ifName); + +// Desktop-only test seam: the frames ethSendRaw() captured, so a host test can pin what the driver +// put on the wire. Active whenever no raw interface is bound, which is the default and the only +// mode an unprivileged test process ever sees. Count resets with ethTestClearFrames(); a frame +// longer than kEthTestFrameMax is recorded truncated (its true length still reported) so an +// oversized send is visible, not silent. +#ifndef ESP_PLATFORM +constexpr size_t kEthTestFrameMax = 1512; // the panel format's largest frame +size_t ethTestFrameCount(); +size_t ethTestFrameLength(size_t i); +const uint8_t* ethTestFrameData(size_t i); +void ethTestClearFrames(); +// Make the next ethSendRaw() calls fail, so a test can exercise the link-down path +// (mirrors setTestBindFails for UdpSocket). +void setTestEthSendFails(bool fail); +// Override the reported link speed so a test can exercise the too-slow-link status. +void setTestEthLinkSpeed(uint16_t mbps); +#endif + bool wifiStaInit(const char* ssid, const char* password); bool wifiStaConnected() MM_NONBLOCKING; void wifiStaGetIPv4(uint8_t out[4]); // see ethGetIPv4 — same octet contract diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 88d27114..3466da2f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -60,6 +60,8 @@ add_executable(mm_tests unit/light/unit_AudioBands.cpp unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp unit/light/unit_NetworkSendDriver_packet.cpp + unit/light/unit_PanelCardDriver.cpp + unit/light/unit_PanelCardDriver_packet.cpp unit/light/unit_WledAudioSyncPacket.cpp unit/light/unit_BlendMap.cpp unit/light/unit_draw.cpp diff --git a/test/unit/light/unit_PanelCardDriver.cpp b/test/unit/light/unit_PanelCardDriver.cpp new file mode 100644 index 00000000..59014381 --- /dev/null +++ b/test/unit/light/unit_PanelCardDriver.cpp @@ -0,0 +1,361 @@ +// @module PanelCardDriver +// @also Drivers, Correction + +// End-to-end tests for the raw-Ethernet panel driver. The desktop platform records every frame +// ethSendRaw() emits instead of putting it on a wire, so these pin what the driver would actually +// send — the geometry, the chunking, and the sync-last ordering — with no hardware and no +// privileges. The bench then only has to confirm the wire itself. + +#include "doctest.h" +#include "light/drivers/PanelCardDriver.h" +#include "light/drivers/Correction.h" +#include "correction_presets.h" +#include "light/layers/Buffer.h" +#include "light/layouts/Layouts.h" +#include "light/layouts/GridLayout.h" +#include "light/layouts/PanelsLayout.h" + +namespace { + +// Build a driver over a `count`-light source with a plain RGB correction, ready to tick. +// Build a driver over a `count`-light source. With no Layer wired the driver falls back to the +// panel arrangement for the wall size, which is exactly what a bare unit test wants: geometry it +// states rather than geometry it has to construct a Layout for. +// A wall of `width` x `height`, wired the way production wires it: a Layout gives the Layer its +// physical size, and the driver reads that. The driver has no geometry of its own to set. +struct Wall { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + + Wall(uint16_t width, uint16_t height) { + grid.width = width; + grid.height = height; + grid.depth = 1; + layouts.addChild(&grid); + layouts.applyState(); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.applyState(); + } +}; + +void setUp(mm::PanelCardDriver& driver, mm::Buffer& source, Wall& wall, + mm::nrOfLightsType count) { + REQUIRE(source.allocate(count, 3)); + mm::Correction correction; + mm::test::rebuildFromPreset(correction, 255, mm::test::PresetOrder::RGB); + driver.setLayer(&wall.layer); + driver.setSourceBuffer(&source); + driver.correctionForTest() = correction; + driver.applyState(); + mm::platform::ethTestClearFrames(); +} + +// Drop any raw-L2 claim left by an earlier test's driver. Test drivers are stack objects that are +// destroyed without release() being called, so the claim count would otherwise carry across cases. +void clearClaims() { + while (mm::platform::ethRawL2Claimed()) mm::platform::ethClaimRawL2(false); +} + +// The packet-type byte of a captured frame (offset 12), which says row / sync / brightness. +uint8_t frameType(size_t i) { + return mm::platform::ethTestFrameData(i)[12]; +} + +} // namespace + +// A panel is sent as one frame per row followed by a single sync — the sync is what latches the +// image, so it must arrive after every row it applies to. +TEST_CASE("PanelCardDriver sends one frame per row then one sync") { + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 4); + setUp(driver, source, wall, 256); + + mm::platform::setTestNowMs(1000); + driver.tick(); + + REQUIRE(mm::platform::ethTestFrameCount() == 8); // 2 brightness + 4 rows + 2 sync + CHECK(frameType(0) == mm::COLORLIGHT_TYPE_BRIGHTNESS); + CHECK(frameType(1) == mm::COLORLIGHT_TYPE_BRIGHTNESS); + for (size_t i = 2; i <= 5; i++) CHECK(frameType(i) == mm::COLORLIGHT_TYPE_ROW); + CHECK(frameType(6) == mm::COLORLIGHT_TYPE_SYNC); + CHECK(frameType(7) == mm::COLORLIGHT_TYPE_SYNC); +} + +// A buffer smaller than the wall sends only the rows it covers, then latches — rather than reading +// past the buffer for the rows it does not have. +TEST_CASE("PanelCardDriver stops at the last row its buffer covers") { + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 8); // wall is 8 rows high + setUp(driver, source, wall, 128); // buffer holds only 2 rows of it + + mm::platform::setTestNowMs(1000); + driver.tick(); + + REQUIRE(mm::platform::ethTestFrameCount() == 6); // 2 brightness + 2 rows + 2 sync, not 8 rows + CHECK(frameType(5) == mm::COLORLIGHT_TYPE_SYNC); +} + +// A row wider than one packet splits into several, each carrying its own pixel offset — the wide- +// panel case, where 497 pixels is the per-packet ceiling. +TEST_CASE("PanelCardDriver splits a row wider than one packet") { + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(600, 1); // one row of 600 > the 497-pixel packet ceiling + setUp(driver, source, wall, 600); + + mm::platform::setTestNowMs(1000); + driver.tick(); + + REQUIRE(mm::platform::ethTestFrameCount() == 6); // 2 brightness + 2 row packets + 2 sync + // First chunk: offset 0, a full 497 pixels. + const uint8_t* a = mm::platform::ethTestFrameData(2); + CHECK(((a[15] << 8) | a[16]) == 0); + CHECK(((a[17] << 8) | a[18]) == mm::COLORLIGHT_MAX_PIXELS_PER_PACKET); + // Second chunk: continues at 497, carrying the remaining 103. + const uint8_t* b = mm::platform::ethTestFrameData(3); + CHECK(((b[15] << 8) | b[16]) == mm::COLORLIGHT_MAX_PIXELS_PER_PACKET); + CHECK(((b[17] << 8) | b[18]) == 600 - mm::COLORLIGHT_MAX_PIXELS_PER_PACKET); +} + +// Pixel bytes reach the wire unchanged, so what an effect rendered is what the panel receives. +TEST_CASE("PanelCardDriver puts rendered pixels on the wire") { + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(2, 1); + setUp(driver, source, wall, 2); + uint8_t* px = source.data(); + px[0] = 10; px[1] = 20; px[2] = 30; + px[3] = 40; px[4] = 50; px[5] = 60; + + mm::platform::setTestNowMs(1000); + driver.tick(); + + REQUIRE(mm::platform::ethTestFrameCount() == 5); // 2 brightness + row + 2 sync + const uint8_t* row = mm::platform::ethTestFrameData(2); + CHECK(row[mm::COLORLIGHT_ROW_PREFIX + 0] == 10); + CHECK(row[mm::COLORLIGHT_ROW_PREFIX + 1] == 20); + CHECK(row[mm::COLORLIGHT_ROW_PREFIX + 2] == 30); + CHECK(row[mm::COLORLIGHT_ROW_PREFIX + 3] == 40); +} + +// The fps control caps the send rate, so a render loop faster than the panel needs doesn't +// saturate the link. +TEST_CASE("PanelCardDriver rate-limits to its fps setting") { + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 1); + setUp(driver, source, wall, 64); + driver.fps = 40; // one frame every 25 ms + + mm::platform::setTestNowMs(1000); + driver.tick(); + const size_t after1 = mm::platform::ethTestFrameCount(); + CHECK(after1 == 5); // 2 brightness + row + 2 sync + + mm::platform::setTestNowMs(1010); // too soon + driver.tick(); + CHECK(mm::platform::ethTestFrameCount() == after1); + + mm::platform::setTestNowMs(1030); // past the interval + driver.tick(); + CHECK(mm::platform::ethTestFrameCount() > after1); +} + +// Nothing is latched when the window is empty: an all-zero geometry must not emit a bare sync, +// which would blank a panel that another driver is feeding. +TEST_CASE("PanelCardDriver sends nothing when the window is empty") { + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 1); + setUp(driver, source, wall, 64); + driver.fps = 0; // nothing to send + + mm::platform::setTestNowMs(1000); + driver.tick(); + + CHECK(mm::platform::ethTestFrameCount() == 0); +} + +// A dropped frame doesn't stall the driver: the cards give no acknowledgement, so a failed send is +// tolerated exactly as a dropped UDP packet is, and the next tick proceeds. +TEST_CASE("PanelCardDriver survives a failing link") { + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 1); + setUp(driver, source, wall, 64); + + mm::platform::setTestEthSendFails(true); + mm::platform::setTestNowMs(1000); + driver.tick(); // must not hang or crash + CHECK(mm::platform::ethTestFrameCount() == 0); // nothing reached the wire + + mm::platform::setTestEthSendFails(false); + mm::platform::setTestNowMs(1100); + driver.tick(); + CHECK(mm::platform::ethTestFrameCount() == 5); // recovers on the next tick +} + +// The correction-applied buffer is sized off the hot path, so tick() never allocates — the same +// contract the other sinks hold. +TEST_CASE("PanelCardDriver sizes its buffer in prepare, not in tick") { + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 1); + setUp(driver, source, wall, 64); + + REQUIRE(driver.correctedBuffer().data() != nullptr); + CHECK(driver.correctedBuffer().count() == 64); + + const uint8_t* before = driver.correctedBuffer().data(); + mm::platform::setTestNowMs(1000); + driver.tick(); + CHECK(driver.correctedBuffer().data() == before); + CHECK(driver.correctedBuffer().count() == 64); +} + +// The driver CLAIMS the ethernet interface while it is enabled, which is how NetworkModule tells +// "the cable is broken" apart from "a driver is using the wire below IP". Stated at prepare rather +// than inferred from traffic, so the claim is in place before the first frame and cannot race the +// network cascade's DHCP timeout. +TEST_CASE("PanelCardDriver claims the ethernet link while enabled") { + clearClaims(); + mm::Buffer source; + mm::PanelCardDriver driver; + CHECK_FALSE(mm::platform::ethRawL2Claimed()); // nothing claimed before setup + + Wall wall(64, 1); + setUp(driver, source, wall, 64); // applyState() runs prepare() + CHECK(mm::platform::ethRawL2Claimed()); // claimed without a frame being sent + + driver.release(); + CHECK_FALSE(mm::platform::ethRawL2Claimed()); // released with the driver +} + +// Re-preparing (a geometry or window edit) must not stack claims, or the link would stay claimed +// after the driver is gone. +TEST_CASE("PanelCardDriver claim survives a re-prepare without stacking") { + clearClaims(); + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 1); + setUp(driver, source, wall, 64); + + driver.prepare(); + driver.prepare(); + CHECK(mm::platform::ethRawL2Claimed()); + + driver.release(); + CHECK_FALSE(mm::platform::ethRawL2Claimed()); // one release is enough +} + +// The wall a PanelsLayout describes is what reaches the card: two 128x64 panels stacked give 128 +// rows of 128 pixels, and the driver reads that from the Layout rather than from controls of its +// own. This is the setup a ColorLight card is normally configured for. +TEST_CASE("PanelCardDriver sends the wall a PanelsLayout describes") { + clearClaims(); + mm::Layouts layouts; + mm::PanelsLayout panels; + panels.horizontalPanels = 1; + panels.verticalPanels = 2; // stacked + panels.panelWidth = 128; + panels.panelHeight = 64; + layouts.addChild(&panels); + layouts.applyState(); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.applyState(); + REQUIRE(layer.physicalWidth() == 128); + REQUIRE(layer.physicalHeight() == 128); + + mm::Buffer source; + REQUIRE(source.allocate(128 * 128, 3)); + mm::Correction correction; + mm::test::rebuildFromPreset(correction, 255, mm::test::PresetOrder::RGB); + mm::PanelCardDriver driver; + driver.setLayer(&layer); + driver.setSourceBuffer(&source); + driver.correctionForTest() = correction; + driver.applyState(); + mm::platform::ethTestClearFrames(); + + mm::platform::setTestNowMs(1000); + driver.tick(); + + // 2 brightness + 128 rows (one packet each, 128 <= 497) + 2 sync + REQUIRE(mm::platform::ethTestFrameCount() == 132); + // Rows are numbered across the whole card, not restarted per panel. + const uint8_t* firstRow = mm::platform::ethTestFrameData(2); + REQUIRE(firstRow != nullptr); + CHECK(((firstRow[13] << 8) | firstRow[14]) == 0); + const uint8_t* lastRow = mm::platform::ethTestFrameData(129); + REQUIRE(lastRow != nullptr); + CHECK(((lastRow[13] << 8) | lastRow[14]) == 127); +} + +// Panels chained side by side widen each card row instead of adding rows — the same Layout control +// the user already sets, with no second place to state it. +TEST_CASE("PanelCardDriver widens the row when a PanelsLayout chains panels across") { + clearClaims(); + mm::Layouts layouts; + mm::PanelsLayout panels; + panels.horizontalPanels = 2; // chained: 256 px per row + panels.verticalPanels = 1; + panels.panelWidth = 128; + panels.panelHeight = 64; + layouts.addChild(&panels); + layouts.applyState(); + + mm::Layer layer; + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + layer.applyState(); + REQUIRE(layer.physicalWidth() == 256); + + mm::Buffer source; + REQUIRE(source.allocate(256 * 64, 3)); + mm::Correction correction; + mm::test::rebuildFromPreset(correction, 255, mm::test::PresetOrder::RGB); + mm::PanelCardDriver driver; + driver.setLayer(&layer); + driver.setSourceBuffer(&source); + driver.correctionForTest() = correction; + driver.applyState(); + mm::platform::ethTestClearFrames(); + + mm::platform::setTestNowMs(1000); + driver.tick(); + + // 2 brightness + 64 rows + 2 sync — still one packet per row, since 256 <= 497. + REQUIRE(mm::platform::ethTestFrameCount() == 68); + const uint8_t* row = mm::platform::ethTestFrameData(2); + REQUIRE(row != nullptr); + CHECK(((row[17] << 8) | row[18]) == 256); // pixels in this packet +} + +// A transmit path that refuses every frame must SAY so. It reads as a healthy link otherwise — the +// IDF driver keeps reporting the negotiated speed while rejecting every frame — so "0 packets/s at +// 1 Gbit" would look identical to an idle effect. +TEST_CASE("PanelCardDriver reports a failing transmit path instead of a healthy link") { + clearClaims(); + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 1); + setUp(driver, source, wall, 64); + + mm::platform::setTestEthSendFails(true); + mm::platform::setTestNowMs(1000); + driver.tick(); + driver.tick1s(); + CHECK(mm::platform::ethSendFailStreak() > 0); + + mm::platform::setTestEthSendFails(false); + mm::platform::setTestNowMs(1100); + driver.tick(); + CHECK(mm::platform::ethSendFailStreak() == 0); // a success clears it: back-pressure is not a fault +} diff --git a/test/unit/light/unit_PanelCardDriver_packet.cpp b/test/unit/light/unit_PanelCardDriver_packet.cpp new file mode 100644 index 00000000..2d9abcc6 --- /dev/null +++ b/test/unit/light/unit_PanelCardDriver_packet.cpp @@ -0,0 +1,115 @@ +// @module PanelCardDriver + +#include "doctest.h" +#include "light/ColorLight5A75Packet.h" + +#include <cstring> + +// The cards filter on a fixed destination MAC, so a frame sent anywhere else (broadcast, or from +// this device's own address) is discarded without any error the sender can see. +TEST_CASE("Panel frames carry the fixed MAC pair the cards filter on") { + uint8_t packet[mm::COLORLIGHT_MAX_FRAME]; + const uint8_t pixels[3] = {1, 2, 3}; + + mm::buildColorLightRowPacket(packet, 0, 0, 1, pixels); + + const uint8_t dest[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66}; + const uint8_t src[6] = {0x22, 0x22, 0x33, 0x44, 0x55, 0x66}; + CHECK(std::memcmp(packet, dest, 6) == 0); + CHECK(std::memcmp(packet + 6, src, 6) == 0); +} + +// The format overloads the EtherType field: byte 12 is the packet type and byte 13 is ALREADY the +// first payload byte. Pinning this is what stops the whole layout drifting one byte, which the +// cards answer with silence rather than an error. +TEST_CASE("Panel payload starts at byte 13, inside the EtherType field") { + uint8_t packet[mm::COLORLIGHT_MAX_FRAME]; + const uint8_t pixels[6] = {255, 0, 128, 10, 20, 30}; + + const size_t len = mm::buildColorLightRowPacket(packet, 5, 0, 2, pixels); + + CHECK(packet[12] == 0x55); // packet type, where a normal frame's EtherType high byte sits + CHECK(packet[13] == 0x00); // row MSB — the first payload byte, in the EtherType's low half + CHECK(packet[14] == 5); // row LSB + CHECK(packet[15] == 0x00); // pixel offset + CHECK(packet[16] == 0x00); + CHECK(packet[17] == 0x00); // pixel count + CHECK(packet[18] == 2); + CHECK(packet[19] == 0x08); // the two constants the cards require + CHECK(packet[20] == 0x88); + CHECK(packet[21] == 255); // pixel data starts at 21 + CHECK(packet[22] == 0); + CHECK(packet[23] == 128); + CHECK(packet[24] == 10); + CHECK(len == mm::COLORLIGHT_ROW_PREFIX + 6); +} + +// A row number above 255 spans both payload bytes, so a tall panel addresses its lower rows. +TEST_CASE("Panel row number above 255 is big-endian across two bytes") { + uint8_t packet[mm::COLORLIGHT_MAX_FRAME]; + const uint8_t pixels[3] = {7, 8, 9}; + + mm::buildColorLightRowPacket(packet, 300, 0, 1, pixels); // 300 = 0x012C + + CHECK(packet[13] == 0x01); + CHECK(packet[14] == 0x2C); +} + +// A row wider than one packet splits, each carrying its own pixel offset so the card can place the +// chunk without depending on arrival order. +TEST_CASE("Panel row wider than one packet carries a pixel offset") { + uint8_t packet[mm::COLORLIGHT_MAX_FRAME]; + static uint8_t pixels[mm::COLORLIGHT_MAX_PIXELS_PER_PACKET * 3] = {}; + + const size_t len = mm::buildColorLightRowPacket( + packet, 0, mm::COLORLIGHT_MAX_PIXELS_PER_PACKET, 3, pixels); + + CHECK(packet[15] == (mm::COLORLIGHT_MAX_PIXELS_PER_PACKET >> 8)); + CHECK(packet[16] == (mm::COLORLIGHT_MAX_PIXELS_PER_PACKET & 0xFF)); + CHECK(packet[18] == 3); + CHECK(len == mm::COLORLIGHT_ROW_PREFIX + 9); +} + +// A full-width packet is the largest frame the format builds, and it sizes the driver's one reused +// buffer — if this grew past COLORLIGHT_MAX_FRAME the driver would overrun it. +TEST_CASE("Panel full row packet fits the driver's frame buffer") { + uint8_t packet[mm::COLORLIGHT_MAX_FRAME]; + static uint8_t pixels[mm::COLORLIGHT_MAX_PIXELS_PER_PACKET * 3] = {}; + + const size_t len = mm::buildColorLightRowPacket( + packet, 0, 0, mm::COLORLIGHT_MAX_PIXELS_PER_PACKET, pixels); + + CHECK(len == mm::COLORLIGHT_MAX_FRAME); + CHECK(len == 1512); // 21-byte prefix + 497 pixels x 3 +} + +// The sync frame latches everything sent since the last one. Fixed size, mostly zeros, with +// brightness repeated in the four places the cards read it. +TEST_CASE("Panel sync frame is 112 bytes and carries brightness") { + uint8_t packet[mm::COLORLIGHT_SYNC_FRAME]; + + const size_t len = mm::buildColorLightSyncPacket(packet, 200); + + CHECK(len == 112); + CHECK(packet[12] == 0x01); // sync packet type + CHECK(packet[13] == 0x07); // sent from a PC/netcard rather than a hardware sender + CHECK(packet[35] == 200); // payload byte 22 + CHECK(packet[36] == 0x05); + CHECK(packet[38] == 200); // payload bytes 25-27 + CHECK(packet[39] == 200); + CHECK(packet[40] == 200); +} + +// The brightness frame sets the card's own gain, ahead of the row data. +TEST_CASE("Panel brightness frame is 77 bytes on three channels") { + uint8_t packet[mm::COLORLIGHT_BRIGHTNESS_FRAME]; + + const size_t len = mm::buildColorLightBrightnessPacket(packet, 128); + + CHECK(len == 77); + CHECK(packet[12] == 0x0A); // brightness packet type + CHECK(packet[13] == 128); + CHECK(packet[14] == 128); + CHECK(packet[15] == 128); + CHECK(packet[16] == 0xFF); +} From 2c4ea973df95bd32bd8f29808e13589fa6391d08 Mon Sep 17 00:00:00 2001 From: ewowi <ewowi@icloud.com> Date: Fri, 31 Jul 2026 00:02:36 +0200 Subject: [PATCH 5/5] Process review findings; pre-merge docs, perf and permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- .claude/settings.local.cleaned.json | 107 ++++++++++++------ docs/assets/light/drivers/PanelCardDriver.png | Bin 0 -> 99147 bytes ...0260730 - Panel cards over raw Ethernet.md | 2 +- docs/moonmodules/light/drivers.md | 4 +- docs/performance.md | 28 +++++ esp32/main/CMakeLists.txt | 2 +- moondeck/MoonDeck.md | 3 +- moondeck/check/check_footprint.py | 18 ++- moondeck/check/check_module.py | 2 + moondeck/check/check_nonblocking.py | 24 +--- src/light/drivers/NetworkSendDriver.h | 3 +- src/light/drivers/PanelCardDriver.h | 42 +++---- src/platform/desktop/platform_desktop.cpp | 12 +- src/platform/platform.h | 4 +- .../light/scenario_Driver_mutation.json | 4 +- .../light/scenario_GridBlacks_blackpixel.json | 4 +- test/scenarios/light/scenario_perf_light.json | 4 +- .../light/scenario_peripheral_grid_sweep.json | 4 +- test/unit/light/unit_PanelCardDriver.cpp | 34 ++++++ 19 files changed, 203 insertions(+), 98 deletions(-) create mode 100644 docs/assets/light/drivers/PanelCardDriver.png diff --git a/.claude/settings.local.cleaned.json b/.claude/settings.local.cleaned.json index 58fcf2d3..069fd140 100644 --- a/.claude/settings.local.cleaned.json +++ b/.claude/settings.local.cleaned.json @@ -1,40 +1,79 @@ { "permissions": { "allow": [ - "PowerShell(& \"C:\\\\Users\\\\ewoud\\\\.local\\\\bin\\\\uv.exe\" run moondeck/*)", - "PowerShell(& \"C:\\\\Users\\\\ewoud\\\\.local\\\\bin\\\\uv.exe\" run --with * python *)", - "PowerShell(& \"C:\\\\Users\\\\ewoud\\\\.local\\\\bin\\\\uv.exe\" run python *)", - "PowerShell(& \"C:\\\\Users\\\\ewoud\\\\.local\\\\bin\\\\uv.exe\" pip *)", - "PowerShell(& \"C:\\\\Program Files\\\\CMake\\\\bin\\\\cmake.exe\" *)", - "PowerShell($env:Path = \"C:\\\\Program Files\\\\CMake\\\\bin;\" + $env:Path; *)", - "PowerShell($env:Path = \"C:\\\\Program Files\\\\GitHub CLI;\" + $env:Path; *)", - "PowerShell(Get-PnpDevice*)", - "PowerShell(Get-Item *)", - "PowerShell(Get-ItemProperty *)", - "PowerShell(Get-ChildItem *)", - "PowerShell(Get-Command * -ErrorAction SilentlyContinue*)", - "PowerShell(Get-Content *)", - "PowerShell(Get-Process *)", - "PowerShell(Get-WmiObject *)", - "PowerShell(Test-Path *)", - "PowerShell(Write-Host *)", - "PowerShell(Start-Sleep *)", - "PowerShell(Start-Process *)", - "PowerShell(New-Item *)", - "PowerShell(Remove-Item *)", - "PowerShell(Rename-Item *)", - "PowerShell(& gh *)", - "PowerShell(& git *)", - "PowerShell(Invoke-RestMethod *)", - "PowerShell(winget install *)", - "PowerShell(winget --version)", - "PowerShell($venv * python.exe *)", - "Bash(grep *)", - "Bash(Test-Path *)", - "Bash(Start-Sleep *)", - "Bash(Write-Host *)", - "Read(//c/Users/ewoud/esp/esp-idf/tools/**)", - "WebFetch(domain:github.com)" + "Bash(./build/projectMM:*)", + "Bash(./build/test/mm_scenarios:*)", + "Bash(./build/test/mm_tests:*)", + "Bash(arp -a)", + "Bash(awk:*)", + "Bash(cat:*)", + "Bash(clang++:*)", + "Bash(clang:*)", + "Bash(cmake:*)", + "Bash(cp .claude/settings.local.json .claude/settings.local.cleaned.json)", + "Bash(ctest:*)", + "Bash(curl -s*github.com/MoonModules*)", + "Bash(curl -s*raw.githubusercontent.com*)", + "Bash(echo:*)", + "Bash(gh api *)", + "Bash(gh auth *)", + "Bash(gh pr *)", + "Bash(gh run *)", + "Bash(gh search *)", + "Bash(git check-ignore *)", + "Bash(git fetch *)", + "Bash(git ls-remote *)", + "Bash(git mv:*)", + "Bash(grep:*)", + "Bash(gunzip:*)", + "Bash(head:*)", + "Bash(ipconfig getsummary *)", + "Bash(ls:*)", + "Bash(lsof:*)", + "Bash(networksetup -listallhardwareports)", + "Bash(node --check:*)", + "Bash(node --input-type=module -e ' *)", + "Bash(node --test:*)", + "Bash(node -e ' *)", + "Bash(npm install *)", + "Bash(npm test *)", + "Bash(objcopy:*)", + "Bash(objdump:*)", + "Bash(otool:*)", + "Bash(pkill:*)", + "Bash(ps:*)", + "Bash(sed:*)", + "Bash(sort:*)", + "Bash(system_profiler SPUSBDataType)", + "Bash(tail:*)", + "Bash(timeout 5 dns-sd -B _http._tcp local.)", + "Bash(uv run *)", + "Edit", + "Read(//Users/ewoud/**)", + "Read(//Users/ewoud/esp/esp-idf/**)", + "Read(//dev/**)", + "WebFetch(domain:components.espressif.com)", + "WebFetch(domain:components101.com)", + "WebFetch(domain:developer.espressif.com)", + "WebFetch(domain:devices.esphome.io)", + "WebFetch(domain:docs.espressif.com)", + "WebFetch(domain:easyelecmodule.com)", + "WebFetch(domain:files.waveshare.com)", + "WebFetch(domain:github.com)", + "WebFetch(domain:kno.wled.ge)", + "WebFetch(domain:mm.kno.wled.ge)", + "WebFetch(domain:moonmodules.org)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:www.digikey.com)", + "WebFetch(domain:www.farnell.com)", + "WebFetch(domain:www.waveshare.com)", + "WebSearch", + "Write" + ], + "additionalDirectories": [ + "/tmp", + "/Users/ewoud/esp/esp-idf/components/esp_mm/include", + "/Users/ewoud/Developer/GitHub/MoonModules/projectMM/.claude" ] } } diff --git a/docs/assets/light/drivers/PanelCardDriver.png b/docs/assets/light/drivers/PanelCardDriver.png new file mode 100644 index 0000000000000000000000000000000000000000..2b7dec4becf486e6faed739f0605065dee21121f GIT binary patch literal 99147 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%0Ak<MCi)77JALc# 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 YMma(SxCSsUXn>dcb#0YgB{Tp30Y^;Cv;Y7A literal 0 HcmV?d00001 diff --git a/docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md b/docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md index 5bdd557d..c1b43156 100644 --- a/docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md +++ b/docs/history/plans/Plan-20260730 - Panel cards over raw Ethernet.md @@ -3,7 +3,7 @@ ## Context [Issue #58](https://github.com/MoonModules/projectMM/issues/58) asks to drive ColorLight receiver -cards from one of our boards. Today that rig is a Raspberry Pi running FPP. +cards from one of our boards, replacing the Linux host that drives them today. **The board renders and sends.** Effects, layers, MoonLive and the preset system are already on the device, so the primary case is a self-contained panel controller: our own render output goes diff --git a/docs/moonmodules/light/drivers.md b/docs/moonmodules/light/drivers.md index 94ac8417..4ee873c1 100644 --- a/docs/moonmodules/light/drivers.md +++ b/docs/moonmodules/light/drivers.md @@ -89,7 +89,9 @@ Detail: [technical](moxygen/NetworkSendDriver.md) <a id="panelcard"></a> -### Panel Send 💫 · raw Ethernet +### Panel Card 💫 · raw Ethernet + +<img src="../../assets/light/drivers/PanelCardDriver.png" width="300" alt="PanelCard controls"> Streams the buffer to **LED panel cards** as raw Ethernet frames, compatible with **ColorLight 5A-75** cards. In vendor terms (ColorLight, NovaStar, Linsn) these are *receiving cards*, and this driver takes the place of the *sending card* that normally feeds them. These take a sender-card feed rather than a pixel protocol, so the driver sends row-addressed data followed by a sync frame that latches the image. diff --git a/docs/performance.md b/docs/performance.md index 8fd26eee..5efd30e2 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -257,6 +257,34 @@ Each parallel LED driver run on real hardware at a 128×128 = 16384-light grid, The **acceptance floors** these establish for the parallel backends: RMT **8×256 = 2048** (verified above); the parallel-I2S (classic i80) driver **16×256 = 4096** (verified 2026-07-13); the virtual (shift-register) driver **48×256 = 12288** — each backend must clear its floor on real hardware. +## Panel cards over raw Ethernet (`PanelCardDriver`, ESP32-S31) + +Measured on an S31 driving two 128×64 HUB75 panels through a ColorLight 5A-75 receiver card, gigabit +RGMII link, 2026-07-30. + +| | µs/tick | note | +|---|---:|---| +| **PanelCardDriver** | **2 636** | 16 384 lights: correction + 130 frames handed to the MAC | +| PreviewDriver | 5 687 | the browser preview, same buffer | +| GameOfLifeEffect | 17 592 | the render, and the largest single cost | + +The wall runs at **~32 FPS**, which is `1 000 000 / (2 636 + 5 687 + 17 592 + overhead)` — the render +dominates, and the panel driver is the cheapest of the three active modules despite pushing 130 +packets per frame (2 brightness + 128 rows + 2 sync, at 497 pixels per row packet). + +**The ceiling is packets, not pixels.** Each frame is sent synchronously from `tick()`, so a taller +wall costs proportionally more rows; a 256-row wall would double the packet count. The card format's +1 Gbit requirement is a wire-time constraint rather than a bandwidth one — at 100 Mbit the same bytes +take ten times as long and overrun the inter-frame window the sync depends on +([drivers.md](moonmodules/light/drivers.md#panelcard)). + +**Static RAM: 0 B.** The driver's 1 512 B packet buffer is a class member, so it costs nothing on a +board that never adds the driver; `check_footprint --module PanelCardDriver --firmware esp32s31` +reports 3 270 B of flash and no static RAM. + +No scenario contract yet: the driver needs a receiver card on the wire, so the numbers above are a +bench record rather than an asserted ceiling. + ## Multicore: the whole output stage on core 1 (`multicore`, Step 2) The `multicore` control on the Drivers container runs **every driver's per-frame work** — the LED encode, the ArtNet packet build, the preview frame build — on a **core-1 task**, while the render loop draws the next frame on core 0. A frame costs `max(render, output)` instead of `render + output`. It stacks with the driver's `doubleBuffer` (which hides the WS2812 *wire* behind DMA on one core); this hides the *encode* behind the *render* on the other. diff --git a/esp32/main/CMakeLists.txt b/esp32/main/CMakeLists.txt index 6c977df6..8a76048c 100644 --- a/esp32/main/CMakeLists.txt +++ b/esp32/main/CMakeLists.txt @@ -107,7 +107,7 @@ if(MM_ETH_ONLY) target_compile_definitions(${COMPONENT_LIB} PRIVATE MM_NO_WIFI) endif() 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) diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 23d744f4..dfbbf9a3 100644 --- a/moondeck/MoonDeck.md +++ b/moondeck/MoonDeck.md @@ -587,7 +587,6 @@ Where a module's bytes land on the device: flash, RAM, or strings. 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 ``` **What the tool is.** No tool of its own — it reads the ESP32 **ELF** the build already produced, @@ -604,7 +603,7 @@ matters. | 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 | +| **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 the main table 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 | **Sorted STATIC-first**, then by size within each group: every file holding static RAM appears diff --git a/moondeck/check/check_footprint.py b/moondeck/check/check_footprint.py index 99e583d5..49fed964 100644 --- a/moondeck/check/check_footprint.py +++ b/moondeck/check/check_footprint.py @@ -145,6 +145,15 @@ def render(per, only, firmware): L = [f"Footprint on {firmware} — {len(rows)} source file(s), " f"{sum(r['total'] for r in rows)} B attributed."] if not rows: + # A module scoped out of THIS firmware is the expected zero, not a failed run — say which, + # because "nothing attributed" reads as a broken tool and a real zero is indistinguishable + # from a tool that read nothing. A driver gated on a per-firmware flag (MM_PANEL_CARDS) is + # simply absent from a variant that does not set it, which is the gate working. + if only: + return L + [f" Not linked into {firmware}: compiled out of this variant by a " + f"per-firmware gate, so it costs nothing here.", + f" Measure it on a variant that includes it " + f"(--firmware esp32s31 for the panel-card driver)."] return L + [" Nothing attributed. Build the firmware, or the ELF carries no debug info."] # What the columns MEAN lives in MoonDeck.md, not in every run's output: it is reference text @@ -324,15 +333,18 @@ def main(): 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") + print(f"Filtered to {args.module}: {', '.join(only)}") + # Resolved by FILENAME, so a module whose bytes partly live in a differently-named sibling + # (a *Packet.h wire format, say) reports only the files that share its name. Said out loud + # because a partial number that looks whole is worse than one that admits its scope. + print(" (files matching the module name; a differently-named sibling header is not " + "included)\n") print("\n".join(render(per, only, args.firmware))) # The string table rides along in the SAME run: both halves answer one question ("what does # this cost"), they read the same ELF, and the whole report is under a second. A separate mode # meant remembering to run it, which is how a report goes unread. - # The string half rides along in the SAME run: both answer "what does this cost", they read - # the same build, and a separate mode is one you forget to run. objdump = _tool("objdump", args.firmware) cxxfilt = _tool("c++filt", args.firmware) if objdump and cxxfilt: diff --git a/moondeck/check/check_module.py b/moondeck/check/check_module.py index b59f187e..40e0e4b4 100644 --- a/moondeck/check/check_module.py +++ b/moondeck/check/check_module.py @@ -51,7 +51,9 @@ TOOLS = [ ("clang-tidy", ["check_clang_tidy.py"], False), ("clang-query", ["check_clang_query.py"], False), + ("-Wfunction-effects", ["check_nonblocking.py"], False), ("lizard", ["check_lizard.py", "--all"], False), + ("CodeQL", ["check_codeql.py"], True), ("footprint", ["check_footprint.py"], True), ] diff --git a/moondeck/check/check_nonblocking.py b/moondeck/check/check_nonblocking.py index 825cdaf8..8387e40b 100644 --- a/moondeck/check/check_nonblocking.py +++ b/moondeck/check/check_nonblocking.py @@ -240,26 +240,8 @@ def _rate_on_line(rel_path, line): return bool(_RATE.search(src[line - 1]) or _LATCH.search(src[line - 1])) -def _matcher_rejected(out): - """True when clang-query refused the matcher rather than finding nothing. - - The two are indistinguishable by exit code — a rejected matcher still exits 0 with no matches. - - Matched on clang-query's OWN diagnostic shapes, anchored to the start of the line. A substring - search for `error: ` finds one in the dumped source too: `snprintf(…, "error: apply failed")` - is a string literal in a matched node, and treating that as a rejection blanked the whole COND - column for a matcher that had in fact produced 255 matches. - """ - # A rejection is reported as `<line>:<col>: <complaint>` against the QUERY text, before any - # matching happens, and never alongside a match. So: complaint present AND nothing matched. - rejected = ("Input value has unresolved overloaded type", - "Error parsing argument", - "Error building matcher", - "Matcher does not support binding", - "Invalid matcher") - if "Match #" in out: - return False # it ran and produced matches — not a rejection - return any(phrase in out for phrase in rejected) +# The matcher-rejection detector lives in check_clang_query: one home for the clang-query +# plumbing both reports share (this module already imports it for the TU list and paths). def guard_forms(rows, build_dir, tool): @@ -295,7 +277,7 @@ def run(form, matcher): # A matcher clang-query cannot resolve yields ZERO matches and exit 0 — indistinguishable # from "no site is guarded". Same detection as check_clang_query.main; without it a single # matcher-syntax drift silently blanks the column and every row reads as unconditional. - if _matcher_rejected(out): + if check_clang_query._matcher_rejected(out): print(f"clang-query rejected the {form} matcher; COND cannot be reported.", file=sys.stderr) return None diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index ac93466a..a9e65f93 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -358,7 +358,8 @@ class NetworkSendDriver : public DriverBase { nrOfLightsType lightsAt(uint8_t i) const { return destCounts_[i]; } private: - /// The send socket, opened once in setup() and reused for every destination. + /// The send socket: opened in prepare() (the sole resource gate), reused for every + /// destination, and closed in release() so a disabled driver holds no socket. platform::UdpSocket socket_; /// The shared frame this driver reads its window from; borrowed, not owned. Buffer* sourceBuffer_ = nullptr; diff --git a/src/light/drivers/PanelCardDriver.h b/src/light/drivers/PanelCardDriver.h index d063af59..ffbfd286 100644 --- a/src/light/drivers/PanelCardDriver.h +++ b/src/light/drivers/PanelCardDriver.h @@ -35,6 +35,7 @@ namespace mm { /// The deep dives are under *More info*, below the attribute/method lists: /// @xref{why-a-gigabit-link|why these cards need a gigabit link}, /// @xref{running-this-on-a-host|running this on a desktop or a Pi}. +/// @card PanelCardDriver.png /// /// @moreinfo /// @@ -130,10 +131,12 @@ class PanelCardDriver : public DriverBase { // 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)) { + // A host needs a named NIC to reach the wire; ESP32 accepts and ignores it. Every prepare + // re-syncs the binding, INCLUDING the blank case — clearing the field must return the host + // to capture-only rather than leaving the last interface bound until release(). A bind + // failure is a Warning rather than an Error: the driver still runs and still records frames, + // which is what a test or a dry run wants — it just is not driving panels. + if (!platform::ethBindRawInterface(interface[0] ? interface : nullptr)) { setStatus("cannot open interface (needs root?)", Severity::Warning); return; } @@ -141,12 +144,8 @@ class PanelCardDriver : public DriverBase { writeLinkStatus(); } - /// Re-read the link every second. A cable is plugged in, unplugged, or renegotiates long after - /// prepare() ran, so a status written once at build time goes stale and reports "no ethernet - /// link" on a link that is up — which is exactly what a user is looking at the card to find out. - /// Runs once a second on the housekeeping path, not the render path, so the status snprintf - /// here costs nothing that matters (the same trade SystemModule/NetworkModule make in their - /// own tick1s — it is flagged by -Wfunction-effects and accepted for that reason). + /// Refresh the link status once a second: a cable plugged in after prepare() ran would + /// otherwise leave the card reporting "no ethernet link" on a link that is up. void tick1s() MM_NONBLOCKING override { writeLinkStatus(); MoonModule::tick1s(); @@ -203,6 +202,10 @@ class PanelCardDriver : public DriverBase { stride = srcCh; } + // Nothing to send if the buffer covers no row at all — bail before putting the brightness + // pair on the wire, so a misconfigured window is silent rather than emitting frames per tick. + if (nLights < static_cast<nrOfLightsType>(wallW)) return; + // Brightness first, ahead of the rows — the order the cards expect. Advisory: older card // firmware ignores it, and the driver never depends on it having landed (our own Correction // has already applied brightness to the pixel data, so this only sets the card's own gain). @@ -234,10 +237,12 @@ class PanelCardDriver : public DriverBase { static_cast<uint16_t>(n), data + first * stride, stride); // A failed frame is dropped, not retried: the cards have no acknowledgement to wait // for, and stalling the render tick to retry would cost the next frame too. - if (platform::ethSendRaw(packet_, len)) framesSent_++; + // Set on a frame that actually reached the wire, not on reaching the row: a link + // that drops mid-frame fails every send, and latching then would blank the panels + // — which is the case the sync guard below exists to prevent. + if (platform::ethSendRaw(packet_, len)) { framesSent_++; anyRowSent = true; } else framesDroppedTotal_++; } - anyRowSent = true; } // The sync frame latches everything above, so it goes LAST and only if something was sent — @@ -250,19 +255,15 @@ class PanelCardDriver : public DriverBase { } } - /// Report what the wire is actually doing: no link, a link too slow for the cards, or the - /// frames-per-second reaching it. The three cases are genuinely different and a user debugging - /// a dark panel needs to tell them apart. Sends regardless — see the class note on why a slow - /// link is reported rather than refused. + /// Report what the wire is doing: no link, a link too slow for the cards, or the packet rate + /// reaching it. A user debugging a dark panel needs to tell those apart. Sends regardless — the + /// class note says why a slow link is reported rather than refused. Runs on the 1 Hz path, so + /// the snprintf here is the same accepted trade SystemModule makes. void writeLinkStatus() MM_NONBLOCKING { if (!platform::ethLinkUp()) { setStatus("no ethernet link", Severity::Warning); return; } - // A wedged transmit path reads as a perfectly healthy link: esp_eth_transmit refuses every - // frame while the IDF driver's own link flag is down, and that flag can outlive the PHY event - // that set it. Reporting the failure streak is what tells "0 packets/s on a 1 Gbit link" - // apart from "the effect is idle" — the two looked identical before. // A short streak is ordinary back-pressure: the DMA ring fills while we push a whole frame // in one tick, the frame is dropped, the next one goes. Measured on a 128x128 wall at ~1900 // packets/s, streaks of 1-4 come and go and always clear themselves — reporting those as an @@ -359,6 +360,7 @@ class PanelCardDriver : public DriverBase { /// Frames the MAC accepted since boot, and the value at the last status write — their /// difference is the per-second rate the card shows. uint32_t framesSent_ = 0; + /// framesSent_ at the last status write — subtracting gives the rate without a timer. uint32_t framesReported_ = 0; /// Frames the MAC refused since boot. Cumulative on purpose: the per-second rate hides a slow /// trickle of drops, and a rising total is the signal that the sender is outrunning the wire. diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 8663f464..080869c9 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -786,11 +786,16 @@ bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING { 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 ssize_t n = ::sendto(ethRawFd_, frame, len, 0, + reinterpret_cast<sockaddr*>(&dst), sizeof(dst)); #else - return ::write(ethRawFd_, frame, len) == static_cast<ssize_t>(len); + const ssize_t n = ::write(ethRawFd_, frame, len); #endif + // Track failures on the REAL send path too, not just the capture path: a bound host is + // where frames actually reach a wire, so a streak here is the one that matters. + if (n != static_cast<ssize_t>(len)) { ethSendFails_++; return false; } + ethSendFails_ = 0; + return true; } #endif @@ -814,7 +819,6 @@ bool ethSendRaw(const uint8_t* frame, size_t len) MM_NONBLOCKING { uint32_t ethSendFailStreak() MM_NONBLOCKING { return ethSendFails_; } - // See platform.h: a claim stated by the driver, reference-counted. void ethClaimRawL2(bool claim) { if (claim) ethRawClaims_++; diff --git a/src/platform/platform.h b/src/platform/platform.h index 38e83eff..acf365ed 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -394,8 +394,8 @@ uint16_t ethLinkSpeedMbps() MM_NONBLOCKING; // Bind raw sending to a host network interface by name ("eth0", "en0"). ESP32 ignores this — it has // one MAC and ethSendRaw always uses it. On desktop it opens the raw socket (Linux AF_PACKET, macOS -// BPF) that makes a host a real panel controller: the same driver on a Pi or a mini-PC does what an -// FPP host does, which is worth having both as a product and as the way to test the wire format +// BPF) that makes a host a real panel controller: the same driver on a Pi or a mini-PC drives the +// same cards, which is worth having both as a product and as the way to test the wire format // without an ESP32 in the loop. // // Returns false when the interface is unknown or the process lacks the privilege (raw L2 is diff --git a/test/scenarios/light/scenario_Driver_mutation.json b/test/scenarios/light/scenario_Driver_mutation.json index b1d898f6..582907c4 100644 --- a/test/scenarios/light/scenario_Driver_mutation.json +++ b/test/scenarios/light/scenario_Driver_mutation.json @@ -170,7 +170,7 @@ "desktop-macos": { "tick_us": [ 8, - 98 + 101 ], "free_heap": [ 0, @@ -182,7 +182,7 @@ ], "at": [ "2026-06-13", - "2026-07-01" + "2026-07-30" ] }, "desktop-windows": { diff --git a/test/scenarios/light/scenario_GridBlacks_blackpixel.json b/test/scenarios/light/scenario_GridBlacks_blackpixel.json index c8eea976..3880e29f 100644 --- a/test/scenarios/light/scenario_GridBlacks_blackpixel.json +++ b/test/scenarios/light/scenario_GridBlacks_blackpixel.json @@ -91,7 +91,7 @@ "desktop-macos": { "tick_us": [ 1, - 5 + 7 ], "free_heap": [ 0, @@ -103,7 +103,7 @@ ], "at": [ "2026-07-22", - "2026-07-28" + "2026-07-30" ] }, "esp32s3-n16r8": { diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json index 9a30396f..76984746 100644 --- a/test/scenarios/light/scenario_perf_light.json +++ b/test/scenarios/light/scenario_perf_light.json @@ -306,7 +306,7 @@ "desktop-macos": { "tick_us": [ 0, - 7 + 8 ], "free_heap": [ 0, @@ -318,7 +318,7 @@ ], "at": [ "2026-06-17", - "2026-07-09" + "2026-07-31" ] }, "esp32s3-n16r8": { diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index 6cf1a220..ec1be1eb 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -312,7 +312,7 @@ "desktop-macos": { "tick_us": [ 68, - 232 + 245 ], "free_heap": [ 0, @@ -324,7 +324,7 @@ ], "at": [ "2026-07-26", - "2026-07-28" + "2026-07-30" ] } } diff --git a/test/unit/light/unit_PanelCardDriver.cpp b/test/unit/light/unit_PanelCardDriver.cpp index 59014381..96c8781f 100644 --- a/test/unit/light/unit_PanelCardDriver.cpp +++ b/test/unit/light/unit_PanelCardDriver.cpp @@ -359,3 +359,37 @@ TEST_CASE("PanelCardDriver reports a failing transmit path instead of a healthy driver.tick(); CHECK(mm::platform::ethSendFailStreak() == 0); // a success clears it: back-pressure is not a fault } + +// A link that fails every send must not latch: the sync frame tells the cards to show what they +// have, so emitting one after a frame where nothing arrived would blank a wall that was previously +// showing a good image. +TEST_CASE("PanelCardDriver does not latch a frame that never reached the wire") { + clearClaims(); + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 1); + setUp(driver, source, wall, 64); + + mm::platform::setTestEthSendFails(true); + mm::platform::setTestNowMs(1000); + driver.tick(); + mm::platform::setTestEthSendFails(false); + + // Nothing was recorded at all — in particular no sync frame slipped through after the failures. + CHECK(mm::platform::ethTestFrameCount() == 0); +} + +// A window covering no whole row sends nothing, rather than putting the brightness pair on the wire +// every tick for a wall it cannot fill. +TEST_CASE("PanelCardDriver sends nothing when the buffer covers no row") { + clearClaims(); + mm::Buffer source; + mm::PanelCardDriver driver; + Wall wall(64, 4); // wall rows are 64 wide + setUp(driver, source, wall, 10); // buffer holds 10 lights: less than one row + + mm::platform::setTestNowMs(1000); + driver.tick(); + + CHECK(mm::platform::ethTestFrameCount() == 0); +}