diff --git a/.clang-tidy b/.clang-tidy new file mode 100644 index 00000000..d8e0b50c --- /dev/null +++ b/.clang-tidy @@ -0,0 +1,203 @@ +# clang-tidy configuration. +# +# WHY THIS SITS IN THE REPO ROOT and not under docs/ with the other tooling state: clangd finds +# it ONLY by walking up the directory tree from each source file, and has no flag to point +# elsewhere (clang-tidy's --config-file would work, clangd's equivalent does not exist). Moving +# it would silently kill editor diagnostics while CI stayed green — the worst possible split. +# It must live at or above src/. +# +# Shape: `*` minus an explicit disable list — the archetype ESPHome and ClickHouse use. The +# alternative (a short opt-in list, as LLVM and Chromium do) suits codebases too large to +# ever get clean; at ~50k LOC we can reach zero findings, and `*`-minus catches new check +# families as LLVM adds them instead of silently ignoring them. +# +# The list is derived from ESPHome's (a large ESP32-targeted C++ codebase, our closest peer — +# theirs is `*` minus 175 checks with WarningsAsErrors: ''), then tuned against this tree. +# Tuning mattered: `*` with only their FAMILY disables gave 6,073 findings here; adding the +# style and cert disables brought it to 131 real ones. +# +# WHY THE REASONS LIVE HERE AND NOT INLINE: a `#` inside the `>-` folded block below is NOT a +# YAML comment — it is folded into the check string as literal text and silently corrupts +# every disable after it. (Learned the hard way: a commented version of this file left +# `abseil-*` enabled and produced 12,181 findings.) So the block stays pure, and every +# disable is justified in the table below. A check removed without a stated reason will be +# re-litigated by the next reader; recording rejected checks is the Chromium pattern. +# +# OTHER PLATFORMS' RULE SETS — not our target, pure noise: +# abseil, altera, android, boost, fuchsia, hicpp, llvmlibc, mpi, objc, zircon +# +# CPPCOREGUIDELINES (whole family) — mostly aliases of checks we already run, plus bounds +# and cast rules that hardware register access and DMA buffer work must violate by nature. +# ClickHouse disables it as "impractical... also slow"; ESPHome disables ~25 individually. +# +# CERT — almost entirely aliases of bugprone-*, so enabling both double-reports. +# cert-err33-c alone produced 3,678 findings here: it fires on every snprintf whose return +# value we ignore deliberately. +# +# LOUD WITH NO ACTIONABLE FIX ON THIS CODEBASE: +# bugprone-easily-swappable-parameters — flags every (int x, int y); in a geometry/LED +# codebase that is most of them, and the "fix" is strong types for every coordinate pair, +# an architecture decision rather than a lint fix. Disabled by SerenityOS, ClickHouse, +# ESPHome, Godot and the Zephyr template. +# bugprone-narrowing-conversions — fires on every uint8_t pixel-math expression. +# performance-enum-size — 3 bytes per enum is real on a 180 KB-heap device, but the fix +# touches every enum for a gain the linker mostly recovers. ESPHome disables it despite +# shipping on the same class of hardware. +# bugprone-implicit-widening-of-multiplication-result — MEASURED 47 findings, 0 reachable. +# 27 are compile-time constants (`256 * 1024`) the compiler folds. The other 20 are +# buffer index arithmetic on nrOfLightsType, which is already uint32_t where it matters +# (light_types.h). Overflow needs 32 bits of product; the largest real installation, the +# 48x256 wall, needs 17 — a margin of ~87,000x. "Fixing" it means casting every index in +# the hot path for a hazard that cannot occur. +# bugprone-signed-char-misuse — MEASURED 12/12 FALSE, and its suggested fix is a BUG. Every hit +# is an int8_t GPIO pin widening to int/int16_t/uint16_t, where -1 is the "unset pin" sentinel +# (platform.h EthPinConfig, the addPin controls). Sign extension is precisely what we want; +# "cast to unsigned char first" would turn -1 into 255 and silently claim GPIO 255. The check +# is aimed at char-typed *text* (where a negative byte indexes out of a table), not at a +# signed-integer-with-sentinel convention. +# performance-no-int-to-ptr — MEASURED 9/9 the SAME deliberate idiom, none a hardware address: +# ControlDescriptor::aux (Control.h) is a `uintptr_t` tagged field holding either a count or an +# options-array pointer. Storing a pointer in an integer field is the point; the check's premise +# (an int that happens to be cast to a pointer defeats alias analysis) does not apply to a +# round-tripped pointer. Splitting aux into a union to satisfy it grows the descriptor on a +# 180 KB-heap device for no correctness gain. +# bugprone-throwing-static-initialization — MEASURED 7/8 provably cannot throw: the peripheral +# registrations and the MoonLive builtin tables write into fixed-size static arrays with no heap +# and no std::string/vector, and Palette::fromBuiltin is plain array math. The 8th is a desktop +# std::filesystem::path built from a literal — theoretically bad_alloc, never reachable, and +# desktop-only. A check that is wrong 7 times out of 8 costs more attention than it earns. +# bugprone-infinite-loop — MEASURED 28/28 FALSE on this tree. It cannot track uint8_t loop +# counters, and `for (uint8_t i = ...; i > 0; i--)` is a house convention +# (coding-standards § Prefer integers); it also misfires on `while (b)` in a textbook +# Euclid gcd. The upstream FPs are a known LLVM issue with fixes still landing, so this +# is worth re-testing on a future LLVM — but a check that is wrong every single time +# teaches us to ignore its whole family, which costs more than it catches. +# +# CONFLICTS WITH DELIBERATE ARCHITECTURE: +# misc-no-recursion — the JSON reader is deliberately recursive (Minimalism means elegance). +# misc-non-private-member-variables-in-classes — the module tree uses plain member data by +# design, so the UI can render any module generically. +# misc-multiple-inheritance — both hits are `MoonModule + ListSource`, where ListSource is a +# pure interface (no data, virtual dtor) and MoonModule is the one stateful base. That is the +# textbook data-source/adapter shape CLAUDE.md sanctions by name (UITableView's data source, +# Qt's QAbstractItemModel); the check cannot tell it from real multiple state inheritance +# because ListSource gives one convenience override a default body. +# misc-use-anonymous-namespace / misc-use-internal-linkage / +# llvm-prefer-static-over-anonymous-namespace — fight header-only light modules. +# portability-avoid-pragma-once / llvm-header-guard — `#pragma once` is our convention. +# +# TOO SLOW, OR FILTERED BY CLANGD ANYWAY: +# misc-const-correctness (>10x AST cost), misc-include-cleaner ("very very noisy"), +# misc-header-include-cycle. +# +# OWNED BY ANOTHER TOOL (one rule, one owner): +# readability-function-cognitive-complexity / *-function-size — complexity is lizard's job; +# it produces the per-commit number repo-health.json trends. +# bugprone-branch-clone — fires on deliberate parallel switch arms. +# clang-analyzer-security.ArrayBound — analyses the macOS SDK's headers, not our code. +# +# Everything else disabled below is a style opinion we do not hold; enabling any of them +# would gate a rewrite rather than a fix. +# +# NOT GATED YET. `WarningsAsErrors` stays empty until the remaining 47 clang-analyzer findings +# are triaged (backlog: "clang-tidy: triage the 47 clang-analyzer findings"). Switching it to +# `'*'` is the ratchet that stops a zero decaying, and it is one line — but it fails the gate +# today, so it lands with that triage rather than before it. +--- +Checks: >- + *, + -abseil-*, + -altera-*, + -android-*, + -boost-*, + -fuchsia-*, + -hicpp-*, + -llvmlibc-*, + -mpi-*, + -objc-*, + -zircon-*, + -cppcoreguidelines-*, + -cert-err33-c, + -cert-err58-cpp, + -cert-dcl50-cpp, + -cert-str34-c, + -cert-int09-c, + -cert-oop57-cpp, + -bugprone-easily-swappable-parameters, + -bugprone-narrowing-conversions, + -bugprone-branch-clone, + -bugprone-infinite-loop, + -bugprone-implicit-widening-of-multiplication-result, + -bugprone-signed-char-misuse, + -bugprone-throwing-static-initialization, + -performance-no-int-to-ptr, + -performance-enum-size, + -misc-no-recursion, + -misc-non-private-member-variables-in-classes, + -misc-multiple-inheritance, + -misc-use-anonymous-namespace, + -misc-use-internal-linkage, + -misc-const-correctness, + -misc-include-cleaner, + -misc-header-include-cycle, + -llvm-prefer-static-over-anonymous-namespace, + -llvm-header-guard, + -llvm-include-order, + -llvm-namespace-comment, + -llvm-else-after-return, + -llvm-qualified-auto, + -llvm-use-ranges, + -portability-avoid-pragma-once, + -modernize-use-trailing-return-type, + -modernize-avoid-c-arrays, + -modernize-avoid-c-style-cast, + -modernize-avoid-variadic-functions, + -modernize-use-nodiscard, + -modernize-use-auto, + -modernize-use-nullptr, + -modernize-use-designated-initializers, + -modernize-use-integer-sign-comparison, + -modernize-use-equals-default, + -modernize-use-default-member-init, + -modernize-type-traits, + -modernize-raw-string-literal, + -modernize-return-braced-init-list, + -modernize-loop-convert, + -modernize-use-ranges, + -readability-magic-numbers, + -readability-identifier-length, + -readability-implicit-bool-conversion, + -readability-uppercase-literal-suffix, + -readability-braces-around-statements, + -readability-inconsistent-ifelse-braces, + -readability-else-after-return, + -readability-isolate-declaration, + -readability-math-missing-parentheses, + -readability-avoid-nested-conditional-operator, + -readability-named-parameter, + -readability-qualified-auto, + -readability-redundant-inline-specifier, + -readability-redundant-parentheses, + -readability-redundant-casting, + -readability-enum-initial-value, + -readability-convert-member-functions-to-static, + -readability-make-member-function-const, + -readability-static-accessed-through-instance, + -readability-use-std-min-max, + -readability-use-concise-preprocessor-directives, + -readability-container-contains, + -readability-simplify-boolean-expr, + -readability-function-cognitive-complexity, + -readability-function-size, + -google-explicit-constructor, + -google-readability-casting, + -google-readability-namespace-comments, + -google-readability-todo, + -google-readability-braces-around-statements, + -google-readability-function-size, + -google-build-using-namespace, + -google-runtime-int, + -clang-analyzer-security.ArrayBound +WarningsAsErrors: '' +HeaderFilterRegex: 'src/(core|light)/' +FormatStyle: none diff --git a/.clangd b/.clangd new file mode 100644 index 00000000..4d676c94 --- /dev/null +++ b/.clangd @@ -0,0 +1,68 @@ +# clangd configuration — live diagnostics in the editor. +# +# Repo root is not a choice: clangd reads project config from a `.clangd` file in the project +# directory, with no override flag. Same constraint as `.clang-tidy` next to it — both are +# discovered by convention, so neither can move under docs/ with the rest of the tooling state. +# +# clangd is the language server behind VS Code's clangd extension (and most other editors). +# It runs the SAME .clang-tidy config CI will use, so a finding appears while you type rather +# than in a pipeline ten minutes later. Nothing here gates anything: this file only affects +# what your editor shows you. +# +# Setup (once): install the `llvm-vs-code-extensions.vscode-clangd` extension and DISABLE +# Microsoft's C/C++ IntelliSense — running both produces duplicated and contradictory +# diagnostics. Everything else is automatic; `cmake --build` refreshes the database. +# +# NOTE clangd deliberately skips clang-tidy checks it considers slow (>10% AST-build cost, +# e.g. misc-const-correctness), so the editor runs a subset of the CI set. That is why the +# same config file is safe to share between the two: CI stays the authority. + +CompileFlags: + # Where to find compile_commands.json — the real include paths and flags for each file. + # Without it clangd guesses, and reports phantom "file not found" errors on every header. + # build/macos is this host's dir (the build script writes build/); + # clangd falls back to searching parent dirs if this one is absent, so a Linux or Windows + # checkout still works after its own build. + CompilationDatabase: build/macos + + # THE ONE SETTING THAT MAKES OR BREAKS THIS. The database records whichever compiler CMake + # picked (/usr/bin/c++, Apple Clang, on a stock Mac), but clangd is usually a DIFFERENT + # clang — Homebrew LLVM, or the one bundled with the extension. A different clang does not + # know Apple Clang's standard-library paths, so EVERY file reports `'cstdint' file not + # found` and all real diagnostics vanish behind the noise. + # + # --query-driver tells clangd to ask the recorded compiler where its headers live. The + # globs cover the system compilers and any Homebrew/Xcode toolchain; harmless if a path + # does not exist. (Same class of bug as the CI trap recorded in the plan: analysing with a + # toolchain that did not produce the database.) + # NOTE --query-driver is a clangd COMMAND-LINE flag, not a compile flag — putting it in + # `Add:` passes it to the compiler, which rejects it ("unknown argument") and breaks every + # file. Set it in the editor instead: VS Code → clangd extension → "Clangd: Arguments" → + # add `--query-driver=/usr/bin/c++`. Left here as a comment so the next reader does not + # re-add it to Add:. + Add: + # macOS: clangd is usually Homebrew LLVM while CMake recorded Apple Clang, and a different + # clang cannot find Apple's SDK headers — every file then reports `'cstdint' file not + # found` and real diagnostics vanish. + # + # MACHINE-SPECIFIC, and this file cannot avoid that: `.clangd` has no conditionals and no + # command substitution, so the path is literal and applies on EVERY platform, not only + # macOS. It is correct for Command Line Tools. Change it if `xcrun --show-sdk-path` + # disagrees (full Xcode puts the SDK under /Applications/Xcode.app/...), and delete the + # line on Linux/Windows, where the system clang already knows its own include paths. + # (check_clang_tidy.py has no such limit — it shells out to `xcrun` and gets this right + # automatically. Only the editor needs a hardcoded value.) + - -isysroot/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk + +Diagnostics: + # The .clang-tidy file at the repo root is picked up automatically; nothing to repeat here. + # This block is for editor-only adjustments that should NOT apply to CI. + ClangTidy: + # Editor-only removals go here. (Anything in .clang-tidy is already applied; repeating a + # name clangd does not know produces a "check was not found" warning on this file.) + Remove: [] + + # Suppress diagnostics from headers we do not own, so opening one of our files does not + # fill the Problems pane with ESP-IDF or standard-library noise. + Suppress: + - unused-includes diff --git a/.claude/workflows/write-behaviour-tests.js b/.claude/workflows/write-behaviour-tests.js index f58dd872..c4bc8bfc 100644 --- a/.claude/workflows/write-behaviour-tests.js +++ b/.claude/workflows/write-behaviour-tests.js @@ -69,7 +69,7 @@ Repo: the current workspace root (the projectMM checkout you're running in) — - First line MUST be: \`// @module ${cls}\` (this is what the doc generator + MoonDeck read — the whole point is that this module becomes a documented, tested module). Add \`// @also X, Y\` only if the test genuinely also exercises another module. - Each TEST_CASE gets a single \`//\` comment line ABOVE it describing the behaviour it pins (the generator turns that into the doc description). Write real, present-tense descriptions. - Assert REAL BEHAVIOUR, not just "renders non-zero". Examples of the bar: - - SolidEffect → the whole buffer is ONE uniform colour (every light equals the configured colour). + - SolidEffect → the whole buffer is ONE uniform color (every light equals the configured color). - FixedRectangleEffect → only lights inside the configured rect are lit; outside is black; defaults (0,0,0)+(15,15,15) light the origin corner. - MirrorModifier → a coord and its mirror map to the same logical position; modifyLogicalSize halves the mirrored axis (study the .h for which axis/percentage). - TransposeModifier → swaps axes (x↔y etc. per the .h); modifyLogicalSize swaps the corresponding size fields. diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000..ece55a4c --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,77 @@ +name: CodeQL + +# POC (docs/history/plans/Plan-20260727): does CodeQL's stock C/C++ suite find anything +# real in this firmware? The question that motivated it: we parse six network packet +# formats (ArtNet, DDP, E1.31, WLED audio sync, MQTT, WLED) plus HTTP, doing ~22 memcpy +# operations on data arriving from the LAN, on a device with no MMU and no process +# isolation — and we run NO security analysis at all today. Nothing else in our toolchain +# looks for that class of bug: the compiler checks effects, lizard counts branches, our +# Python checks read text. +# +# `build-mode: none` (GA Oct 2025) scans C/C++ WITHOUT building it, inferring compile flags +# per file. That matters here because our real firmware build is an ESP-IDF cross-compile +# for Xtensa/RISC-V that a runner would have to reproduce; build-free skips that entirely. +# The trade is some extraction noise on macro/template-dense headers — acceptable for a POC +# whose question is "is there anything here", not "prove there is nothing". +# +# Free on public repos, so the cost is wall-clock only. Scheduled weekly + on demand rather +# than per-push: this is a sweep, not a gate, until the POC says otherwise. +# +# If this finds nothing actionable after a few runs, that is a RESULT — record it in the +# scorecard and delete this file. + +on: + # Push-triggered on the POC branch, deliberately. + # + # `workflow_dispatch` alone would NOT work here: GitHub only offers the "Run workflow" + # button for workflows that exist on the DEFAULT branch, and this one lives on + # next-iteration until the POC is judged. A push trigger is the only way to get a first + # result without merging an unevaluated workflow into main. + # + # This is still not a gate: it does not run on pull_request, so it cannot block a merge. + # It runs, we read the findings, and we decide. + push: + branches: + - 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. + - 'src/**' + - '.github/workflows/codeql.yml' + workflow_dispatch: + schedule: + # Monday 04:00 UTC. Weekly is enough for a codebase this size, and keeps the signal + # from becoming background noise nobody reads. + - cron: '0 4 * * 1' + +permissions: + contents: read + # Required to upload results to the Security tab (this is what buys the alert lifecycle: + # open/fixed/dismissed tracked across runs, i.e. baselining we would otherwise build). + security-events: write + +jobs: + analyze: + name: Analyze C/C++ + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + 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 + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:c-cpp" diff --git a/CLAUDE.md b/CLAUDE.md index 70ebf6d7..d332d00a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,271 +2,125 @@ ## What This Is -A high-performance multi-platform system that drives large LED installations and DMX lighting fixtures. ESP32 is the primary target. Also runs on Teensy, macOS, Windows, Linux, and Raspberry Pi. C++20. CMake. - -See `docs/architecture.md` for system design. This file contains only rules and constraints for working on the project. +A high-performance multi-platform system driving large LED installations and DMX fixtures. ESP32 is the primary target; also Teensy, macOS, Windows, Linux, RPi. System design: [docs/architecture.md](docs/architecture.md); coding conventions: [docs/coding-standards.md](docs/coding-standards.md). This file holds only the rules. ## Principles -- **Common patterns first.** This repo is meant to be a recognisable example of good practice across code, docs, tests, and UI, not a Frankenstein of bespoke conventions only the authors understand. Hold every decision against it, especially in core architecture and documentation. Before introducing a pattern, name a widely-used project / framework / canonical resource that uses it; if you can't, treat it as bespoke and justify the divergence in a one-line comment at the introduction site. A new contributor with general C++/web experience should recognise the pattern within 30 seconds. Bespoke choices are allowed (header-only light modules, the MoonModule lifecycle, present-tense docs), but each carries its reason at the place it's introduced. -- **Industry standards, our own code.** Reach for the established, recognisable solution: the textbook *algorithm* (a DC-blocker high-pass, a Hann window, RMS, an integer-square-root) AND the textbook *name* for every variable, function, and UI control. That's *Common patterns first* applied to both domains, core and light: take the textbook approach over a clever or borrowed one. Study the prior art hard, whatever sharpens our thinking: repos, datasheets, vendor sites. Respect it, learn from it, credit it by name in the `history/` digests and per-module "Prior art" sections. Then write every line fresh against our own architecture: **carry the ideas forward, but write our own code rather than copying theirs or tracing their structure.** The method that guarantees it: spec from primary sources (ESP32 / peripheral / sensor datasheets, Espressif docs, reference standards), pin the behaviour as tests (unit + scenario), and let the worker-bee agents implement against the process ([CLAUDE.md](CLAUDE.md)) and architecture ([architecture.md](docs/architecture.md)). The result is independent by construction, not a renamed copy. -- **Minimalism means elegance, not fewest features.** Minimalism here is *consistency, reusability, no duplication, minimal memory, and the fastest hot path* — not "the smallest feature set" or "the fewest lines." A recognizable, proven construct (recursion, a textbook algorithm, a clean data structure) that ticks those boxes is *more* minimal than a hand-rolled special case, even when it's more code, because it's reusable and consistent rather than a one-off. So **reach for the established, beautiful solution**, and when you *know* a complex system will need a capability, build the cleanest *complete* version of it rather than a crippled subset that forces hacks elsewhere later (a JSON reader that can't read arrays is not "minimal" — it pushes ugliness outward). Guardrail: the construct must be *recognizable* (see *Common patterns first*), not bespoke cleverness — a contributor with general experience should recognise *what* it is within 30 seconds, even if the full *why* takes longer. Beauty and consistency win over raw line count. -- **Complexity lives in core; domain modules stay simple.** When something is genuinely hard — a recursive parser, a bounded arena, a scheduling or mapping algorithm — it belongs in the **core**, written once as the elegant standard construct (see *Minimalism means elegance*), so every **domain module** (a light effect/driver/layout/modifier, DevicesModule, …) gets to stay short and obvious by leaning on it. A domain module that is accreting parsing, buffer juggling, or clever bookkeeping is a smell: that complexity wants to move down into a core primitive the module then calls in a line or two. The test: a domain module should read like *what it does*, not *how the hard parts work* — the "how" is core's job. (Example: the device-list persistence — the recursive JSON reader is core; DevicesModule's restore is "parse, fill my array," a dozen plain lines.) This is the same coin as *Core grows slower than the domain*: core earns its size by absorbing complexity that would otherwise be duplicated, badly, across many simple modules. - - **When core already owns a mechanism for one path, extend it to the sibling path — don't re-implement it per module.** The sharpest form of this smell: the same *cross-cutting rule* (a lifecycle gate, a timing wrapper, a persistence hook, an enabled-check) is already centralized in core for one call path, and a new call path arrives that needs the identical rule. The reflex must be *lift the rule into core for the new path too*, not *paste the check into every module the new path touches*. If you find yourself writing the same `if (something-core-already-knows)` guard into method after method across many modules, stop: core almost certainly already applies that guard somewhere, and the honest fix is to make core apply it here as well. A guard that is correct in isolation but repeated per-module is still the *Complexity lives in core* violation — the module now has to *remember* a rule that isn't its job to know. (Canonical example in this repo: the Scheduler gates `tick()` by `!respectsEnabled() || enabled()` in exactly one place, so a driver's `tick()` never checks `enabled()`. The resource lifecycle mirrors it: `MoonModule::applyState()` is the single router — it calls `prepare()` (pure build) on an `effectivelyEnabled()` node and `release()` otherwise, recursing the tree — so a driver's `prepare()` never checks `enabled()` either; a disabled module, or one under a disabled parent, releases through the one path. The anti-pattern this clause names is what that replaced: pasting `if (enabled())` into every module's `prepare()`/`setup()`. The fix was to lift the decision into the `applyState` router, not duplicate it per module.) The *timing* corollary lives in *Continuous refactor, no hacks*: when the same fix is genuinely out of scope for the change that's open (e.g. it's a core-orchestrator contract change needing its own `/plan`), the per-module version may ship as the *tested, behaviour-pinned* interim — but it is **backlogged with the core fix named**, never left as the destination. The interim's tests must assert *behaviour*, not the per-module mechanism, so they survive the move to core unchanged. -- **Core grows slower than the domain.** Adding a domain module is expected growth: a new unit of domain capability adds lines because it adds a feature, and that's fine. The **core** (domain-neutral infrastructure: `src/core/`, the platform layer, the docs that describe them) is held to a higher bar: each core change buys proportionally more than a domain addition — new infrastructure that many modules use, not a one-off. A core change is suspect until that leverage is shown. Core is the lean base under a wider domain, not the other way around. The bar is a *kind* test, not a line-count race: the domain is effectively unbounded (each light effect/driver/device model is a self-contained feature), whereas **core earns growth only by adding a recognizable, industry-standard, reusable primitive** (a streaming write, a positional read, a bounded arena, a recursive JSON reader) — never a bespoke one-off. Such a primitive is welcome *because it is the textbook construct many modules lean on*, not because it is small; a core change that only one caller needs is the smell this rule catches. (This is *Common patterns first* and *Industry standards, our own code* applied to the core/domain growth ratio.) -- **Default to subtraction.** The reflex on most changes (a bug fix, a review finding, a refactor) should be *can this remove or replace code, or land net-neutral?*, not *what do I add?* If a change only ever grows the line count and the doc count, that's the smell this rule exists to catch. Prefer removing code over adding it; a deletion that preserves behaviour is the best kind of change. -- **Continuous refactor, no hacks.** Improvement is not a scheduled phase; it happens *the moment* a hack, a divergence, or a duplicated pattern is spotted, in whatever change is already open. The bar is absolute: **never** leave a hack, a workaround, or a bespoke one-off in place because "it works for now" — the fix is the *recognisable, standard* one. So when you reach for a clever shortcut, an environment sniff, a duplicated block, a stub that papers over a broken dependency, stop and ask *what's the textbook construct here?* and do that instead. This is the union of three principles applied as a working reflex rather than a checklist: *[Common patterns first](#principles)* (use the construct a new contributor recognises in 30s), *[Industry standards, our own code](#principles)* (the textbook algorithm AND the textbook name, written fresh against our architecture), and *[Minimalism means elegance](#principles)* (consistency, reuse, no duplication, the fast hot path). What this bullet adds over those: the **timing** (on sight, continuously, not deferred to a "cleanup later" that never comes) and the **no-hacks floor** (a workaround is never the destination; if the standard fix is genuinely out of scope right now, the hack doesn't ship — it's backlogged with the standard fix named, per *[Mandatory subtraction](#process-rules)*). The product-owner-initiated counterpart, for larger restructures, is the *[Refactor for simplicity](#process-rules)* process rule; this principle is the small-scale, agent-initiated version of the same instinct. -- **No duplication, in code or docs.** Same logic in two places belongs in one shared function; same fact in two docs belongs in one place the other links to. A comment or doc paragraph that restates what the code already says is duplication too; delete it. (Reuse a recognisable shape rather than inventing one; see *Common patterns first* above.) -- **Document a thing once, reference it generically.** A module lives in one home (its `.h` + one `docs/moonmodules/*.md`), its registration, and its tests. Don't name it elsewhere: in other prose say "a modifier"/"a driver", not `FooModifier`, and don't re-explain what it does — the reader studies its spec. Naming a thing across unrelated files multiplies rename cost and teaches nothing a link wouldn't. *No duplication* applied to names. -- **Data over objects in the hot path.** This is minimalism's hot-path corollary — the same "minimal memory, fastest hot path" test (see *Minimalism means elegance*), applied where speed and memory matter most and resolved to one answer: design around plain contiguous data, not an object graph. A flat buffer of elements that one stage writes and the next stage reads, following the producer/consumer data flow in [docs/architecture.md](docs/architecture.md). A contiguous buffer is cache-friendly and lets a stage do integer math straight on the array, whereas per-element objects with virtual accessors are cache-hostile and allocation-heavy, exactly what the hot-path rules forbid. So in the render loop: no object graph, no inheritance, don't wrap buffer data in objects. The **one deliberate class hierarchy** is the module tree (one `MoonModule` base, shallow subclasses, a single virtual-dispatch boundary), because uniform polymorphism is what lets the UI render any module generically with zero per-module UI code. **Outside the hot path**, a small *recognizable* adapter interface with a couple of virtuals is allowed when it passes the *Common patterns first* test — e.g. `ListSource` is the textbook data-source/adapter shape (UITableView's data source, Qt's `QAbstractItemModel`): the view is generic, the rows stay with their owner. That is not "adding inheritance" in the sense this rule forbids; a *bespoke* hierarchy outside the module tree still is. The line: hot-path data is flat and object-free, period; off the hot path, prefer flat data but a proven adapter interface beats a hand-rolled callback table when it's more consistent and reusable. -- **Concrete first, abstract later.** Build one working feature end-to-end before extracting patterns into shared abstractions. Don't build the framework before the domain logic works. -- **Robust to any input.** A running device tolerates any sequence of UI actions or API calls: add, delete, replace, or reconfigure any module in any order, at any grid size, and it keeps running. Degraded or idle is acceptable; crashed is not. This robustness is a defining strongpoint of projectMM, and it's guarded by the test framework, not by hope: a discovered crash drives a new test that pins the fix (see the Hard Rule). Out of scope: power loss, malformed OTA, brown-out, and other physical/electrical faults the firmware can't intercept; this principle is about what the software accepts as input. -- **No reboot to apply a configuration change.** Every setting takes effect live, on the next render tick — change a pin map, a strand length, an output protocol, a mic pin or rate, anything, on a running device and it just works. There is no init-once-at-boot step, and no *config* change requires a restart, which sets projectMM apart from most LED-controller firmware (where a pin or protocol change means a reboot). Like robustness, this is a defining strongpoint, and it falls out of the architecture for free rather than being hand-built per module: any control whose change reshapes derived state routes through the generic `prepare()` rebuild sweep, so drivers, the audio peripheral, effects, layouts, modifiers and network I/O all inherit it. When adding a feature, don't reach for a reboot/restart to apply config; make the change live. Full mechanism + rationale: [architecture.md § Live reconfiguration](docs/architecture.md#live-reconfiguration-every-change-applies-without-a-reboot). The one exception is what you'd expect: a *firmware* OTA flash swaps the binary and needs the usual power cycle — that's not a configuration change, and (like power loss and brown-out) it's the same physical-fault boundary the robustness principle draws. -- **Domain-neutral core.** Separate core infrastructure from the light domain as much as practical. When mixing is necessary, use domain-neutral naming so the code stays open to future separation. -- **Present tense only.** Code, comments, and documentation describe the system as it is now. No changelogs, no roadmaps. History lives in git commits. This bans not just future-tense ("will be", "planned") but **absence-narration**: phrases like "no longer", "anymore", "formerly", "used to", "X was removed", or "there's no longer a Y" describe a *change from a past state* a present-tense reader never saw — state what *is*, not what stopped being. (The test: "there is no MCLK pin" is a present-tense *property* — keep it; "there's no SET_DEVICE_MODEL RPC anymore" narrates a removal — cut it, just describe the path that exists.) Exceptions: `docs/backlog/` (forward-looking) and `docs/history/` (backward-looking) — and `docs/adr/` + `lessons.md`, which legitimately contrast before/after because the contrast *is* the record (an ADR's Context is the situation that forced the decision; a lesson's before-state is what makes it a lesson). - -## Hard Rules - -The design rationale for each rule below lives in [docs/architecture.md](docs/architecture.md). The one-liners here are what the agent holds in working memory. - -**Tests must pass.** Run `ctest` (unit tests) and `uv run moondeck/scenario/run_scenario.py` (scenarios) before considering work complete. The Python wrapper invokes the C++ runner and persists per-target observations back to each scenario JSON (drift visibility); direct `./build/test/mm_scenarios` is fine for ad-hoc pass/fail checks but skips the JSON write-back. New core logic needs a corresponding module test. Full pipeline needs a scenario test. See [docs/testing.md](docs/testing.md) for the strategy and test inventory. - -**Warnings are errors.** Build with `-Wall -Wextra -Werror`. No warning is "harmless"; if it's noise, fix it or silence it explicitly with a `-Wno-…` justified in code. - -**Platform boundary.** No `#ifdef`, platform-specific `#include`, or hardware API call outside `src/platform/`. Compile-time platform branching uses `if constexpr` on `platform_config.h` flags. Full rule: [architecture.md § Platform Abstraction](docs/architecture.md#platform-abstraction). - -**Hot path discipline.** In the render loop and anything it calls: no heap allocations (`new`, `malloc`, `push_back`, `std::string`), no blocking (`delay`, `sleep`, `mutex.lock()`; use `try_lock`), integer math preferred over `float` per-light. Memory: single contiguous blocks outside the hot path, PSRAM via `heap_caps_malloc(..., MALLOC_CAP_SPIRAM)` for large buffers. Network input: synchronous by default. Full rules + rationale: [architecture.md § Hot path discipline](docs/architecture.md#hot-path-discipline). - -**The sub-hot path is a hot path too: every periodic tick shares the render thread.** The render loop gets all the optimisation attention, but `tick20ms()` and `tick1s()` (and anything they call — a state serialise, a file walk, a socket write) run **inline in the same Scheduler loop, on the same thread, between two render frames**. So a heavy `tick1s()` doesn't run "in the background" — it *steals* from the render budget the instant it fires, and the symptom is a periodic hitch at exactly that tick's cadence (a 1 Hz stutter for `tick1s`, a 50 Hz one for `tick20ms`). **Any code that runs on a timer is on the hot path when it fires.** So hold periodic work to the render loop's bar: *cheap per firing* (bounded work, no O(tree) serialise of unchanging data, no blocking write), or *amortised* (drain a chunk per tick, like the preview/state resumable sender), or *skipped when nothing changed* (a dirty-gate, not an unconditional rebuild). The reflexes: (1) **don't send/compute what doesn't change** — options, static structure, identical repeated data are built once, not re-serialised every second (canonical example: the WS control `optionSets` — the role-name array is emitted once per list and referenced by `optionsRef`, not re-inlined across all preset rows × channels every `tick1s`, which would be a ~20 KB/s serialise spiking the render tick; see `writeListOptionSets`); (2) **gate expensive periodic work on a real change signal** so the common case is near-zero; (3) **amortise an unavoidably-large periodic transfer** across many ticks rather than one blocking burst. When adding anything to a `tick*()` method, ask "what does this cost *every* time it fires, and does it fire on the render thread?" — "a lot" + "yes" is a hot-path violation even though it isn't the render loop. The KPI tick timing is the guard: a spike at a tick's cadence is the smell this rule catches. - -**Effects must run at every grid size and tick rate.** No crash on 0×0×0; animation math doesn't truncate to zero on fast devices. Full rule + rationale: [architecture.md § Effects](docs/architecture.md#effects). - -**Robust to any input.** No UI action or API-call sequence crashes or wedges a running device, including deleting, replacing, or clearing modules in any order. A crash or hang is a bug, not the user's fault; the fix is incomplete until a test reproduces the sequence. Full rule + rationale: [architecture.md § Robustness](docs/architecture.md#robustness). - -## Process Rules - -**Specs before code.** Module docs (`docs/moonmodules/*.md`) and the UI spec must be sufficient to implement from before writing code. What's sufficient is case by case. When in doubt, ask. - -**Ask, don't guess.** When uncertain about requirements, behavior, or approach, ask the product owner. Asking is always preferred over guessing. This is the default. - -**Sanity-check every request before acting.** When the product owner asks for something, first hold it against three references: does the request make sense, and does it align with [README.md](README.md), this [CLAUDE.md](CLAUDE.md), and [docs/architecture.md](docs/architecture.md)? If it does, proceed. If it doesn't, if the request contradicts a principle, breaks a hard rule, fights the architecture, or just doesn't add up given context the product owner may have missed, push back briefly before doing the work: name what looks off, name which doc says what, and offer the alternative. The product owner can still say "do it anyway" (they often have context the agent doesn't), but the check has happened. This catches bad decisions early instead of after the diff lands. - -**Refactor for simplicity.** When the product owner asks to make something simpler, more consistent, or "cleaner," do not start moving files or rewriting code until three questions are answered in writing: - -1. **Enumerate alternatives.** List the 2–4 plausible end states. One line each (e.g. "A: split into core/light", "B: split into core/light/platform", "C: keep flat with naming convention"). -2. **For each, name what's gained and what's lost.** Concrete and measurable: lines removed, ambiguities resolved, duplicated parsers eliminated, contributor friction added (every extra subfolder is friction; every empty placeholder dir is friction; every renamed-but-unused alias is friction). -3. **Pick the leanest that solves the actual problem.** Subtraction beats addition. An empty subfolder, a parser duplicated "for clarity", a renamed alias kept "for compatibility" all add friction without paying for themselves; don't propose them. - -Then check the recommendation against [§ Principles](#principles) (minimalism, data over objects, concrete first) and propose it as a question, not a fait accompli. The product owner picks; the agent implements only what was picked. If the picked option turns out to need a follow-up change (e.g. an updated naming convention to make the new layout consistent), surface that *before* starting the move so it's a single coherent refactor, not three round-trips. - -**Plan before implementing.** Use `/plan` mode before every feature. Review plans for: unnecessary files, inheritance where structs suffice, modifications outside the relevant directory. Reject and regenerate bad plans. **Save every approved plan** to `docs/history/plans/` named `Plan-YYYYMMDD - .md` (ISO-8601 date order so the directory sorts chronologically, e.g. `Plan-20260620 - Improv-as-REST.md` for 2026-06-20), as the first implementation step. The plan is the design record that complements `lessons.md` (the lesson record): the plan says what we set out to build and why; lessons.md captures what we learned doing it. **These saved plans are a reference archive for the product owner — agents WRITE a plan when creating one, but do NOT read the existing plan files for context unless the product owner explicitly points to one** (they're under the "Never automatically" rule below alongside the rest of `docs/history/`). Plans are **kept, not pruned** — they are the permanent design-intent record. When a plan's design ships (or doesn't), mark its outcome in the filename with a trailing parenthetical — `… (shipped).md` once it lands, `… (attempted, abandoned).md` if it was tried and dropped — so the directory shows at a glance what's done; an unmarked plan is still in flight. The **one exception** to "kept, not pruned": a multi-phase effort's per-phase plans may be **consolidated into a single `… (shipped).md` record** once the *whole* effort lands, provided that record preserves the design-intent arc (what each phase set out to do + the outcome, including any dead-ends). This isn't losing design intent — it's the same subtraction the rest of the process rewards, applied to a set of plans whose story is now one story. Consolidate only *shipped/settled* phases, never in-flight ones. - -**Use `uv` for every Python invocation.** Never type `python` or `python3` directly; always go through `uv run` (e.g. `uv run moondeck/build/build_desktop.py`, `uv run python -c "…"`). This applies to shell commands, CMake `add_custom_command` / `execute_process`, documentation examples, and anything that shells out. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter. Reason: uv manages the project venv and is the project standard ([moondeck/MoonDeck.md](moondeck/MoonDeck.md)); bare `python3` isn't on PATH on Windows (and macOS Python Launcher pops a Store prompt). If you catch yourself about to type `python`, stop and prefix with `uv run`. - -The **one exception** is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's bundled Python venv, not the project venv; adding uv to ESP-IDF docker would be a bigger CI lift than the portability win pays for. That file uses `find_package(Python3 REQUIRED COMPONENTS Interpreter)` and invokes `${Python3_EXECUTABLE}`, so CMake locates whichever Python IDF set up (`.venv\Scripts\python.exe` on Windows IDF, `.venv/bin/python3` on macOS/Linux IDF). The shared `src/ui/embed_ui.cmake` script takes a `PYTHON_CMD` parameter that callers pass: desktop passes `${UV_EXECUTABLE};run;python`, ESP32 passes `${Python3_EXECUTABLE}`. +1. **Minimalism.** Minimal flash, minimal memory, fastest hot path — and the periodic housekeeping that shares it is fast too. Minimal code, minimal documentation: every fact and every piece of logic has exactly one home — reference it, never copy it. Present tense only; history lives in git (`docs/backlog/`, `docs/history/`, and `docs/adr/` are the exemptions). One uniform building block: everything is a (Moon)module with the same known lifecycle. -**Consider extending before creating.** When adding a feature, check if an existing module can be extended cleanly. If a new file is genuinely cleaner, that's fine, but justify it. +2. **Industry standards.** The textbook solution, pattern, algorithm, and name — a codebase any experienced contributor understands in minutes. The standard, complete construct beats a hand-rolled special case, even when it's more lines. Any bespoke choice carries its one-line reason where it's introduced. -**Do not remove comments** unless they are outdated or factually wrong. Comments document intent and context. Removing them silently loses knowledge. +3. **Architecture first.** The domain-neutral core owns the hard constructs, written once; the light domain stays simple on top of it. Platform-specific code lives only in the platform layer. When core enforces a rule on one path, extend core to the next path — never paste the check into modules. No hacks: fix it the standard way the moment it's spotted, or backlog the real fix by name. Default to subtraction: the first question on any change is what it can remove. -**Anti-stalling.** If a build error or test failure takes more than 2 attempts to fix, STOP. Do not rewrite surrounding files. Ask the product owner for guidance or rollback with Git and re-approach. +4. **Guardrails everywhere.** Every behavior is pinned by tests, unit and scenario, whose descriptions read as functional documentation — a test states a behavior a user could understand, and a trivial test doesn't earn its place. Every commit is measured — performance, size, repo health — so growth and regression are visible the moment they happen. Judgment is reviewed; everything else is checked by the gate scripts. The final guardrail is physical: nothing counts as verified until it runs on real hardware — the bench, and the product owner's eyes, are the measurement. -**Git: only on explicit request.** Do not `git add`, `git commit`, or `git push` on your own initiative. Only execute these when the product owner explicitly asks (e.g. "commit now", "push it"). **When and what to commit or merge — including how much work to bundle into one commit and when to fold uncommitted work forward — is 100% the product owner's task and responsibility. Agents NEVER ask "shall we commit / merge?", never propose commit timing, never prompt for it, and never surface commit/merge sequencing as a question.** The product owner routinely bundles multiple topics into one commit and one merge; that is expected, not something to flag. An agent's job is to finish the work, report status (gates green, tree state), and leave uncommitted work sitting until the product owner says commit — silently. Two workflow specifics: (1) **one combined commit per cycle** — all uncommitted branch work lands as a *single* commit when the product owner triggers it, never split into standalone/partial commits (not even a small hygiene or doc change; leave those uncommitted to fold into the next combined commit), with the message's change sections grouping the areas. (2) **A verified hotfix goes straight to `main`** — a small, already-tested CI/build/manifest fix is committed directly on `main`, no feature branch or PR (the "branch first on the default branch" rule is for *feature* work that warrants review); feature work still branches. +5. **Robustness.** Unbreakable in use: any input, any order, any size — degrade visibly, never crash, and every discovered crash becomes a test. Every setting applies live; no reboot to apply configuration ([architecture.md § Live reconfiguration](docs/architecture.md#live-reconfiguration-every-change-applies-without-a-reboot)). Out of scope: power loss, brown-out, corrupted updates. -**Gate lists are initiated by the product owner, not by agents. Never start a gate list on your own; always ask first.** The full set of gates (especially the Opus reviewer at PR-merge and the ESP32 build at commit) easily takes 10 minutes per event and burns real tokens; agents must not initiate them unprompted. When the product owner says "run pre-commit" / "commit now" / "pre-merge" / "ready to release" the relevant list below runs. Do NOT start any list automatically because you finished a feature, because tests pass, because a milestone feels reached, because completion seems imminent, or because an earlier instruction implied a sequence ending in "commit". If you finished feature work and are unsure whether the product owner wants to proceed, **ask**: "Feature work is done, should I run pre-commit, or do you want to look first?" Treat each list as a gate the product owner opens; the agent's job is finishing the work and reporting status so the product owner can decide when to open it. +## The Process -**The bench boards are test rigs — build and flash them freely to verify work, no need to ask.** The PO-initiated rule above governs *gate lists* (the pre-commit / pre-merge / release ceremony that ends in a commit), NOT development iteration. Building firmware and flashing a bench board to see a fix run live is exactly what the boards are for — do it on your own initiative whenever a change needs hardware verification, the same as running `ctest` locally. Do not conflate "the ESP32 build *gate* (a pre-commit step)" with "flash a board to test": the former is PO-initiated, the latter is always available. Re-probe the port (ports drift, see [moondeck.json](moondeck/moondeck.json)), build, flash, confirm it serves, report what you saw. +Every change follows the same timeline: **main → branch → build → test → document → commit → merge → release**. The **product owner** (PO) is the person initiating a branch — any contributor can be one. The PO initiates every event and every gate list — never start one unprompted; if unsure, ask ("Feature work is done; run pre-commit, or do you want to look first?"). A conditional check runs only when its objective trigger matches; an applicable-but-skipped check needs a one-line reason in the commit/PR/release notes. Each cycle produces visible output, and each cycle subtracts: remove code and docs that no longer earn their place, or know why nothing can go — `backlog/` and `history/` shrink too. External contributors follow the same timeline: fork, branch, PR into main — the same checks and review apply. -**The one nuance: a *rigorous* change gets a heads-up first.** "Flash freely" is the default for ordinary iteration (a code fix, a UI tweak, a normal reflash to see it run). But when the change is *rigorous* — it could leave a board in a bad state or is disruptive to recover from: erasing/repartitioning flash, a bootloader / partition-table / sdkconfig change, changing the flash-size variant, a first-flash of an untested board, a long full-erase, or anything that risks bricking or a bootloop — **say what you're about to do and why, and wait for the go-ahead.** The test is *reversibility*: a routine reflash is a keystroke to redo, so just do it; a change that could cost real recovery effort (or hardware) is worth one sentence of confirmation first. When unsure which side a change falls on, ask. +### Main -**Invite the product owner to test — then STOP and wait.** Whenever a change produces something the product owner can *see or judge* (LEDs on a bench board, a UI screen, a rendered effect, a live device behaviour), the agent's job ends at "it's running on <board/URL>, here's what to look at" — **not** at the agent's own verdict. Say what changed, where to look, and what would count as good or bad, then **stop and wait for their observation before drawing conclusions, writing them into docs, or moving to the next step.** The product owner's eyes are the measurement; serial logs and API reads are supporting evidence, not a substitute. This is the *[Agent Roles](#agent-roles)* division made concrete: the product owner tests on hardware before approving, and an agent that races ahead — reaching a conclusion, updating a doc, starting the next task — has quietly taken that decision away from them and is often *wrong* (this rule exists because it happened repeatedly: conclusions written up from a bench the product owner never got to look at). The trigger is simply *"could the product owner see this?"* — if yes, hand it over and wait. Doubly so before anything irreversible-ish (a revert, a reconfigure, a reflash) that would destroy the very state they were about to look at: leave it running. +Main is always releasable: what's on main ships as the latest *pre-release*; tagged releases are cut from it. Work never starts on it: feature work branches. One exception: a small, already-verified hotfix commits directly to main. -The full gate lists per lifecycle event (commit, push, PR merge, release) live in **Lifecycle Events** below. +### Branch -**Mandatory subtraction.** Periodically review and remove code and docs that no longer earn their place. If nothing can be removed, justify why. This applies to `docs/backlog/` and `docs/history/` too: both grow *and shrink*. A backlog item that ships is deleted (it lives in the code now); a history entry whose lesson has been absorbed — folded into a principle, a doc, or simply internalised — gets pruned. The permanent record is the git commits; these two folders are a working narrative layered on top, kept only as long as they still earn it. Don't treat either as append-only. +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 - <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. 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. -**Reference, don't copy.** Previous prototype branches have working solutions. Reference them for proven approaches but don't copy code structure. +### Build -## Implementation Process +Implement against the architecture ([docs/architecture.md](docs/architecture.md)) and the coding standards ([docs/coding-standards.md](docs/coding-standards.md)). Verify with the tests and on the bench, and invite the product owner to judge the result — their eyes are the measurement (§ Principles, Guardrails). Everything build/flash/run/monitor: [docs/building.md](docs/building.md). -Each commit produces visible output. The product owner picks what to build next. - -### Per-feature workflow - -1. **Pick what to build.** One layout, one effect, one driver, one modifier, one system module: whatever adds the next useful capability. -2. **Spec it.** Write (or review) the module's spec. A spec-in-progress is a plain `.md` in `docs/backlog/` (like any other forward-looking note); when the module ships, its final spec is written in `docs/moonmodules/<Name>.md` and the temporary backlog draft (if any) is deleted. Most small modules go straight to `docs/moonmodules/` in the same change that implements them — the backlog draft is only for specs worth circulating before the code exists. -3. **`/plan` it.** Plan references only the relevant `docs/moonmodules/` specs + architecture docs. The approved plan is saved to `docs/history/plans/` (see *Plan before implementing*); the implemented code, docs, and commit message together describe what actually landed (which may diverge from the plan — that's expected, the plan is the intent record, not a contract). -4. **Implement in a branch** (`next-iteration` or feature branch). Test on hardware. Run the commit gates (see Lifecycle Events below). Commit. -5. **Push.** Product owner pushes. CodeRabbit reviews the PR. Process findings. -6. **Repeat.** - -### Lifecycle Events - -The project has **three** gated lifecycle events: **commit**, **PR merge into `main`**, **release tag**. (Push has no gate of its own: every check that needs to land before code goes out either lives in the commit gates or is the CodeRabbit / human PR review.) Each event has its own checklist below. Gates within a list run **only when the change makes them applicable**: every conditional gate states its trigger objectively (e.g. "any file under `src/` changed"). A gate that doesn't apply is skipped; a gate that *does* apply but the product owner chooses to skip must have a one-line reason in the commit body / PR description / release notes. The trail stays honest and auditable. +```sh +cmake --build build # desktop build (zero warnings) +ctest --test-dir build --output-on-failure # unit tests +uv run moondeck/scenario/run_scenario.py # scenario tests +uv run moondeck/build/build_esp32.py --firmware <fw> # ESP32 firmware build +uv run moondeck/build/flash_esp32.py --firmware <fw> --port <port> +uv run moondeck/check/check_specs.py # spec/doc drift check +``` -Initiation is always the product owner's call; see the rule above. Agents never start a list on their own. +All Python goes through `uv run`, never bare `python` (full rule: [coding-standards](docs/coding-standards.md)). -#### Event 1: Commit +Keep a branch under ~100 changed files: past that CodeRabbit declines the PR outright rather than reviewing part of it, so the branch silently loses a review layer. Split, or say so in the PR. -The narrow safety net: "this snapshot is internally consistent." +**MoonDeck** is the project's tooling: every build, flash, monitor, test, and check task is one Python script under `moondeck/`, and MoonDeck itself is the local web dashboard that runs those same scripts for a human ([moondeck/MoonDeck.md](moondeck/MoonDeck.md) is the per-script reference). Agents invoke the scripts from the command line — one set of scripts, two front ends — and every gate invokes one of them. Deliberately our own scripts rather than an embedded toolchain like PlatformIO: the firmware builds vendor-native against pinned ESP-IDF versions, and the tooling covers far more than compile-and-flash — one script per task keeps humans, agents, and CI on the identical path (rationale: [building.md § MoonDeck](docs/building.md#moondeck--the-dev-console)). -**Always run (cheap, applies to every commit):** +### Test -1. Spec check, `check_specs.py`, fast (<1s), catches `docs/moonmodules/*.md` ↔ control-name drift even on doc-only commits, and validates every `main.cpp` `registerType` docPath resolves to a real page + `#anchor` (so a docs rename can't silently 404 the in-UI help links). +New behavior is pinned before it ships: a unit test for module logic, a scenario test for a full pipeline, and every discovered crash becomes a regression test (§ Principles, Guardrails + Robustness). Test descriptions read as functional documentation — a statement a user could understand — and a trivial test doesn't earn its place. Placement: [coding-standards § Tests](docs/coding-standards.md#tests); inventory and strategy: [docs/testing.md](docs/testing.md). -**Conditional (run if trigger matches):** +### Document -2. Desktop build, `cmake --build build` (zero warnings), if any file that compiles into the desktop binary changed: `src/`, `test/`, `CMakeLists.txt` (root or `test/`), `library.json`. A YAML / docs / `moondeck/` / `.claude/` change cannot break the build. -3. Unit tests, `ctest --output-on-failure` (all pass), same trigger as Desktop build. No build, no tests. -4. Scenario tests, `uv run moondeck/scenario/run_scenario.py` (all pass; wraps `mm_scenarios` and persists per-target `observed.<target>` blocks back to each scenario JSON for drift tracking), same trigger as Desktop build, plus any `test/scenarios/*.json` change. -5. Platform boundary, `check_platform_boundary.py`, if any file under `src/` (excluding `src/platform/`) changed. -6. ESP32 build, `build_esp32.py`, if any file under `src/` (excluding `src/platform/desktop/`), `esp32/`, `CMakeLists.txt`, or `library.json` changed. -7. KPI collection, `collect_kpi.py --commit`, if any file under `src/` changed. **The one-liner MUST include `tick:Xus(FPS:Y)` for every supported target** (desktop + ESP32 today; Teensy/RPi when added). If a target's tick/FPS is missing (e.g. ESP32 wasn't monitored recently and `esp32/monitor.log` is stale), re-run a short live capture before committing, or note explicitly in the commit body why the value is absent. -8. Device-model catalog, `check_devices.py`, fast (<1s), if `web-installer/deviceModels.json` or `moondeck/check/check_devices.py` changed. Validates the installer catalog: required fields, `firmwares` a non-empty list of non-empty strings (`firmwares[0]` is the default), every `image` resolves on disk, each entry's `System.deviceModel` control equals its entry `name`, module `type`s are factory-registered (or boot-wired singletons), `pins` controls live only on `*LedDriver` modules, and `supported` capabilities stay within the known vocabulary. -9. Firmware list, `check_firmwares.py`, fast (<1s), if `moondeck/build/build_esp32.py`, `web-installer/firmwares.json`, or `moondeck/check/check_firmwares.py` changed. Regenerates the firmware projection from the `FIRMWARES` dict and fails on drift from the committed `web-installer/firmwares.json` (so a `FIRMWARES` edit without regenerating is caught). Trigger includes `build_esp32.py` because that dict is the upstream source. -10. Host-side unit tests (Python + JS), fast (<2s), the suites the C++ ctest can't reach (MoonDeck / build-script logic, web-installer logic). Run `uv run --with pytest --with pyserial --with markdown --with wled pytest test/python -q` if any file under `moondeck/` or `test/python/` changed, and `node --test "test/js/**/*.test.mjs"` if any file under `web-installer/` or `test/js/` changed. (Same command + `--with` set the PR-triggered `.github/workflows/test.yml` runs; the deps come from each test file's inline PEP-723 block, passed via `--with` as the explicit, discovery-friendly form.) These pin the cross-language contracts the device shares with hosts: the Improv frame wire format (`test/python` + `test/js` assert a shared golden vector so the C++, Python, and JS frame builders can't drift) and the WLED `/json` shape (parsed through frenck/python-wled, the library HA's WLED integration uses). New Python/JS unit suites land under `test/python` / `test/js` and run here. +Docs land with the code, not at merge time: the module's spec and catalog card describe what actually shipped ([coding-standards § Documentation model](docs/coding-standards.md#documentation-model)); a breaking change gets its entry in [docs/MIGRATING.md](docs/MIGRATING.md); a shipped backlog item or spec draft is deleted. The merge gate only verifies this happened. -A commit that touches *only* `.github/`, `docs/`, `README.md`, `CLAUDE.md`, or `.claude/` therefore runs only the spec check (plus the board-catalog and firmware checks when their specific files changed); the build/test/ESP32/KPI gates are no-ops because their triggers don't fire. A `moondeck/` change adds the Python host-side unit-test gate, and a `web-installer/` change adds the JS one, but both still skip the C++ build/ESP32/KPI gates. This is the intended pre-commit cost for CI-only or doc-only changes. +### Commit -**Recommended (manual, not blocking):** +Git only with the PO in the loop: staging, committing, and pushing happen only when the PO explicitly triggers them. What and when to commit or merge is 100% the product owner's call — never ask or propose commit timing. One combined commit per cycle (no partial commits; hygiene changes fold into the next one). Branches and commits may bundle multiple topics: not every small change gets its own commit — the pre-commit and pre-merge checks would be too much overhead. -- **Improv smoke test**, `uv run moondeck/build/improv_smoke_test.py --port <port>` (or MoonDeck → ESP32 → **Improv Smoke Test**), recommended when a connected ESP32 is available and any of these changed: `src/core/ImprovFrame.h`, `src/platform/esp32/platform_esp32_improv.cpp`, `web-installer/index.html`, `src/ui/install-picker.js`, `moondeck/build/improv_*.py`. Three-step end-to-end check (probe + WiFi provision + LAN reachability). Not a blocking gate because it needs hardware that isn't always plugged in; pair with `preview_installer`'s flash-ready mode for the browser-side equivalent. +On "run pre-commit": `uv run moondeck/event/precommit.py`. It runs every gate whose trigger the change matches and reports PASS / FAIL / SKIP / MANUAL. Then wait for an explicit "commit now". -**After all gates pass:** stop and wait for the product owner to explicitly say "commit now" (or equivalent). Do not commit on your own initiative. +Commit message: title ≤ 72 characters, imperative. Then a 1–3 sentence end-user TL;DR (no file lists). Then the performance one-liner, measured for every supported target by running `collect_kpi.py --commit` with a board attached. Then change sections as bullets: **Core**, **Light domain**, **UI**, **Scripts/MoonDeck**, **Tests**, **Docs/CI**, **Reviews** (🐇 external / 👾 Reviewer, one bullet per finding: flagged → done/accepted/deferred + why). Core and Light domain are the preferred default categories (a core-module test → Core; a script fix touching a light driver → Light domain). No hard wraps inside a part. Full performance block at the bottom. -**When "commit now" is received**, compile the commit message in this format and execute the commit: +**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". -8. Commit message format (the structure below uses hard newlines *between* its parts: title, summary, KPI line, bullets are each their own line. But do **not** hard-wrap *within* a part: the summary paragraph and each bullet are a single unbroken line that the viewer soft-wraps, same reasoning as the no-hard-wraps-in-markdown rule in [coding-standards.md](docs/coding-standards.md), keeps diffs clean and renders correctly on GitHub. Only the title obeys a length cap; everything else runs as long as it needs to on one line): - - **Title line**: short imperative summary of the change (≤ 72 chars), e.g. `Add MirrorModifier and fix PreviewDriver sampling` - - **Short summary**: a TL;DR for the commit: 1–3 sentences max, end-user readable, plain language. State *what* changed and *why* at the level a release-notes reader cares about; do NOT recap the change sections that follow (the bullets do that), and do NOT enumerate files. If your draft is longer than three sentences or restates section headers, cut it. A reader who only sees the title + this paragraph should know what shipped and why. - - **KPI one-liner**: the `tick:Xus(FPS:Y)` line from step 7. Omit if the KPI gate didn't run (no `src/` changes). - - **Change sections**: one section per applicable category below; omit a section entirely if nothing in that area changed. Each section is a bulleted list, one bullet per module/file, in your own words. **Core and Light domain are the preferred default categories**: a test for a core module goes under Core, a script fix that touches a light driver goes under Light domain. Only use the other categories for changes that have no meaningful connection to Core or Light domain: - - **Core** (`src/core/`, `src/platform/`): e.g. `- HttpServerModule: added 409 guard to prevent overlapping OTA jobs` - - **Light domain** (`src/light/`): e.g. `- PreviewDriver: replaced strided sampling with max-pooling to fix empty frames` - - **UI** (`src/ui/`): e.g. `- app.js: auto-fit camera distance on first preview frame` - - **Scripts / MoonDeck** (`moondeck/`): e.g. `- MoonDeck: added per-scenario dropdown to scenario card` - - **Tests** (`test/`): e.g. `- test_preview_driver: updated default assertions for new fps/detail/decompress values` - - **Docs / CI** (`docs/`, `README.md`, `CLAUDE.md`, `.github/`, `CMakeLists.txt`): e.g. `- README: consolidated ESP32 install info to web installer` - - **Reviews**: if any review findings were processed in this commit, one bullet per finding, prefixed by reviewer: 🐇 for CodeRabbit findings, 👾 for internal Reviewer agent findings. Each bullet states what was flagged, what was done (fixed / accepted / deferred), and, if not fixed, why. Omit if no review findings were processed. - - Full KPI details block at the bottom (as produced by `collect_kpi.py`) +### Merge -**Not at commit-time** (these run at PR-merge): Reviewer agent; live perf analysis + `docs/performance.md` update; documentation sync sweep; permission review. +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. -**Reviewer at commit-time — large commits, or on demand.** The Reviewer's home is PR-merge (it sees drift best across N commits, per gate 5 below), but a *single* large commit already hides the same drift a whole branch does, so it earns a review before it lands. Trigger: **run the Reviewer on the staged diff when the commit is large** — a rough bar is **≥ ~10 changed files spanning multiple areas** (e.g. core + light + scripts + UI), or a diff big/multi-threaded enough that "these changes each look fine alone but do they cohere?" is a real question. Same scope as the merge-time gate (gate 5). Below that bar the pass is **on demand** — the product owner says "run reviewer" before commit when a specific change feels risky. Small, single-area commits skip it (the mechanical commit gates are the net there). Either way the Reviewer only *reports*; findings are fixed, or accepted with a one-line reason in the commit body's **Reviews** section (👾), before "commit now". As with every gate list, the product owner initiates — the agent finishing a large diff should *ask* "this is large, run the Reviewer first?", not launch it unprompted. +Those judgment gates: review feedback addressed; the Reviewer agent over the whole branch diff (start it first, it runs in parallel; scope: boundaries, bespoke conventions, unnecessary abstractions, duplication, hot path, spec conformance, bloat); lessons carried forward only when VERY important — most learning lives in the commit/PR record; a truly important gotcha → `lessons.md`, a major architectural decision → a new ADR, a hardened rule → CLAUDE.md or coding-standards; docs sync; the PR title and description matching the actual diff; the performance snapshot when tick-path code changed; the permission review (prune the accumulated local list and snapshot the approved result — never broaden destructive or network-mutating permissions without explicit approval, err toward tight); a README refresh when build, flash, or first-run changed. -#### Event 2: PR merge into `main` +### Release -The "this is now trunk" moment. Where the wider hygiene checks live, because once it's in trunk it gets shipped. +On "run pre-release": `uv run moondeck/event/prerelease.py`. The mechanical checks run; the rest is judgment it lists for the PO — merge gates passed on the tagged commit, the real-hardware test (PO only), no open release-blockers, the per-release criteria done, release notes, cross-platform smoke on a major/minor bump, and the principles audit for forward-looking language (the Reviewer agent can run that one). -**Mandatory:** +## Roles & Collaboration -1. All commit gates passed on every commit in the PR. -2. PR feedback addressed (CodeRabbit + human review). -3. **Carry forward lessons**: if the branch produced something worth keeping, record it in the right home as part of the branch's commits, so it lands in `main` with the code that proved it (do this on the branch before the merge commit). A **debugging lesson or gotcha** (a bug, its cause, the fix) goes in `docs/history/lessons.md`. A genuine **architectural decision** (chose approach A over B/C) is a new immutable ADR in `docs/adr/` (`NNNN-<title>.md`, Nygard format; supersede, never edit). A lesson that has hardened into a **general rule** goes to CLAUDE.md or `coding-standards.md`, not a history file. -4. **Documentation sync**: every new module / control / API endpoint has matching docs (`docs/moonmodules/*.md`, `docs/testing.md`, `docs/architecture*.md`). -5. **Reviewer agent**: trigger this **first** so it runs while the other checks (docs sync, carry-forward lessons, conditional gates) proceed in parallel. Opus reviewer over the **whole branch diff** (`git diff main...HEAD`). Scope: domain boundary, **common patterns first** (flag any new convention (naming scheme, file shape, build flag, control mechanism, UI affordance) that isn't recognisable from a widely-used project / framework / canonical resource; bespoke choices must carry a stated reason at the introduction site, see the principle in § Principles), **unnecessary abstractions** (no-op / pass-through wrappers that only rename or re-namespace an existing function, single-call-site indirection that would read clearer inlined, names that obscure where the real code lives), **duplicated patterns** (same logic in multiple places that belongs in a base class or shared function), hot-path violations, spec conformance, bloat, platform boundary. Architectural drift is more visible across N commits than across one: "three commits each added a wrapper" reads as a pattern that one commit hides. Findings either get fixed in additional branch commits before merge, or are accepted with a one-line reason in the PR description. CodeRabbit complements this: CodeRabbit handles line-level bugs in the PR; the Reviewer agent handles architectural drift. -6. **PR title and description**: review and update if the work done differs from what the PR title/description says. The description is the permanent record of what landed and why; it should reflect the actual diff, not the original intent. +The product owner is the critical success factor. The PO reviews every line before committing, specifies requirements, controls all git operations, tests on hardware, decides what's built, and filters agent suggestions critically. The agent writes; the product owner thinks. Tight PO control is deliberate: it is what keeps the system lean and predictable. -**Conditional:** +| | Agent | Model | Focus | +|--|-------|-------|-------| +| 🤖 | **Architect** | Opus | System design, boundary review | +| 👽 | **Developer** | Sonnet | Implementation, one step at a time | +| 👾 | **Reviewer** | **Fable** (Opus fallback) | Pre-merge branch review + large-commit review; model fixed | +| 🛸 | **Tester** | Sonnet | Tests, verifying rules in code | +| 💀 | **Runner** | Haiku | Script runs, checks, build verification | +| 🔬 | **Researcher** | **Fable** | Read-only fan-out: inventories, blast radius, prior art | -7. **Live perf snapshot**: `docs/performance.md` updated, if the branch touches anything under `src/light/`, `src/core/Scheduler.h`, `src/core/HttpServerModule.cpp`, or any platform code that runs in the tick path. Compare new tick/FPS to the previous committed values; explain significant changes. -8. **Permission review**: scan `.claude/settings.local.json`. The `allow` list grows organically and accumulates one-off entries (specific `sed` line ranges, one-time `lldb` invocations, `/tmp/probe` paths, hard-coded device-IP curls, bare `python3 -c` scratch) that will never recur. Propose to the product owner: (a) one-off entries that can be deleted, and (b) clusters of narrow entries that could collapse into one broad pattern (e.g. several `./build/test/mm_tests -tc="..."` lines → `Bash(./build/test/mm_tests:*)`) so routine commands stop prompting. Advisory: agent suggests, product owner approves. Never broaden permissions for destructive or network-mutating commands without explicit approval; err toward keeping the list tight. **After the product owner approves the cleaned list, immediately `cp .claude/settings.local.json .claude/settings.local.cleaned.json` to save a reference snapshot, and commit `settings.local.cleaned.json` (it is *tracked*, unlike the gitignored live file).** Reason: Claude Code auto-appends a new `allow` entry for every fresh command shape it runs, so the live file silently re-accumulates noise *during the very cleanup* and between merges — it looks like the cleanup "reset back," but it's new session churn, not a revert. The committed snapshot is the canonical clean baseline to diff against and `cp` back from next time, immune to that churn. Not commit-blocking but always worth doing once per merge since this is when noise has accumulated. -9. **README + quick-start refresh**: if the change altered build, flash, or first-run UX. +Agents never commit. **Delegate the mechanical roles**: parallelizable or substantial → delegate (gate fan-out → Runner; pinning a fixed bug → Tester; broad mapping → Researcher); a single fast check → inline. -#### Event 3: Release tag +**Ask, don't guess.** Asking the product owner is always preferred over guessing. -The "end users will use this" moment. Per-release criteria are defined by the product owner; this is the generic envelope. +**A question is answered, not acted on.** When the product owner asks a question, answer it and stop; changes happen only after explicit agreement. -**Mandatory:** +**Sanity-check every request.** Hold it against README, this file, and architecture.md. If it conflicts, push back briefly with the specific reference; the product owner can still overrule. -1. All PR-merge gates passed on the trunk commit being tagged. -2. **Real hardware test**: at minimum one ESP32, plus any other target the release claims to support. Cannot be agent-verified; **product owner only**. -3. **No known critical bugs**: open issues reviewed; any flagged "release-blocker" closed or downgraded. -4. **Per-release criteria**: every release criterion set by the product owner for this tag is done. +**Anti-stalling.** If a build error or test failure survives 2 fix attempts: STOP. Ask, or roll back and re-approach. -**Conditional:** +**Bench boards are free test rigs.** Build and flash freely to verify work; re-probe ports first. A *rigorous* change (anything that could brick, boot-loop, or wipe a board: flash erases, boot/partition/build-config changes, a first flash of an untested board) gets a one-sentence heads-up and a go-ahead first — the test is reversibility. -5. **Changelog / release notes**: drafted in the GitHub release body. Skip only for unreleased pre-1.0 tags. -6. **Cross-platform smoke**: run scenarios on every supported platform (today: desktop + ESP32; later: + Teensy, RPi), if the release claims new platform support or the version bumps a major or minor. -7. **Principles audit**: sweep `docs/` (except `docs/backlog/`, `docs/history/`, and `docs/adr/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty. +**Invite the product owner to test, then STOP.** If the PO could see or judge the result, hand it over ("running on X, look at Y") and wait for their observation before concluding, documenting, or moving on. Leave the state running; don't revert, reflash, or reconfigure what they were about to look at. -What the agent reads: -- Always: `CLAUDE.md`, `architecture.md` -- For this commit: `docs/moonmodules/<only the specs relevant to this change>` -- Never automatically: `docs/history/*`, `docs/backlog/*` +What the agent reads: always CLAUDE.md + architecture.md + coding-standards.md; per commit, only the relevant module specs; never automatically `docs/history/` or `docs/backlog/`. ## Documentation -```text -CLAUDE.md ← this file (rules and process) -docs/ - architecture.md ← system design (core + light domain) - coding-standards.md ← how code is written (conventions, file shape, checks) - building.md ← how to build, flash, run for every target - testing.md ← test inventory and strategy - performance.md ← per-module timing, memory, sizeof for each platform - MIGRATING.md ← breaking-change log (newest first): what changed + the action it costs the user - backlog/ ← forward-looking: what to build next (not present-tense) - README.md ← landing page: overview of every item + index (the rest of the system links here, not into items) - backlog-core.md ← to-build list, core / infrastructure domain (+ UI) - backlog-light.md ← to-build list, light domain (drivers, effects, preview, sensors) - backlog-mixed.md ← to-build list, items spanning both domains - adr/ ← architecture decision records (Nygard format; immutable, superseded-not-edited) - README.md ← ADR index - NNNN-<title>.md ← one decision each (Status / Context / Decision / Consequences) - history/ ← backward-looking: accumulated wisdom - README.md ← index: what's here + cross-repo trends + digest prompt - lessons.md ← debugging lessons + gotchas (bug → cause → fix; pruned as absorbed) - plans/ ← approved feature plans (Plan-YYYYMMDD - <title>.md; PO reference, agents don't auto-read) - *-inventory.md ← prior-project surveys (v1, v2, moonlight) - <repo>.md ← friend-repo monthly activity digests (FastLED, WLED, …) - moonmodules/ ← catalog pages (effects/modifiers/layouts/drivers) + core/light detail pages (see coding-standards § Documentation model) -``` - -Documentation describes the system as it is. Git commits are the history. Module specs are written before implementation. Doc pages are kept current with the code. - -**Documentation model** — the full standard lives in [docs/coding-standards.md § Documentation model](docs/coding-standards.md#documentation-model); the working-memory summary: every module has **two surfaces** — a hand-written **summary/catalog page** (one flat page per group under its domain: `light/{effects,modifiers,layouts,drivers,supporting}.md`, `core/{services,supporting,ui}.md`; authored as prose `### ` blocks, rendered as tables by the build hook) for the end user, and a **generated technical page** (`<domain>/moxygen/<Module>.md`, from the `.h`'s `///` comments) for the developer. Each summary card's Links column links to the technical page via a `Detail: [technical]` line (🧪 Tests · 📄 Technical, icon'd by the hook) — **not to the `.h`** (the technical page already links the `.h`). Rich rationale therefore goes in `///` (not `//` — `//` is invisible to moxygen), directly above the class; a `@card <file.png>` line adds the UI screenshot; relative `.md` links are stripped by Doxygen, so use full URLs. Shared rationale across sibling modules lives once on the base class's `///`. Test inventories + catalog tables are **generated at build time, not committed**. Where a fact is the same in the `.h` and a doc, `check_specs.py` **validates** they agree (control names, ranges, author URLs, and every `main.cpp` `registerType` docPath resolving to a real page + `#anchor`); embed a struct/enum/wire format via a `--8<--` snippet. Never re-type a fact the `.h` already states. - -The `history/` folder is the distilled experience of years of building LED/light systems, from WLED, WLED-MM, StarLight, MoonLight, through projectMM. It contains proven patterns, memory tricks, control mechanisms, and hard-won lessons, studied under the [*Industry standards, our own code*](#principles) principle. Per-project credits live in the `history/` digests and the per-module "Prior art" sections. - -The `backlog/` folder is its forward-looking counterpart: the to-build list is split by domain (`backlog-core.md` / `backlog-light.md` / `backlog-mixed.md`) with `README.md` as the landing page the rest of the docs link to, design studies sit alongside it, and a spec for a not-yet-built module can live here as a plain draft `.md` until it ships (its final spec then goes to `moonmodules/` and the draft is deleted). Both `history/` and `backlog/` are exempt from the present-tense rule and agents don't read them automatically; only when planning new work. Neither folder only accumulates: per [*Mandatory subtraction*](#process-rules), both shrink as well — shipped backlog items and absorbed history entries are deleted, since the git commits are the permanent record and these folders are just the working narrative above it. - -## Code Style - -All coding conventions, general (`#pragma once`, `constexpr`, `std::span`, namespaces, semantic names, markdown wrapping) and structural (header-only vs `.h` + `.cpp` for light vs core modules, exception-reason comment), live in [docs/coding-standards.md](docs/coding-standards.md). - -**Spelling: American English everywhere** — code identifiers, JSON/wire keys, comments, and docs all use US spelling (`color`, `serialize`, `optimize`, `initialize`, `behavior`, `center`, `gray`). This is the dominant technical-project convention (LLVM / Chromium / the kernel) and, crucially, the spelling every graphics/LED API we interop with uses (`CRGB`, `color`, CSS `color`), so it removes the split-spelling hazard (a grep for `color` that misses `colour`, a wire key that drifts between dialects). New code is American; existing British spellings (`colour`/`behaviour`/`centre` in older prose) get converted opportunistically when a file is touched, not in one big sweep. The one word to watch: `analysis`/`analyze` — `analysis` is already US (keep it), only `analyse`→`analyze`. - -## Agent Roles - -The project uses Claude Code agents in defined roles. The user is the **Product Owner**, the critical success factor in agentic coding. - -**What the product owner does:** -- Reviews every line of code and every spec change before committing -- Specifies requirements in detail; agents ask, they don't guess -- Controls staging, committing, and pushing (agents never do this) -- Tests on hardware before approving -- Decides what to build next and in what order -- Catches architectural drift, bloat, and unnecessary complexity early -- Evaluates agent suggestions critically; not everything proposed gets built - -**Why this matters:** Earlier in this project's history, agents had more autonomy. The result was bloat, architectural drift, and compounding bugs. The current approach (tight product owner control with agents as tools, not decision-makers) produces cleaner, more predictable code. The agent writes; the product owner thinks. - -| | Agent | Model | Focus | Does | -|--|-------|-------|-------|------| -| 🤖 | **Architect** | Opus | System design | Reviews against architecture, designs components, validates boundaries | -| 👽 | **Developer** | Sonnet | Implementation | Writes code in worktrees, follows all rules, one step at a time | -| 👾 | **Reviewer** | **Fable** (Opus only if Fable is unavailable) | Pre-merge check | Runs at PR merge over the whole branch diff (Event 2, gate 5), and pre-commit on the staged diff when the commit is large or the product owner asks (see *Reviewer at commit-time*). The model is fixed, not a per-run choice. Complements CodeRabbit (which handles line-level bugs in the PR). | -| 🛸 | **Tester** | Sonnet | Verification | Writes tests, verifies architectural rules in code | -| 💀 | **Runner** | Haiku | Quick checks | Runs MoonDeck scripts, platform boundary checks, build verification | -| 🔬 | **Researcher** | **Fable** (same as Reviewer) | Investigation | Read-only fan-out over the codebase (or friend repos / datasheets) to answer a scoped question before a design or change — an interface inventory, a blast-radius map, a prior-art survey. Returns the conclusion, not file dumps. Feeds the Architect's plan and the Developer's spec. | - -Agents work in parallel on independent steps. Agents never commit; only the product owner approves commits after testing. - -**Delegate the mechanical roles; don't absorb them.** The main loop tends to *become* the Runner/Tester/Researcher rather than spawning them — running every gate inline, writing every test itself. That is the right call for a **single fast check** (a lone `check_specs`, one `git status`) where the spawn round-trip costs more than the work. But **delegate when the work is parallelizable or substantial**: the pre-commit gate fan-out is textbook **Runner** work (Haiku, in parallel, cheaper and faster than serial-inline); pinning a fixed bug with a regression test through the real code path is a clean **Tester** spec (find the mechanism yourself, hand Tester the spec, verify the result); a broad "map the interface / blast radius / prior art before we design" is **Researcher** work (Fable, read-only fan-out). The heuristic: *parallelizable or substantial → delegate; a single fast check → inline.* +Published at [moonmodules.org/projectMM](https://moonmodules.org/projectMM/); sources under `docs/`: -## Build +- [architecture.md](https://moonmodules.org/projectMM/architecture.html) — system design +- [coding-standards.md](https://moonmodules.org/projectMM/coding-standards.html) — how code is written +- [building.md](https://moonmodules.org/projectMM/building.html) — build/flash/run per target +- [testing.md](https://moonmodules.org/projectMM/testing.html) — test inventory and strategy +- [performance.md](https://moonmodules.org/projectMM/performance.html) — per-module timing/memory per platform +- [MIGRATING.md](https://moonmodules.org/projectMM/MIGRATING.html) — breaking-change log +- [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 -How to build, flash, run, monitor, and check the project for every target: [docs/building.md](docs/building.md). Per-script reference: [moondeck/MoonDeck.md](moondeck/MoonDeck.md). +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). -Agents use the CLI (`uv run moondeck/<group>/<name>.py`); humans typically use MoonDeck (`uv run moondeck/moondeck.py`, port 8420). Same scripts under both. Every gate in [Lifecycle Events](#lifecycle-events) ultimately invokes one of these scripts. +`history/` is the distilled experience of prior projects (WLED, StarLight, MoonLight, …), credited per module. `backlog/` is its forward mirror. Agents read both only when planning. Both shrink under mandatory subtraction. diff --git a/CMakeLists.txt b/CMakeLists.txt index a0c03095..7c026207 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,13 @@ set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +# Emit build/<host>/compile_commands.json — the compilation database clangd reads to give +# real diagnostics in the editor (it needs the actual include paths and flags, not guesses). +# On by default because it costs nothing, is ignored by every consumer that doesn't want it, +# and a missing database is the single most common reason clangd reports phantom errors. +# `.clangd` at the repo root points at it; see docs/coding-standards.md § Editor setup. +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + # Warning flags differ between GCC/Clang and MSVC. Gate by compiler so the # desktop build runs cleanly on Windows runners (no -W flags on MSVC) and on # macOS/Linux (no /W flags on Clang/GCC). diff --git a/README.md b/README.md index 97ba72e9..a3b565ee 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ If you like projectMM, give it a ⭐️, fork it, or open an issue or pull reque 🎵 **Audio-reactive**: an I²S microphone drives a 16-band FFT spectrum + sound level, consumed by audio-reactive effects — all built fresh from the mic datasheet and textbook DSP. -🏠 **Home-automation control**: a device joins Homebridge (and any MQTT hub) over a dependency-free MQTT 3.1.1 client — on/off, brightness, and a HomeKit colour wheel that picks the nearest palette. See [the MQTT module docs](docs/moonmodules/core/services.md#mqtt). +🏠 **Home-automation control**: a device joins Homebridge (and any MQTT hub) over a dependency-free MQTT 3.1.1 client — on/off, brightness, and a HomeKit color wheel that picks the nearest palette. See [the MQTT module docs](docs/moonmodules/core/services.md#mqtt). 📁 **On-device File Manager**: browse and edit the device filesystem from the browser — a lazy folder tree with an inline editor, drag-drop upload, and create/delete, plus [firmware upload OTA](docs/moonmodules/core/services.md#firmware-update) (flash a `.bin` over the LAN, no USB). See [the File Manager docs](docs/moonmodules/core/services.md#file-manager). @@ -48,7 +48,7 @@ If you like projectMM, give it a ⭐️, fork it, or open an issue or pull reque 🛠️ **ESP-IDF directly, no Arduino**: the ESP32 build is pure ESP-IDF (v6.x): native LED drivers, `esp_http_server`, FreeRTOS, built with `idf.py`, not PlatformIO or the Arduino framework. See [building.md § Why not Arduino](docs/building.md#why-not-arduino). -📦 **No third-party libraries**: no FastLED, no ESPAsyncWebServer, no ArduinoJson. The colour math, the HTTP/WebSocket server, and the control storage are all in-tree. A library, when genuinely needed, lives behind the platform boundary in `src/platform/`, never in core. The full rationale + replacements: [building.md § Third-party libraries](docs/building.md#third-party-libraries). +📦 **No third-party libraries**: no FastLED, no ESPAsyncWebServer, no ArduinoJson. The color math, the HTTP/WebSocket server, and the control storage are all in-tree. A library, when genuinely needed, lives behind the platform boundary in `src/platform/`, never in core. The full rationale + replacements: [building.md § Third-party libraries](docs/building.md#third-party-libraries). 🔬 **Industry standards, our own code**: we study the prior art hard — friend repos, peripheral datasheets, the Art-Net / E1.31 / WS2812 standards — carry its *ideas* forward, and credit it by name; but we write our own code rather than copying theirs or tracing their structure. Each feature is spec'd from the primary source, its behaviour pinned with unit + scenario tests, then written fresh against our own architecture, so the result is independent by construction, not a renamed fork. Textbook algorithm, textbook name, our implementation. The method: [CLAUDE.md § Principles](CLAUDE.md#principles). @@ -169,7 +169,7 @@ Specific people whose work directly shaped parts of projectMM. We study their th - **[hpwit](https://github.com/hpwit) (Yves Bazin)** — the clockless I2S / RMT / Parlio LED-driver techniques and the [ESPLiveScript](https://github.com/hpwit/ESPLiveScript) live-script engine behind the LED drivers and MoonLive. - **Christophe Gagnier ([@Moustachauve](https://github.com/Moustachauve))** — author of the native [WLED-Android](https://github.com/Moustachauve/WLED-Android) app. Its source let us reverse-engineer exactly what the WLED app reads, so projectMM devices appear in (and are controllable from) the native WLED apps. - **The [Improv Wi-Fi](https://github.com/improv-wifi) project** — the open Improv serial provisioning standard ([sdk-cpp](https://github.com/improv-wifi/sdk-cpp) / [sdk-js](https://github.com/improv-wifi/sdk-js)) that the projectMM web installer uses to provision a freshly-flashed device over USB. -- **[FastLED](https://github.com/FastLED/FastLED)** — the canonical LED-effects library whose conventions the LED-effect world shares. projectMM links no part of FastLED, but it carries forward FastLED's recognisable *names and models* for the colour/animation primitives — `scale8`, `sin8`, the gradient-palette model (`CRGBPalette16` / `colorFromPalette`), the `beatsin8` / `inoise8` / `qadd8` family — so a contributor recognises them on sight. The implementations are projectMM's own, integer-only and hot-path-tuned for our render loop; FastLED is the prior art behind the convention, credited here and in each primitive's notes. +- **[FastLED](https://github.com/FastLED/FastLED)** — the canonical LED-effects library whose conventions the LED-effect world shares. projectMM links no part of FastLED, but it carries forward FastLED's recognisable *names and models* for the color/animation primitives — `scale8`, `sin8`, the gradient-palette model (`CRGBPalette16` / `colorFromPalette`), the `beatsin8` / `inoise8` / `qadd8` family — so a contributor recognises them on sight. The implementations are projectMM's own, integer-only and hot-path-tuned for our render loop; FastLED is the prior art behind the convention, credited here and in each primitive's notes. - **wladi ([myhome-control](https://shop.myhome-control.de))** — designer of the [MHC-WLED ESP32-P4 shield](https://shop.myhome-control.de/en/ABC-WLED-ESP32-P4-shield/HW10027), and the source of the hardware and the pinout details that got its **line-in audio** working in [AudioService](docs/moonmodules/core/moxygen/AudioService.md): the onboard PCM1808 I2S ADC (WS 26 / SD 33 / SCK 32 / MCLK 36), the PCM1808's stereo wiring, and its `FMT` format-select jumper (open = I2S/Philips, our default; tie to 3V3 for left-justified) — which is what confirmed the standard-I2S path the ADC needs. ## Contributing diff --git a/docs/MIGRATING.md b/docs/MIGRATING.md index 18ed798e..a6a844be 100644 --- a/docs/MIGRATING.md +++ b/docs/MIGRATING.md @@ -106,7 +106,7 @@ The browser localStorage key for the UI's last-selected module. **Action: nothing.** Lost: the remembered selection resets to the first module. -### Device-list `colour` → `color` (US-spelling rename) +### Device-list `color` → `color` (US-spelling rename) The DevicesModule persisted-list key for a Hue bridge's color-capable light count (`DevicesModule::restoreList()`). A device list persisted under the old key reads the count as absent → 0. diff --git a/docs/adr/0007-moonlive-expressions-host-bound-functions.md b/docs/adr/0007-moonlive-expressions-host-bound-functions.md index cad42619..5c1e4676 100644 --- a/docs/adr/0007-moonlive-expressions-host-bound-functions.md +++ b/docs/adr/0007-moonlive-expressions-host-bound-functions.md @@ -4,7 +4,7 @@ Status: Accepted ## Context -The MoonLive compiler was first built around a fixed *statement shape*, `setRGB(idx, r, g, b)`, with per-slot parser rules (the index could be `random16`, colours were literal-only) and an RGB-specific `Store` op baked into the core. Three product-owner remarks exposed one root flaw: `random16` worked only in the index slot; `random16(255)` capped at a byte (validators conflated ranges); and the core compiler was light-domain-specific. +The MoonLive compiler was first built around a fixed *statement shape*, `setRGB(idx, r, g, b)`, with per-slot parser rules (the index could be `random16`, colors were literal-only) and an RGB-specific `Store` op baked into the core. Three product-owner remarks exposed one root flaw: `random16` worked only in the index slot; `random16(255)` capped at a byte (validators conflated ranges); and the core compiler was light-domain-specific. ## Decision diff --git a/docs/adr/0012-ha-discovery-wled-default-mqtt-opt-in.md b/docs/adr/0012-ha-discovery-wled-default-mqtt-opt-in.md index e8780dca..3af7ef0d 100644 --- a/docs/adr/0012-ha-discovery-wled-default-mqtt-opt-in.md +++ b/docs/adr/0012-ha-discovery-wled-default-mqtt-opt-in.md @@ -6,10 +6,10 @@ Status: Accepted A projectMM device can announce itself to Home Assistant over two independent auto-discovery mechanisms, and it implements both: -- **WLED integration** — HA's built-in WLED integration discovers the device over mDNS (`_wled._tcp`) and validates it by fetching `/json`; no broker. The `HttpServerModule` WLED-compat shim serves the `/json` shape `frenck/python-wled` requires, so HA adopts the device as a light with colour, palette, and diagnostic sensors. +- **WLED integration** — HA's built-in WLED integration discovers the device over mDNS (`_wled._tcp`) and validates it by fetching `/json`; no broker. The `HttpServerModule` WLED-compat shim serves the `/json` shape `frenck/python-wled` requires, so HA adopts the device as a light with color, palette, and diagnostic sensors. - **MQTT discovery** — the `MqttModule` publishes a retained `homeassistant/light/<id>/config`, the Tasmota/ESPHome/Zigbee2MQTT convention; HA (and any Discovery-aware hub) auto-creates a wired light. This needs an MQTT broker. -Both were on by default. The result on the bench: HA created a light entity from *each* path, so every device appeared **twice** — one `platform=wled` card (rich: colour, palette, sensors) and one `platform=mqtt` card (on/off + brightness only, per the discovery config). The duplication reads as a bug and confuses which card to use. +Both were on by default. The result on the bench: HA created a light entity from *each* path, so every device appeared **twice** — one `platform=wled` card (rich: color, palette, sensors) and one `platform=mqtt` card (on/off + brightness only, per the discovery config). The duplication reads as a bug and confuses which card to use. The two paths are not redundant. WLED needs no broker and carries a richer entity, so it is the better default. MQTT discovery reaches where mDNS cannot — a broker-only network, or a device on a different subnet/VLAN from HA — so it earns its place as a fallback, not as a second simultaneous announcement. @@ -19,7 +19,7 @@ HomeKit does not enter the decision: neither path exposes HomeKit directly. Appl Default to the **WLED** path; make **MQTT discovery opt-in**. -The `haDiscovery` control defaults **off**. A device on defaults appears in HA exactly once, via the WLED `/json` shim over mDNS, with the full colour/palette/sensor entity and no broker required. Turning `haDiscovery` on publishes the retained MQTT discovery config for setups where mDNS does not reach HA (broker-only, cross-subnet). The two never announce the same device to the same hub by default, so HA never double-lists it. +The `haDiscovery` control defaults **off**. A device on defaults appears in HA exactly once, via the WLED `/json` shim over mDNS, with the full color/palette/sensor entity and no broker required. Turning `haDiscovery` on publishes the retained MQTT discovery config for setups where mDNS does not reach HA (broker-only, cross-subnet). The two never announce the same device to the same hub by default, so HA never double-lists it. Toggling `haDiscovery` re-announces / retracts live (an empty retained config removes the MQTT entity), no reconnect. diff --git a/docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md b/docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md index 42823da5..8d51ab96 100644 --- a/docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md +++ b/docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md @@ -4,7 +4,7 @@ Status: Accepted ## Context -Persisted config and UI state carry keys/values that drift as the schema evolves (a control renamed, a config file renamed, a wire key re-spelled). The reflex fix is *migration code*: a fallback that reads the old key when the new one is absent, or a one-time cleanup that deletes files a renamed type wrote. Two such migrations had accumulated (a `FilesystemModule::migrateRenamedConfigs()` deleting `LayoutGroup.json`/`DriverGroup.json`, and a `lsRead(key, legacyKey, default)` localStorage fallback), and a review proposed a third (fall back to a legacy `colour` key after the US-spelling rename). +Persisted config and UI state carry keys/values that drift as the schema evolves (a control renamed, a config file renamed, a wire key re-spelled). The reflex fix is *migration code*: a fallback that reads the old key when the new one is absent, or a one-time cleanup that deletes files a renamed type wrote. Two such migrations had accumulated (a `FilesystemModule::migrateRenamedConfigs()` deleting `LayoutGroup.json`/`DriverGroup.json`, and a `lsRead(key, legacyKey, default)` localStorage fallback), and a review proposed a third (fall back to a legacy `color` key after the US-spelling rename). Migration code is **absence-narration in code form**: it exists only to describe a past state a present-tense reader never sees, which fights CLAUDE.md's *Present tense only* principle. It also accretes — each rename tempts one more fallback, and they become permanent dead weight because no one remembers to remove them. A *patching framework* (version-stamp the persisted file, run an ordered chain of patch functions) was considered as the disciplined alternative, but it is still migration code — just centralized — and building the framework before there are several real migrations to hold is premature abstraction (*Concrete first, abstract later*; *Core grows slower than the domain*). @@ -18,7 +18,7 @@ A **patching framework is explicitly deferred**, not rejected forever: it become ## Consequences -- The two existing migrations were removed: `FilesystemModule::migrateRenamedConfigs()` (and its call + declaration) and the `lsRead` legacy-key parameter. The proposed `colour`-fallback was not added. +- The two existing migrations were removed: `FilesystemModule::migrateRenamedConfigs()` (and its call + declaration) and the `lsRead` legacy-key parameter. The proposed `color`-fallback was not added. - **Robust-by-default is the contract**: a schema change must degrade gracefully through absent-default / clamp / ignore, never crash or wedge (the *Robust to any input* principle already guards this in tests). A change that would lose data silently is documented here instead of papered over with a fallback. - **Known breaking changes are logged in [MIGRATING.md](../MIGRATING.md)** — one entry per break, newest first, each with the action it costs the user (usually none: the value re-populates on next use). That log is the home for this decision's output; it lives outside the ADR because it *grows* with every break, and an ADR is immutable (superseded, never edited). - The lesson that generalizes: a robust reader is worth more than a migration — build the loader to tolerate drift, and most migrations never need to exist. Reach for a patching framework only when real, frequent, high-value breaks prove it earns its complexity. diff --git a/docs/architecture.md b/docs/architecture.md index c114328f..413b306e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,6 +68,8 @@ The system is two layers, separated as much as practical: When mixing is needed (for performance or simplicity), it must be an explicit decision: consciously choosing minimalism over separation, not accidentally blurring the boundary. Use domain-neutral naming in those cases ("producer buffer" not "LED buffer", "output driver" not "LED driver" in core interfaces) to keep the door open for future separation. +**Core primitives, not one-offs.** Core earns growth only by adding a recognizable, reusable primitive many modules lean on (a streaming write, a positional read, a bounded arena, a recursive JSON reader); a core change that only one caller needs is the smell. When a complex system will need a capability, build the cleanest complete version rather than a crippled subset that pushes hacks outward (a JSON reader that can't read arrays is not "minimal"). And concrete first, abstract later: build one working feature end-to-end before extracting the shared abstraction. + # Core The core's job is the runtime: modules, their lifecycle, their parameters, how they're scheduled, how they're persisted, how they reach the platform underneath. @@ -120,7 +122,7 @@ Controls are dynamic: when a value changes, the control set can be rebuilt. A se Prefer `uint8_t` (0–255) for slider controls. Minimises per-control memory, aligns with DMX channel values, keeps the UI range manageable. -Controls are the bridge between the [web UI](moonmodules/core/ui.md) and the running module tree: the UI renders a control from what the MoonModule declares, and a value the user changes there writes straight back into the module's member variable. The exact control types (slider, toggle, colour picker, text input, dropdown) are defined in the [UI spec](moonmodules/core/ui.md#control-types). The principle: modules declare what they need, the UI renders it. +Controls are the bridge between the [web UI](moonmodules/core/ui.md) and the running module tree: the UI renders a control from what the MoonModule declares, and a value the user changes there writes straight back into the module's member variable. The exact control types (slider, toggle, color picker, text input, dropdown) are defined in the [UI spec](moonmodules/core/ui.md#control-types). The principle: modules declare what they need, the UI renders it. ## Persistence @@ -201,7 +203,7 @@ Why this needs stating as its own guarantee: the mutation-driven rebuild above ( - **Resolve links at `prepare`, don't cache them across mutations.** A module that depends on another (a `Drivers` reading the active `Layer`, a `Layer` reading its `Layouts`) re-resolves that link from the tree at every rebuild rather than pinning a pointer once at wiring time. When the dependency is gone, the link resolves to null, not to freed memory. - **Tolerate null at the point of use.** Every consumer of a resolved link null-checks it and falls back to an idle state (no buffer, zero lights, nothing sent) rather than dereferencing. A driver with no Layer sends nothing; a Layer with no Layouts reports zero lights. Idle, not crashed. -The enforcement is the test framework, not discipline alone (see the [Hard Rule](../CLAUDE.md#hard-rules)). When a sequence is found that crashes or wedges the device, the fix is **incomplete until a test reproduces that sequence**, so the same break can't return. Worked example: deleting the last Layer once left `Drivers` holding a dangling pointer to the freed Layer; `PreviewDriver` then read it and panicked (`LoadProhibited`), and because the tree persists, the device boot-looped. The fix made `Drivers` clear its drivers' Layer pointers to null when no Layer is active, and a regression test (`unit_PreviewDriver`, "tolerates the active Layer being deleted") drives a Layer delete + rebuild and asserts the driver ends up null, not dangling. The scenario layer adds the same coverage end-to-end: `clear_children` lets a scenario clear a container and rebuild its own pipeline from any starting tree, so the delete/rebuild path is exercised on real hardware, not just in unit tests. +The enforcement is the test framework, not discipline alone (see the [Robustness principle](../CLAUDE.md#principles)). When a sequence is found that crashes or wedges the device, the fix is **incomplete until a test reproduces that sequence**, so the same break can't return. Worked example: deleting the last Layer once left `Drivers` holding a dangling pointer to the freed Layer; `PreviewDriver` then read it and panicked (`LoadProhibited`), and because the tree persists, the device boot-looped. The fix made `Drivers` clear its drivers' Layer pointers to null when no Layer is active, and a regression test (`unit_PreviewDriver`, "tolerates the active Layer being deleted") drives a Layer delete + rebuild and asserts the driver ends up null, not dangling. The scenario layer adds the same coverage end-to-end: `clear_children` lets a scenario clear a container and rebuild its own pipeline from any starting tree, so the delete/rebuild path is exercised on real hardware, not just in unit tests. ## Hot path discipline @@ -215,6 +217,10 @@ The render loop (`Scheduler::tick` and everything it calls: every effect, modifi **Network input** follows the same discipline: process synchronously at a defined point in the frame loop. Async input with staging buffers is allowed when memory is plentiful (desktop, PSRAM-rich ESP32), but the default is synchronous to keep the loop's worst case predictable. +**Data over objects.** The hot path is designed around plain contiguous data: flat buffers one stage writes and the next stage reads, no per-element objects, no virtual calls per light. The module tree is the one deliberate class hierarchy (uniform polymorphism is what lets the UI render any module generically, see [§ Web UI](#web-ui)); off the hot path, a proven adapter interface is fine, e.g. `ListSource`: the textbook data-source shape (UITableView's data source, Qt's `QAbstractItemModel`). + +**The sub-hot path is a hot path too.** `tick20ms()` and `tick1s()` run inline on the render thread between two frames, so any code on a timer is on the hot path the moment it fires; a heavy periodic step shows as a stutter at exactly that tick's cadence (a 1 Hz hitch for `tick1s`). Periodic work is therefore *cheap per firing* (bounded, no O(tree) serialize of unchanging data), *amortized* across ticks (drain a chunk per tick, like the preview/state resumable sender), or *gated on a real change signal* so the common case is near-zero. Never re-serialize data that doesn't change: the canonical example is the WS control `optionSets`, emitted once per list and referenced by `optionsRef` rather than re-inlined every `tick1s` (which would be a ~20 KB/s serialize spike on the render thread). The KPI tick timing is the guard; a spike at a tick's cadence is the tell. + ## Platform abstraction Only abstract what you actually need. Currently: @@ -228,7 +234,7 @@ Only abstract what you actually need. Currently: Abstractions are added when a concrete implementation needs them, not pre-designed. -**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 § Lifecycle Events](../CLAUDE.md#lifecycle-events)). +**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)). ## Firmware vs deviceModel vs board @@ -290,7 +296,7 @@ A device has **one** network name, `deviceName`, and every name the device prese # Light domain -The light domain is everything specific to driving lights. **Light** here means any controllable light source: an addressable LED pixel (WS2812, APA102), a DMX fixture (RGB par, moving head, dimmer), or any other output that takes colour/intensity data. The term is used instead of "pixel" because the system controls both LEDs and conventional lighting fixtures. +The light domain is everything specific to driving lights. **Light** here means any controllable light source: an addressable LED pixel (WS2812, APA102), a DMX fixture (RGB par, moving head, dimmer), or any other output that takes color/intensity data. The term is used instead of "pixel" because the system controls both LEDs and conventional lighting fixtures. ## The pipeline @@ -363,7 +369,7 @@ A **Layer** (a MoonModule, child of Layers) owns: - **Effects** (ordered list): write light values into the buffer. - **Modifiers** (ordered list): transform the LUT or light values. -A layer can have **multiple effects**. Each effect writes to the buffer sequentially in its listed order, overwriting or adding to the previous — so the effects stack (a base-colour effect followed by a sparkle effect). +A layer can have **multiple effects**. Each effect writes to the buffer sequentially in its listed order, overwriting or adding to the previous — so the effects stack (a base-color effect followed by a sparkle effect). A layer applies **all its enabled modifiers as a chain** during the mapping build (`Layer::rebuildLUT`): each modifier is a coordinate fold, and they compose in child order (M₁∘M₂∘…). Modifiers are **reorderable** in the UI, and order is meaningful (a multiply-then-checkerboard mask differs from checkerboard-then-multiply, just as mirror-then-rotate differs from rotate-then-mirror). The fold contract (the three hooks, the physical→logical build, the live pass) is documented in [ModifierBase](moonmodules/light/moxygen/ModifierBase.md). @@ -371,7 +377,7 @@ Each layer references the shared Layouts. The layer builds its mapping by walkin ## Effects -Effects produce light colours. They write into the Layer's buffer, which represents a logical grid. The Layer determines the buffer's dimensions (width, height, depth) from the Layouts and its modifiers. Effects receive these logical dimensions and elapsed time (millis) as their rendering context. They compute light positions from the buffer index (e.g. `x = i % width`, `y = i / width`). +Effects produce light colors. They write into the Layer's buffer, which represents a logical grid. The Layer determines the buffer's dimensions (width, height, depth) from the Layouts and its modifiers. Effects receive these logical dimensions and elapsed time (millis) as their rendering context. They compute light positions from the buffer index (e.g. `x = i % width`, `y = i / width`). Effects use elapsed time for animation, not frame count. Animation speed becomes frame-rate independent: an effect looks the same at 30 fps and 60 fps. This is also what makes the cross-device clock sync work: a shared elapsed-time base means synced visuals across controllers (see [§ Multi-device sync](#multi-device-sync)). @@ -421,7 +427,7 @@ uint8_t t = static_cast<uint8_t>((phase_num_ * 256) / 60000); See NoiseEffect / MetaballsEffect for the canonical pattern. Animation speed must depend only on `bpm` and wallclock, not on tick rate or grid size. -**An effect renders a pattern; it does not transform geometry.** When migrating or adding an effect, strip out anything that is really a *modifier* — mirroring, tiling, rotation, scrolling/offset, a kaleidoscope fold, masking, any remap of *where* pixels land — and add it as a separate [modifier](#modifiers) instead. WLED (and other sources we port from) routinely fold these into the effect's own loop (a "mirror" checkbox, a "2D" rotation, a built-in pinwheel), because WLED has no modifier concept; we do. Keeping them out of the effect is what lets any effect compose with any modifier (the same RotateModifier rotates Fire, Noise, or a network-received frame) instead of every effect re-implementing its own half-baked mirror. The test: an effect's `tick()` should only *write colours into the logical buffer for its own coordinates*; if it's reading or rewriting positions to move/fold/duplicate the image, that behaviour belongs in a modifier. (This is the light-domain face of *Complexity lives in core; domain modules stay simple* — geometry transforms are the modifier's job, shared once, not duplicated into every effect.) +**An effect renders a pattern; it does not transform geometry.** When migrating or adding an effect, strip out anything that is really a *modifier* — mirroring, tiling, rotation, scrolling/offset, a kaleidoscope fold, masking, any remap of *where* pixels land — and add it as a separate [modifier](#modifiers) instead. WLED (and other sources we port from) routinely fold these into the effect's own loop (a "mirror" checkbox, a "2D" rotation, a built-in pinwheel), because WLED has no modifier concept; we do. Keeping them out of the effect is what lets any effect compose with any modifier (the same RotateModifier rotates Fire, Noise, or a network-received frame) instead of every effect re-implementing its own half-baked mirror. The test: an effect's `tick()` should only *write colors into the logical buffer for its own coordinates*; if it's reading or rewriting positions to move/fold/duplicate the image, that behaviour belongs in a modifier. (This is the light-domain face of *Complexity lives in core; domain modules stay simple* — geometry transforms are the modifier's job, shared once, not duplicated into every effect.) ## MoonLive: the live-script engine @@ -448,7 +454,7 @@ A modifier is a coordinate transform, applied in one of two ways (the fold contr ## Mapping and blending -The blend+map step walks each layer in turn: reads each logical light, uses that layer's LUT to find the physical position(s), blends the colour into the physical output buffer. This is where logical space meets physical space. +The blend+map step walks each layer in turn: reads each logical light, uses that layer's LUT to find the physical position(s), blends the color into the physical output buffer. This is where logical space meets physical space. Each mapping LUT is a flat, contiguous lookup table allocated outside the hot path. It is built in `Layer::prepare()` and rebuilt whenever a Layout or Modifier control changes (the controls' `affectsPrepare` returns true) or a Modifier/Layout child is added/removed/replaced/moved; both triggers flow through the same core mechanism, see [§ Event triggering between modules](#event-triggering-between-modules). @@ -560,7 +566,7 @@ The UI is a handful of hand-maintained files: `index.html`, `app.js`, `style.css The UI is **MoonModule-driven**. It contains no hard-coded knowledge of specific effects, layouts, or drivers. It queries the system for the current MoonModule tree (layers, effects, modifiers, layouts, drivers, each with their controls) and renders generically: - Each MoonModule shows as a card with its name and declared controls. -- Controls are auto-rendered by type (slider, toggle, colour picker, text input, dropdown). +- Controls are auto-rendered by type (slider, toggle, color picker, text input, dropdown). - Modules can be switched (change which effect a layer uses) and linked (assign a layout to a layer). Adding a new MoonModule with controls needs **zero changes** to the UI files. This extends to the tree-mutation affordances: which modules accept children (and of what role) comes from each type's `acceptsChildRoles()`, and whether a module can be deleted/replaced comes from its `userEditable()`: both declared on the C++ side and reported in `/api/types` + `/api/state`. The UI hardcodes no list of "which types are containers" or "which roles are editable"; a new container type or a fixed child is a one-line C++ override. diff --git a/docs/assets/extra.css b/docs/assets/extra.css index da1e4104..fa8a3c02 100644 --- a/docs/assets/extra.css +++ b/docs/assets/extra.css @@ -38,7 +38,7 @@ .md-typeset .mm-catalog-wrap table td { word-wrap: break-word; overflow-wrap: anywhere; vertical-align: top; } /* Name column: title distinct from description (was one run-on line). The name is - bold and slightly larger; the description sits below in a muted colour with a + bold and slightly larger; the description sits below in a muted color with a small gap, reading as title + subtitle. */ .md-typeset .mm-catalog-wrap .mm-name { font-weight: 700; @@ -63,7 +63,7 @@ .md-typeset .mm-catalog-wrap .mm-param:last-child { margin-bottom: 0; } /* The param NAME (a `code` chip in the source) is the anchor the eye scans for — - make it stand out: accent colour, bold, no chip background so it reads as a + make it stand out: accent color, bold, no chip background so it reads as a highlighted term rather than a muted grey box blending into the prose. */ .md-typeset .mm-catalog-wrap .mm-param code { color: var(--md-typeset-a-color); /* the theme accent (purple) */ @@ -81,18 +81,18 @@ } /* Links column: grey the plain attribution prose (author, project, year). The - actual links (Tests, source .h, MoonLight) keep their accent colour — Material's + actual links (Tests, source .h, MoonLight) keep their accent color — Material's `a` rule is more specific than this, so only the surrounding text greys. */ .md-typeset .mm-catalog-wrap .mm-links { color: var(--md-default-fg-color--light); } /* --------------------------------------------------------------------------- - Coloured headings (whole site). The slate theme renders every heading in the + Colored headings (whole site). The slate theme renders every heading in the same near-white, so an h1/h2/h3 doesn't stand out from body text — the page - reads as one grey wall. Give headings the theme's primary/accent colour so + reads as one grey wall. Give headings the theme's primary/accent color so the document structure is scannable at a glance (the standard Material-site - look: coloured headings over neutral body). h4+ stay default — colouring + look: colored headings over neutral body). h4+ stay default — coloring every level would just re-flatten the hierarchy. */ .md-typeset h1, .md-typeset h2 { diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 32742235..bc3eab04 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -12,7 +12,7 @@ Split along the codebase's own boundary (`src/core/` vs `src/light/`), with a th - **[backlog-light.md](backlog-light.md)** — the light domain: LED drivers (architecture + deferred increments), LCD/DMA driver work, effects & preview, and sensors / audio-reactive input. - **[backlog-mixed.md](backlog-mixed.md)** — cross-domain items where a core mechanism interacts with a light driver/effect/modifier. -Completed items are removed; a file is deleted when empty (per [*Mandatory subtraction*](../../CLAUDE.md#process-rules)). Tags in item titles: *(investigation)* = needs measurement before a fix · *(backlog)* = scoped but not started · *(deferred)* = waiting on a prerequisite · *(future / long term)* = directional. +Completed items are removed; a file is deleted when empty (per [*Mandatory subtraction*](../../CLAUDE.md#the-process)). Tags in item titles: *(investigation)* = needs measurement before a fix · *(backlog)* = scoped but not started · *(deferred)* = waiting on a prerequisite · *(future / long term)* = directional. ## At a glance diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 83f41bc6..baf32fb4 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -23,9 +23,9 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co ### DevicesModule — interop plugins + the command half (discovery shipped) -DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`DevicePlugin`](../../src/core/DevicePlugin.h) seam (shipped: projectMM + WLED plugins). mDNS is advertise-only so projectMM appears in the native WLED apps + Home Assistant; the WLED-app interop (list + live colour + brightness control) is shipped too. What remains is *growth on the seam*, each piece additive (one plugin file, no core change): +DevicesModule discovers via **passive UDP presence** (UDP 65506) feeding a [`DevicePlugin`](../../src/core/DevicePlugin.h) seam (shipped: projectMM + WLED plugins). mDNS is advertise-only so projectMM appears in the native WLED apps + Home Assistant; the WLED-app interop (list + live color + brightness control) is shipped too. What remains is *growth on the seam*, each piece additive (one plugin file, no core change): -- **More discovery plugins** — ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only — never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../moonmodules/light/moxygen/HueDriver.md) — bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the colour-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver** — pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP — do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.) +- **More discovery plugins** — ESPHome, Tasmota, Hue (*hub-shaped*: a bridge whose Zigbee bulbs are children behind it, with link-button auth). Each is a new `DevicePlugin` declaring its `discoveryPort()` + classifying the datagram (or, for a system that only does mDNS, a re-introduced advertise-side browse scoped to *foreign* services only — never the ones we advertise). Hue is the canonical "more than a flat device" case the seam is shaped for. (Note: Hue *control* already ships as an **output driver**, see [HueDriver](../moonmodules/light/moxygen/HueDriver.md) — bulbs as effect pixels; the driver also *lists* its bridge in DevicesModule with the color-light count. Two complementary follow-ups remain: (a) auto-fill the driver's bridge IP from discovery so the user doesn't type it (the mDNS-browse plugin above); (b) **pair once, not per driver** — pairing + the app key currently live on each HueDriver, so two drivers on one bridge pair twice. The clean end-state moves the bridge identity (IP + key + Pair button + light list) into DevicesModule and makes HueDriver a pure output that reads the paired bridge by IP — do this together with the discovery plugin, since both hinge on DevicesModule owning the bridge.) - **The command half** — `DevicePlugin::command()` (+ per-plugin capability/auth), so projectMM can *control* a discovered foreign device, not just list it: set WLED brightness via its JSON API, a Hue resource via the bridge's authenticated CLIP API, a Tasmota via `cmnd`. Built when a control consumer exists; the discovery seam is already shaped to accept it (incl. hub plugins). This is the **multi-ecosystem selling point** — one UI controlling WLED + ESPHome + Hue. Commands split by need (the rule, not "all REST"): must-arrive config over REST; latency-critical sync over UDP (~0.5–1 ms vs REST's 10–50 ms — REST would visibly de-sync). - **Live peer state** — a discovered peer's brightness / on-off shown in our list, refreshed by polling its REST `/json` after discovery gives the IP (discovery = UDP/mDNS, state = REST). The read-side complement to the command half. - **Non-IP transports (board-gated, far future)** — Tasmota-MQTT / zigbee2mqtt need an MQTT client; **direct Zigbee/Thread** (S31/C6/H2 802.15.4 radio) makes projectMM the *hub itself*, driving bulbs over the mesh with no gateway — the standout differentiator, the biggest lift. Same plugin philosophy, a transport addition + board gate. @@ -71,7 +71,7 @@ This determines the practical LED limit for WiFi-only boards. Until the `sdkconf ### Network round-trip test — drop/reorder measurement (deferred) -`moondeck/scenario/run_network_roundtrip.py` measures desktop→device→desktop **latency and jitter** per protocol (ArtNet/E1.31/DDP) by timing how long a sent colour takes to appear in the device's preview stream. It deliberately does **not** measure per-frame **drops or reorder**, because the path can't track individual frames cleanly: `NetworkSendDriver` re-clocks at its own fps (decoupled from receive) and `NetworkReceiveEffect` holds-last-frame, so frames don't pass through 1:1 — a sequence number embedded in frame N may be re-sent 0, 1, or several times downstream. The min/median/max spread the test already reports *is* the jitter signal (it surfaced multi-second outliers on the classic ESP32). To measure true drop/reorder, the firmware would need a sequence-faithful echo path (e.g. a `NetworkReceiveEffect` echo mode that re-emits each received frame 1:1 back to the sender, bypassing the fps re-clock), then the desktop could match sent↔received sequence numbers. The test's docstring lists this under "extend later" alongside per-frame sequence matching and the device→device chain. +`moondeck/scenario/run_network_roundtrip.py` measures desktop→device→desktop **latency and jitter** per protocol (ArtNet/E1.31/DDP) by timing how long a sent color takes to appear in the device's preview stream. It deliberately does **not** measure per-frame **drops or reorder**, because the path can't track individual frames cleanly: `NetworkSendDriver` re-clocks at its own fps (decoupled from receive) and `NetworkReceiveEffect` holds-last-frame, so frames don't pass through 1:1 — a sequence number embedded in frame N may be re-sent 0, 1, or several times downstream. The min/median/max spread the test already reports *is* the jitter signal (it surfaced multi-second outliers on the classic ESP32). To measure true drop/reorder, the firmware would need a sequence-faithful echo path (e.g. a `NetworkReceiveEffect` echo mode that re-emits each received frame 1:1 back to the sender, bypassing the fps re-clock), then the desktop could match sent↔received sequence numbers. The test's docstring lists this under "extend later" alongside per-frame sequence matching and the device→device chain. ### Async ArtNet send — decouple the wire from the render tick (PSRAM-only) @@ -341,6 +341,25 @@ Fix options: (a) make every live mutate scenario clear+rebuild its own canvas (c ## Housekeeping +### clang-tidy: triage the 47 clang-analyzer findings, then gate + +`.clang-tidy` runs `*` minus a documented disable list and reaches zero on everything except the +path-sensitive `clang-analyzer-*` family: **47 findings**, led by `core.UndefinedBinaryOperatorResult` +(11), `bugprone-unchecked-string-to-number-conversion` (9), `security.insecureAPI.strcpy` (5), +`cplusplus.NewDeleteLeaks` (4) and `core.NonNullParamChecker` (4). `NewDeleteLeaks` and +`cplusplus.Move` are the two worth reading first — the analyzer traces real paths, so a finding is +a claim about an execution, not a style opinion. + +These surfaced late for an instructive reason: the report parser's check-name pattern rejected the +`,-warnings-as-errors` suffix clang-tidy appends when `WarningsAsErrors` is set, so **every finding +was silently dropped** the moment the ratchet was switched on. Fixed; recorded here because it is +the sixth silent-zero this tooling has produced, and each looked like a clean tree. + +Once triaged (fix / `NOLINT` with a reason / disable with a measured one), set +`WarningsAsErrors: '*'`. That one line is the ratchet that stops a zero decaying back into noise; +it is deliberately not set today because it would fail the gate on the 47. + + ### Heap-allocate the `registerType<T>` boot probe (lift a per-module lesson into core) `ModuleFactory::registerType<T>` stack-constructs a `T probe` at boot to capture `sizeof(T)`. On the main task's ~8 KB stack, a module with large inline members can overflow it and bootloop — a lesson the code records *per module* as a comment rather than fixing once in core: GameOfLifeEffect (`an inline array here caused a P4 stack-overflow bootloop`), HueDriver (`the lightsBuf_ stack-probe lesson`), and AudioService still carries ~5 KB of inline scratch while being factory-registered. This is the [*Complexity lives in core*](../../CLAUDE.md#principles) "lift the rule into core, don't paste it per module" clause: heap-allocate the probe (`MoonModule::operator new` already routes to PSRAM), or capture `sizeof` without constructing at all, so no module author ever has to remember the stack budget. Flagged by the 👾 Reviewer on PR #43. Small, core-only, its own `/plan`. @@ -389,7 +408,7 @@ Rounds 1 (board + Ethernet-only) and 2 (Parlio LED driver) have landed. Remainin - ❌ **Boot: `sleep_clock_icg_startup_init` aborts with `ESP_ERR_NO_MEM` (0x101) → reboot loop.** A KNOWN, OPEN ESP-IDF bug: **[esp-idf #18759 (IDFGH-17859)](https://github.com/espressif/esp-idf/issues/18759)** — on ESP32-P4 + PSRAM, this sleep-clock retention init runs unconditionally at a SECONDARY boot phase (before `app_main`, NOT gated by `CONFIG_PM_ENABLE`) and fails to allocate its REGDMA retention links when early internal DRAM is tight, which it is once esp_hosted's SDIO stack is pulled in (WiFi build only; the eth-only P4 has DRAM to spare). Espressif's guidance on the issue: reduce early internal-DRAM static usage. Bench findings (2026-07-03): `CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP` does NOT help (those buffers allocate after the boot init); `CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP=y` drops the ICG file but only moves the failure to the next retention alloc (PCR / int_wdt) then a `sleep_retention.c:914` assert; `.bss`/`.noinit` → PSRAM (`CONFIG_SPIRAM_ALLOW_{BSS,NOINIT}_SEG_EXTERNAL_MEMORY`) still fails — because `MALLOC_CAP_RETENTION` is a *specific reserved memory region*, not general DRAM, so freeing general DRAM doesn't reach it. This is the current blocker; MoonLight ran the same board on IDF 5.5 without it, so it's a 6.1-era regression. **Resolution paths (each independent):** - 1. **Upstream fix — the clean one, but no ETA.** Espressif's engineer on #18759 proposed *"add new configuration options to control whether the sleep clock ICG feature is enabled."* That fixes us directly: ICG retention only exists to save/restore clock config across a *light-sleep* cycle, and a 236-FPS LED controller never light-sleeps — so a `disable-ICG` Kconfig would let us skip the crashing init entirely, losing nothing, via one sdkconfig line. **Status (checked 2026-07-03):** the issue is `Status: Reviewing`, `Type: Bug`, **no milestone / no assignee / no target version** — the config-option approach was *offered by Espressif and seconded by another 24/7-app user (matti122), but not committed to a release*. So there is **no expected date**; it could land soon or sit. Watch [esp-idf #18759](https://github.com/espressif/esp-idf/issues/18759) for a linked PR / milestone. + 1. **Upstream fix — SHIPPED on master, needs a v6.1 back-port or a cherry-pick (rechecked 2026-07-27).** #18759 is now **CLOSED (Resolution: Done)**. The fix is exactly the knob we wanted: commit `7d31b82d27d` ("change(esp_pm): add kconfig option for REGDMA sleep clock ICG", 2026-07-06) adds **`CONFIG_PM_SLEEP_CLK_ICG_ENABLE`** (`bool`, default `y`) — set it **`n`** and the crashing `sleep_clock_icg_startup_init` is not built/run. Since a 236-FPS LED controller never light-sleeps, ICG retention is dead weight for us, so `=n` costs nothing. **But it is on `origin/master` only — NOT back-ported to `release/v6.1`** (our pinned IDF `14f663f`/dev-5880 and the current `origin/release/v6.1` are the same commit; neither has it). So bumping the pinned v6.1 IDF does *not* get it yet. **Decision (2026-07-27): wait for the next v6.1 beta** that carries the back-port, rather than cherry-pick `7d31b82d27d` onto the pinned IDF — a manual local IDF patch is bespoke, drifts, and complicates the single-pinned-IDF story for a fix that is a clean one-line sdkconfig change once it's in the branch. When a new v6.1 beta publishes: check it contains `7d31b82d27d` (or the `CONFIG_PM_SLEEP_CLK_ICG_ENABLE` symbol), bump the pinned IDF, add `CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n` to `sdkconfig.defaults.esp32p4-eth-wifi`, and bench-test the P4-WiFi boot. Once it boots, resume round-3 (the runtime SDIO re-init / C6 slave-reset work above). Watch `origin/release/v6.1` for the back-port. 2. **IDF 5.5 fallback — investigated 2026-07-03, NOT a cheap escape.** MoonLight ran this exact board on IDF **5.5** without the crash, so a 5.5 build of just this variant (everything else on 6.1) looked like a timeline-independent path. A bench attempt against IDF 5.5.4 found `src/platform/esp32/` has drifted to genuinely require IDF **6.x**: four distinct 5.5↔6.1 API breaks in `platform_esp32.cpp` / `platform_config.h` — (a) `RMT_LL_TX_CANDIDATES_PER_INST` renamed (5.5: `SOC_RMT_TX_CANDIDATES_PER_GROUP`); (b) `esp_eth_phy_ip101.h` moved (5.5: ctor lives in core `esp_eth_phy.h`, no standalone header); (c) `CHIP_ESP32S31` enum is 6.1-only; (d) `ETH_ESP32_EMAC_DEFAULT_CONFIG()` in 5.5 has out-of-declaration-order designated initializers — a C++ **hard error no compiler flag suppresses** (`-fpermissive` doesn't touch it). So a working 5.5 binary needs permanent `#if`-IDF-version compat branches in the platform layer (an EMAC-init back-port + a second IDF 5.5.4 in the CI matrix) — a real feature, not a throwaway. Only worth it if #18759 stalls long enough that a shippable P4-WiFi is needed sooner. 3. **Version-matched C6 slave reflash** (see round-3 item 1) — may change the boot memory picture, but is blocked behind the boot crash (host must boot to test the C6 handshake), so it only matters once 1 or 2 gets us to `app_main` stably. - **Round 4 — Parlio loopback self-test FIXED (2026-07-09), real long strip still to prove.** The Parlio loopback self-test now passes on P4 hardware at any grid size (verified on MM-P4, jumper GPIO 32↔33, at both 8×8 and 128×128). A real *long* WS2812 strip (not just the bench panel) is the remaining hardware proof. @@ -424,7 +443,7 @@ The shipped File Manager (see [system.md](../moonmodules/core/system.md#file-man - **Upload — recursive folder tier.** Single-file upload of **any size** streams to the file (`fsWriteStream`, binary-safe, `kUploadMax` + free-space guarded) and downloads stream back (`fsReadAt`), so text/config/binary all work. Remaining: **multi-file / recursive folder drops** — client-side `mkdir` + walk via `dataTransfer.items` `webkitGetAsEntry()`. - **Download — folder as `.zip`.** Per-file download streams (`<a download>` on `/api/file`). A folder download needs the browser to walk `/api/dir` recursively, fetch each file, and build a `.zip` client-side — which means bundling a zip library into `app.js`. That's a permanent app.js size bump, and app.js is embedded in firmware, so it weighs on the flash budget (see the flash-budget item above). Gate on real demand; symmetric with the folder-upload tier. - **Last-modified dates.** Needs a time source (NTP) + LittleFS mtime storage; the tree is name + size until both land. Backlogged with the NTP work. -- **`.ml` syntax highlighting in the editor.** MoonLive source wants *highlighting* (a colour layer over the textarea), not the JSON-style reformat — a bigger editor change (a highlight overlay or a small tokenizer), added when MoonLive `.ml` files land on disk. The editor already has an extension seam (`fmPrettify`) for the reformat case; highlighting is the separate, larger tier. +- **`.ml` syntax highlighting in the editor.** MoonLive source wants *highlighting* (a color layer over the textarea), not the JSON-style reformat — a bigger editor change (a highlight overlay or a small tokenizer), added when MoonLive `.ml` files land on disk. The editor already has an extension seam (`fmPrettify`) for the reformat case; highlighting is the separate, larger tier. - **Keyboard + screen-reader access for the tree.** The `fm-row` tree entries are mouse-only today (click to select/expand/open). Making them keyboard-operable (focusable, Enter/Space activation) with ARIA tree semantics (`role="treeitem"`, `aria-expanded` from `isOpen`, `aria-level`) is a real accessibility win but adds JS that lands in the firmware-embedded `app.js` (flash cost). Do it when a11y is a stated goal, together across the whole generic UI, not just this panel. ### Open design questions @@ -460,7 +479,7 @@ Legend: **Adopt-1.0** (small, high value) · **Defer-1.x** (needs engine work or | Log channel `{t:"log",m:"…"}` pushed by server | no server log push | **Defer-1.x** — needs an engine-side log producer. Gate: when boot/network/persistence logs become interesting to non-developers. | | Schema channel `{t:"schema",modules:[…]}` for tree-shape changes | full `/api/state` push every update | **Drop** — keep the full-tree push; re-evaluate only if WS bandwidth becomes a problem with large trees. | | System health panel (polls `GET /api/test`, pass/fail table) | none | **Defer-1.x** — needs a runtime `/api/test` that runs the doctest suite; `ctest` covers this for now. | -| Log panel (ring buffer, severity colouring, stick-to-bottom, `GET /api/log` backfill) | none | **Defer-1.x** — pairs with the log WS channel; both arrive together. | +| Log panel (ring buffer, severity coloring, stick-to-bottom, `GET /api/log` backfill) | none | **Defer-1.x** — pairs with the log WS channel; both arrive together. | ### Cost / decision table @@ -505,3 +524,77 @@ Mitigated in practice: prime-only fires exactly one EOF per frame (no intra-fram **Fix:** widen the backstop's condition so a stuck prime-only ring finalizes too — likely a single "any ring frame whose wire time has elapsed with `busy` still set" oracle that subsumes both ring branches, calling the shared `finalizeStalledTransfer`. Verify on the expander wall (prime-only = the small-strand ring config), since the ring recovery is not desktop-testable. **Related (same file, same wall dependency):** the whole-frame backstop stops the hardware before draining the FIFO and the EOF ISR drops a firing against an empty FIFO, which rejects a late EOF that arrives after recovery. A tighter guarantee — an ISR that has *already passed* the empty-FIFO guard cannot then clear a freshly-recovered `busy` or give `wireFree` for the next transfer — would need explicit serialization (disable the EOF interrupt across finalize+rearm, or an explicit recovery/rearm state the ISR checks). The window is extremely narrow (an ISR in-flight at the instant of finalize) and stopping GDMA+LCD first already disables the source; the hardened version is an ISR-concurrency change that must be proven on the wall, not landed blind. + +## ESP32-P4 400 MHz on rev-3+ silicon + +**Found:** bench, 2026-07-25 (Quindor Discord question about the P4's 400 MHz). + +The P4 build runs at **360 MHz** because IDF's `Kconfig.cpu` caps a `SELECTS_REV_LESS_V3` build (which we set, so a stock binary boots on the v0.x/v1.x P4 chips in the field) at 360; 400 MHz is IDF's default only for rev ≥ 3. Forcing 400 on our bench P4 (a **rev v1.3** chip) was tried and **bootloops** — `assert failed: esp_clk_init clk.c:105` — so 400 is a genuine hardware limit on pre-rev-3 silicon, not marginal stability. The ~11% compute headroom is left on the table for rev-3+ owners. + +**Two ways to reach it, both deferred until rev-3 P4 hardware is on the bench to validate:** +- **A separate `esp32p4-400` variant** — `sdkconfig` with `ESP32P4_REV_MIN` = rev 3 + `CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_400`, so IDF's normal boot-time clock init sets 400 and the bootloader refuses to run on a pre-rev-3 chip (safe). The web installer offers it only to rev-3+ boards. This is the projectMM-idiomatic per-variant path. +- **A runtime bump** — keep the 360 build (boots everywhere) and, at `app_main`, read `esp_chip_info().revision` and if ≥ 300 call `rtc_clk_cpu_freq_mhz_to_config(400)` + `rtc_clk_cpu_freq_set_config`. One binary, self-selecting. Riskier: a runtime CPU-PLL change on the P4 couples to the flash/PSRAM clock trees configured for 360, so it needs validation on real rev-3 hardware before shipping. + +Neither ships until a rev-3 P4 can prove 400 runs clean — no untested clock config, per the same rule the S31/320 and this P4/400 bootloop both taught. + +## ESP32-S31 RGMII Ethernet: DHCP at 100 Mbps (link-speed Tx-clock mismatch) + +**Found:** bench, 2026-07-26. Design record: [Plan-20260726 - S31 RGMII eth DHCP at 100M](../history/plans/Plan-20260726%20-%20S31%20RGMII%20eth%20DHCP%20at%20100M.md). + +The S31's 1 Gb RGMII EMAC (YT8531 PHY) **never** completes DHCP on the bench GL-AR300M (10/100) — and, as of 2026-07-27, **not on the gigabit router either** (see the gigabit result below, which contradicts the earlier reading this item was opened on). Confirmed on hardware that this is **not a regression** — it fails identically at the original S31 bring-up commit `d5ee07c` built against its exact pinned IDF (`0d928780081` / v6.1-dev-5215). The firmware logs `Ethernet no IP (DHCP timeout), cascading` and falls back to WiFi. + +**The gigabit test ran (2026-07-27) and Ethernet still does NOT lease — the link-speed theory is in doubt.** This was the decisive experiment this item asked for, and the expected outcome (leases at 1000M, because TXC is already 125 MHz there) did **not** happen. Boot trace on the current branch build, gigabit router: + +| t | line | | +|---|---|---| +| 1.73 s | `YT8531 RGMII init: auto-nego re-enabled, Rx+Tx delays set` | ✅ | +| 1.73 s | netif glue attached, eth MAC `32:ed:a0:f3:d4:69` | ✅ | +| 4.03 s | `Ethernet started` → `Ethernet link up` → DHCP hostname set | ✅ | +| 4–19 s | DHCP client runs its full 15 s window, no OFFER | ❌ | +| 19.3 s | `Ethernet no IP (DHCP timeout), cascading` | ❌ | +| 21.0 s | `WiFi STA got IP: 192.168.1.212`, status *"Ethernet detected: no address assigned"* | (fallback works) | + +**The frames never reach the router.** The device holds two MACs — WiFi `30:ed:a0:f3:d4:68` and Ethernet `32:ed:a0:f3:d4:69`. The host's ARP table lists the **WiFi** MAC and contains **zero** entries for the Ethernet MAC. A DISCOVER that reached the router would leave the eth MAC visible there even without a completed lease, so TX is not arriving — the same data-plane TX conclusion the static-IP test reached at 100M, now reproduced with a gigabit router in the path. + +**Physical-layer caveat, and the most likely explanation:** the jack's **yellow LED is off while green blinks fast**. On most RJ45 jacks yellow is the speed/link indicator, so yellow-off suggests the port did **not** negotiate 1000M and fell back to 100M — which would put this squarely back in the known TXC case rather than disproving it. This is unresolved because the firmware cannot currently report the negotiated speed: IDF logs `working in 100Mbps` / `1000Mbps` at `ESP_LOGD`, filtered out at our log level (exactly the diagnostic gap step (2) below names). **Before drawing any conclusion about the root cause, settle the actual link speed** — read the router's port page, and try a known 4-pair (gigabit-capable) cable, since a 2-pair cable forces 100M regardless of the router. + +**Root cause (IDF source-traced).** On RGMII the MAC's Tx clock (TXC) must be 125 MHz at 1000M but **25 MHz at 100M** (`emac_esp32_set_speed`). The MAC is reprogrammed only when the generic 802.3 PHY driver's poll sees a link_status *transition* (`updt_link_dup_spd` → `on_state_changed(ETH_STATE_SPEED)`). Because the YT8531 needs its auto-negotiation manually re-enabled before `esp_eth_start` (it disables it on reset), the first negotiation can latch link-UP before the poll's first read, so the poll sees no transition, so `set_speed(100M)` never runs and TXC stays at its 125 MHz install default. At 100M every Tx frame then clocks out garbled and the switch drops it (Rx still works — it rides the PHY-recovered RXC): link up, DISCOVER sent, no OFFER ever. + +**Approaches tried on the bench (neither shipped):** +- **`esp_eth_stop()` + `esp_eth_start()` bounce after start** — DID produce the first-ever eth lease (`.125`), proving the mechanism, but `esp_eth_stop` tears the netif down (releases the lease, resets dhcpc to INIT, clears the IP), which races the NetworkModule 15 s cascade window and the double `applyHostname` → non-deterministic (sometimes eth, sometimes WiFi; when eth, the netif IP was half-applied). +- **In-place `link_status = ETH_LINK_DOWN` + `phy->get_link()`** (netif-preserving, from the CONNECTED handler) — ran cleanly (no crash from the re-entrant poll) and forced a second link-up, but eth **still** DHCP-timed-out on the bench. Open question: whether `set_speed` actually fired (the `working in 100Mbps` proof line is `ESP_LOGD`, likely filtered by the esp_eth component log level — absence is not proof). + +**Already landed (the link-up half):** `ethYt8531BoardInit` (re-enables the YT8531's auto-negotiation — disabled on reset — plus the RGMII Tx/Rx clock delays) brings the RGMII **link** up (speed/duplex negotiated, activity LED lit); and the `ethPhyAddr` int16 fix (the `-1` auto-detect sentinel a `uint8` mangled to 31, with its regression test) makes the PHY addressable. What remains open is only the **100M Tx-clock (TXC) reconfiguration** so frames actually flow at 100M — the DHCP half below. + +**Next (needs hardware) — first, the gigabit test + a scope decision:** test the current firmware on a **1 Gb switch** (confirm `ETH-DIAG` reports "link up at 1000M full", then look for `Ethernet got IP`). At 1000M the MAC's Tx clock is already 125 MHz (the install default), so the 100M-specific TXC bug is absent — eth is expected to lease, matching the earlier field success on gigabit. **If it leases at 1000M, there is a product decision to make: whether to support 100M routers at all**, or to state that the S31 (a 1 Gb board) requires a gigabit switch and close this item as "won't-fix at 100M." Only if 100M support is deemed in-scope do the TXC steps below apply. + +**If 100M support is kept** (the TXC path): (1) if it links at 1000M but still no lease, sweep the RGMII delays (`MM_YT8531_{RX,TX}_DELAY`) for this board's trace lengths; (2) for 100M, add a decisive speed-readback diagnostic (`ETH_CMD_G_SPEED` before/after the resync + raise the esp_eth log level) to confirm whether `set_speed` runs; (3) if the in-place `link_status`-reset poke can't trigger `set_speed`, fall back to the stop/start bounce and make it deterministic by widening the S31 eth-DHCP cascade window (> 15 s) and suppressing the double `applyHostname`. + +**The failure is data-plane TX corruption, not a DHCP-handshake quirk (bench-confirmed 2026-07-26 via static IP).** Setting a static IP on the S31 at 100M bypasses DHCP entirely, yet the interface is still unreachable: ARP for the device resolves to the eth MAC (`30:ed:a0:f3:d4:68`) — so a broadcast round-trips — but unicast (ping / HTTP) is 100% dropped. That rules out "only the DHCP exchange is broken" and pins it to garbled unicast frames on the 100M Tx path (the TXC issue). So static addressing is NOT a workaround for 100M; the TXC fix (or a gigabit link) is genuinely required. **This is a bug to FIX, never a reason to weaken the AP → STA → ETH promotion cascade** — Ethernet always outranks WiFi when a cable is present, unconditionally. Interim behaviour on the S31 at 100M with a cable in Static mode: it promotes to Ethernet (as it must) but can't pass traffic; the user recovers by unplugging the cable (link-down cascades back to WiFi). Acceptable only as a temporary state for this one board's open bug, not a design. + +**Related symptom — the "Ethernet detected: no address assigned" degraded warning is intermittent on the S31.** In DHCP mode, that warning is meant to appear when the eth link is up but leaseless past the 15 s window (`NetworkModule::ConnectedSta`). On the S31 at 100M it shows only *sometimes*, because the marginal 100M link *flaps*: each `ETHERNET_EVENT_DISCONNECTED` makes `tick1s()` reset `ethLinkUpAt_` (the degraded clock), so the 15 s threshold is often never reached. This is the same root cause (a bad 100M physical link), so it resolves when the TXC/link issue is fixed. Two robustness follow-ups if it's ever decoupled: (a) don't reset the degraded clock on a *brief* link blip (debounce link-down), and (b) note that a lower `check_link_period_ms` makes the flapping more visible — the default 2000 ms is deliberately kept (a 500 ms poll surfaced the flaps and suppressed the warning entirely). + +## Flaky unit tests: the AudioService sync suite contends on a fixed UDP port + +**Found:** 2026-07-27, caught by `premerge.py`; pinned to the exact cases by looping the suite and keeping the failing logs. Fails roughly **1 run in 10**. + +Two cases in `test/unit/core/unit_AudioService_sync.cpp` fail intermittently, both around the socket rather than the audio logic: + +- `:125` *"…drives frame_, then holds it and reports listening"* — `CHECK(strcmp(status(a), "listening") == 0)`. The receive path **binds** the fixed port `kTestSyncPort` (21988); when a previous run's socket is still in `TIME_WAIT` the bind fails and the service never reaches "listening". +- `:95` *"Local+send: lazy-opens once and reports sending"* — `CHECK(strcmp(status(a), "sending") == 0)` plus `syncOpenForTest()`. This one **connects** to a broadcast address rather than binding, so it is a *different* failure mode on the same suite. + +**An attempted fix that did not hold (recorded so it is not retried blind).** Deriving the port per run (from the steady clock) instead of the fixed constant cut the receive-path failures but left the send-path case failing ~3 in 20 — because that case never binds the test port, so the port was never its problem. The real fix has to address the send path's socket open on its own terms, not just port choice. + +**Why it matters:** the gate scripts run the suite on every commit and merge, so a 1-in-10 flake interrupts gate lists often enough to train the reader to re-run instead of investigate — the habit that hides a real regression later. + +**Fix (do not paper over it):** treat the two cases separately. For the receive path, bind an **ephemeral** port (bind 0, read the assigned port back) so two runs can never contend. For the send path, first determine *why* the lazy open fails under repetition — whether the broadcast connect is refused or the latch is left set by an earlier case in the same process. A retry wrapper or a sleep is explicitly the wrong fix: it hides the next genuine failure too. `unit_NetworkReceiveEffect*` and `unit_SineEffect` share the fixed-port shape and deserve the same treatment while the fix is fresh. + +## ModuleFactory: registration can fail silently (uint8_t ceiling, unchecked bool) + +**Found:** 2026-07-27, while adding the effects grid sweep; flagged by the 👾 Reviewer. + +`ModuleFactory::registerType` returns `bool` and **appends unconditionally** — it does not dedupe by name, so the same type registered from several places takes several slots (in the test binary `Layer` holds 9 and `RainbowEffect` 8). `count_` is a `uint8_t`, and at the 255 ceiling `grow()` returns false, registration fails, and the return value is discarded at every call site — including the static-init registrars, which cannot check it anyway. The failure is silent: the type is simply absent from `/api/types` and `create()` answers `nullptr` for it, far from the cause. + +Production is not near the ceiling today (~90 types), so this is not urgent. It became visible because a test binary that registers repeatedly can reach it, which is what made the grid sweep construct effects directly instead of registering them. + +**Options, cheapest first:** (a) make `registerType` idempotent — ignore a duplicate name and return true, which fixes the multiplier and is arguably the correct semantic anyway; (b) widen `count_`/`capacity_` to `uint16_t` (a handful of bytes, removes the ceiling as a practical concern); (c) make a failed registration loud in a debug build (assert or a boot-time log) so it can never be silent again. (a) plus (c) is probably the right pair. diff --git a/docs/backlog/backlog-light.md b/docs/backlog/backlog-light.md index 8730a79f..b28f6e74 100644 --- a/docs/backlog/backlog-light.md +++ b/docs/backlog/backlog-light.md @@ -70,7 +70,7 @@ The `I80LedDriver` (classic-ESP32 I2S + S3/P4 LCD_CAM, both via the i80 bus) and ### Multicore Step 2b — ping-pong second output buffer (deferred, workload-gated) -The shipped render↔encode split (Step 2a, `multicore` control) uses one `Drivers::outputBuffer_`, so the composite is serialized at the boundary. A **second** output buffer would overlap even that (core 0 composites into B while core 1 encodes A), at the cost of one full frame buffer (~48 KB at 16K lights). The read-only `stall` KPI was built as the trigger metric, and it splits cleanly: a **heavy effect** (render-bound) shows `stall` ~1 µs (S3) / ~15 µs (classic) — 2b recovers *nothing*, core 0 already fills the encode window; a **light effect on many lights** (output-bound — a solid colour or slow gradient across 16K lights) shows `stall` 6–11 ms — 2b recovers that whole wait. So it's a real but narrow win: **gated on the product owner naming that light-effect/many-lights workload as worth the 48 KB**, not on a generic fps gain. (Design context in the shipped Plan-20260713 - Multicore Step 2.) +The shipped render↔encode split (Step 2a, `multicore` control) uses one `Drivers::outputBuffer_`, so the composite is serialized at the boundary. A **second** output buffer would overlap even that (core 0 composites into B while core 1 encodes A), at the cost of one full frame buffer (~48 KB at 16K lights). The read-only `stall` KPI was built as the trigger metric, and it splits cleanly: a **heavy effect** (render-bound) shows `stall` ~1 µs (S3) / ~15 µs (classic) — 2b recovers *nothing*, core 0 already fills the encode window; a **light effect on many lights** (output-bound — a solid color or slow gradient across 16K lights) shows `stall` 6–11 ms — 2b recovers that whole wait. So it's a real but narrow win: **gated on the product owner naming that light-effect/many-lights workload as worth the 48 KB**, not on a generic fps gain. (Design context in the shipped Plan-20260713 - Multicore Step 2.) ### Frame pacing — decided against (record) @@ -179,17 +179,17 @@ Only **NoiseEffect**, **PlasmaEffect** and **RipplesEffect** have z-aware math. ### Full-density interpolated preview for large layouts (backlog) -The preview index-downsamples a large layout to fit the WS send budget (e.g. 128×128 = 16384 lights → ~1639 sent at stride 10), so the UI shows a sparse sample, not every light. To show **all** lights at their real positions with **interpolated** colours for the unsent ones: +The preview index-downsamples a large layout to fit the WS send budget (e.g. 128×128 = 16384 lights → ~1639 sent at stride 10), so the UI shows a sparse sample, not every light. To show **all** lights at their real positions with **interpolated** colors for the unsent ones: - Decouple the `0x03` coordinate-table density from the per-frame `0x02` stride. Positions are static and sent once, so the table can carry **all** light coordinates (16384 × 3 = ~48 KB one-time — acceptable off the per-frame path, possibly chunked) while the per-frame RGB stays strided to protect ArtNet/the link. -- The browser holds the full position set and, per frame, interpolates each unsent light's colour from its nearest sent neighbours (the sent indices are known from the stride). True positions, guessed colours — better than the removed dense-box block-replicate because positions are exact. +- The browser holds the full position set and, per frame, interpolates each unsent light's color from its nearest sent neighbours (the sent indices are known from the stride). True positions, guessed colors — better than the removed dense-box block-replicate because positions are exact. - Open questions: 48 KB one-time table vs `MAX_WRITE_CHUNKS` / send-buffer (needs chunked send or a raised cap, with the same partial-write care as `writeChunks`' drain); interpolation cost on a 16384-point cloud each frame in JS; whether nearest-neighbour or weighted is worth it. -Not simple — own planning pass. Until then the preview is a faithful strided *sample* (correct shape/colour/motion, not per-pixel). A cheap interim (point-size scaled by stride to fatten samples into their cells) was tried and reverted as not what's wanted — it filled the volume but didn't add real points. +Not simple — own planning pass. Until then the preview is a faithful strided *sample* (correct shape/color/motion, not per-pixel). A cheap interim (point-size scaled by stride to fatten samples into their cells) was tried and reverted as not what's wanted — it filled the volume but didn't add real points. ### Self-describing preview frame header (mid term) -The preview wire format is a private opcode protocol: `0x02` per-frame channels, `0x03` coordinate table, each a hand-rolled byte layout, and the colour payload is **always RGB** regardless of the buffer's `channelsPerLight`. Every new data kind (RGBW display, beam direction, …) means inventing another opcode and another fixed layout by hand. The minimal fix that stops that sprawl: a small **typed header** — `[type][format][count][stride]` where `format` enumerates `{RGB, RGBW, …}` — so one message kind carries any per-light channel layout and the browser shader reads `format` to interpret the payload. Do it concrete-first, when RGBW *display* (below) is actually wanted, not speculatively. Prereq for both items below. +The preview wire format is a private opcode protocol: `0x02` per-frame channels, `0x03` coordinate table, each a hand-rolled byte layout, and the color payload is **always RGB** regardless of the buffer's `channelsPerLight`. Every new data kind (RGBW display, beam direction, …) means inventing another opcode and another fixed layout by hand. The minimal fix that stops that sprawl: a small **typed header** — `[type][format][count][stride]` where `format` enumerates `{RGB, RGBW, …}` — so one message kind carries any per-light channel layout and the browser shader reads `format` to interpret the payload. Do it concrete-first, when RGBW *display* (below) is actually wanted, not speculatively. Prereq for both items below. ### RGBW preview end-to-end (mid term) @@ -197,7 +197,7 @@ The light `Buffer` already holds `channelsPerLight = 4` (RGBW), and the device o ### Fixture model — moving heads, beams (long term) -Today a "light" is a point at a static coordinate with a colour. A **moving head** is a fixture that emits a *beam* in a direction it controls live (pan + tilt), plus colour, beam-width, etc. — per-light **vector** state, not just colour, and a different draw (a cone/ray, not a disc). The static-positions-`0x03` + colour-`0x02` split can't express "this fixture's beam now points here." The industry-standard model is **DMX/GDTF fixtures**: a fixture has a position *and* a set of typed attributes (color, pan, tilt, beam). The preview becomes a fixture renderer (disc for a pixel, cone for a beam); this is also the "make Preview a general-purpose module, not light-specific" goal. A domain-model change (the fixture/attribute model), not just transport. Plan when moving heads are actually on the bench. +Today a "light" is a point at a static coordinate with a color. A **moving head** is a fixture that emits a *beam* in a direction it controls live (pan + tilt), plus color, beam-width, etc. — per-light **vector** state, not just color, and a different draw (a cone/ray, not a disc). The static-positions-`0x03` + color-`0x02` split can't express "this fixture's beam now points here." The industry-standard model is **DMX/GDTF fixtures**: a fixture has a position *and* a set of typed attributes (color, pan, tilt, beam). The preview becomes a fixture renderer (disc for a pixel, cone for a beam); this is also the "make Preview a general-purpose module, not light-specific" goal. A domain-model change (the fixture/attribute model), not just transport. Plan when moving heads are actually on the bench. **Sub-item — per-emitter targets, and fixing the Yellow/UV RGB-synthesis placeholder.** `Correction::apply()` today synthesizes every non-RGB emitter (White, WarmWhite, Yellow, UV) from RGB via the one `whiteMode` gate. That conflates two different physics: **White/WarmWhite are broadband illumination** with a real achromatic basis (`min(R,G,B)` is a sound approximation — warm vs cold differ only in phosphor CCT a byte value can't express), but **Yellow/Amber (~590 nm) and UV (~400 nm) are saturated/out-of-gamut emitters with no honest RGB pre-image.** Yellow's `min(R,G)` stand-in reads greener than a true amber die AND fires on far too much (any red+green content — yellows, whites, skin tones — muddies the fixture); UV's blue-excess is an eyeball hack. Amber is a *real, common, targetable* emitter (RGBA / RGBAW PARs ship a dedicated ~590 nm die), so the right model is an **effect that targets amber DIRECTLY** (a typed attribute), not RGB-and-synthesize. The fixture model is where that lands. **Decision to make when built:** whether Yellow/UV should default to **off** (0, like `whiteMode::None`) until an effect drives them — more honest than always firing a wrong approximation — vs. keeping the "light it up to eyeball the wiring" placeholder. **Practical test to run then:** on a real RGBA/RGBAW+UV PAR, compare the synthesized amber against a directly-driven amber die (does `min(R,G)` look acceptably yellow, or muddy?), and confirm the White subtraction under `Accurate` reads correct with all emitters present. Rationale + the current formulas live in `Correction.h`'s emitter-field comment. diff --git a/docs/backlog/livescripts-analysis-bottom-up.md b/docs/backlog/livescripts-analysis-bottom-up.md index cf1acd4e..257dfe03 100644 --- a/docs/backlog/livescripts-analysis-bottom-up.md +++ b/docs/backlog/livescripts-analysis-bottom-up.md @@ -90,7 +90,7 @@ This binding is a **flat C-pointer registry** — exactly what projectMM must *r C-like, LED-oriented. From `sc_examples/*.sc` + README: -- **Types**: `int`/`s_int` (32/16-bit), `uint8_t..uint32_t`, `float`, `char`, `bool`, plus **`CRGB`/`CRGBW`** (LED colour) as first-class; user `struct`s with fields, methods, constructors; multi-dimensional arrays (`int g[z][y][x]`). +- **Types**: `int`/`s_int` (32/16-bit), `uint8_t..uint32_t`, `float`, `char`, `bool`, plus **`CRGB`/`CRGBW`** (LED color) as first-class; user `struct`s with fields, methods, constructors; multi-dimensional arrays (`int g[z][y][x]`). - **Control flow**: `if/else`, ternary, `while`, C-style `for`, `break`/`continue`, `return`; recursion (`sc_examples/fibonacci.sc`). - **Built-ins**: `printf`/`printfln` (int only), `millis()`, `rand`/`copy`/`memset`/`fill` (inline-asm in `functionlib.h`), `hsv()` and FastLED math when `USE_FASTLED` is set. - **Escape hatch**: `__ASM__ … @` blocks for hand-written Xtensa. @@ -226,7 +226,7 @@ Decisions from the design discussion that produced this survey. These are *direc 5. **Controls = minimal ceremony.** A scripted control is a near-plain top-level variable (e.g. `uint8_t speed = 60;` with a range annotation); the engine derives the MoonModule control + UI + persistence. Lighter than today's explicit `controls_.addUint8(...)`, copy-paste-friendly. (Exact annotation syntax is the top-down's call.) 6. **Safety = staged, climb the tiers, don't pay upfront.** Ship the **cheap** tier first — array **bounds-checking** (a compare-branch per indexed access, low single-digit %, removable in a trusted/fast mode) + **watchdog / instruction budget** (kill a runaway loop, near-free). The **expensive** true-memory-sandbox tier (a script physically can't touch memory outside its arena — what WASM gives free, native can't cheaply) is **deferred**, reachable via the IR→WASM fallback only if a public script editor in the field shows the cheap tier isn't enough. Decided this way because the price of full sandboxing upfront isn't worth paying before evidence demands it. 7. **MoonModule-first.** A scripted module **is** a MoonModule (role, controls, `loop()`, generic UI, lifecycle, robustness, live-reconfig). The script ⇄ MoonModule binding (reach the `Buffer`/`AudioFrame`/LUT via the producer/consumer pull pattern, no copy) is the projectMM value-add to design — no prior art copies cleanly. -8. **General in core + specific in light.** One engine serves a domain-neutral core script (e.g. transform sensor data) *and* a scripted layout / effect / modifier / driver. **Effect is the first role.** `RipplesEffect.h` is the *reference* effect for the language design (it stresses float trig + 3D + memset), but it is **too complex for the hello-world spike** — the first running script must be trivial (e.g. fill the buffer one colour, or a single moving dot), proving the engine end-to-end before any real effect. Ripples is the *graduation* target, not the spike. For how an effect is structured for a newcomer, the [MoonLight effects tutorial](https://moonmodules.org/MoonLight/moonlight/effects-tutorial/) is a good read (a sibling project's step-by-step). The simple→Ripples progression is itself the start-small-grow staging applied to the demo. +8. **General in core + specific in light.** One engine serves a domain-neutral core script (e.g. transform sensor data) *and* a scripted layout / effect / modifier / driver. **Effect is the first role.** `RipplesEffect.h` is the *reference* effect for the language design (it stresses float trig + 3D + memset), but it is **too complex for the hello-world spike** — the first running script must be trivial (e.g. fill the buffer one color, or a single moving dot), proving the engine end-to-end before any real effect. Ripples is the *graduation* target, not the spike. For how an effect is structured for a newcomer, the [MoonLight effects tutorial](https://moonmodules.org/MoonLight/moonlight/effects-tutorial/) is a good read (a sibling project's step-by-step). The simple→Ripples progression is itself the start-small-grow staging applied to the demo. 9. **Infinitely scalable.** Run *as many* live scripts concurrently as memory allows, exploiting PSRAM — each script is an independent compiled unit, the ceiling is free heap, not a fixed slot count. Many small scripted modules coexist; the device hosts what fits and degrades gracefully when it doesn't (the same scaling-to-available-memory contract the light pipeline already honours). 10. **Inline execution by default; task is the exception.** A scripted effect/layout/modifier/driver runs *inline in the `Scheduler` tick*, called exactly like a compiled effect's `loop()` — one mental model, no cross-thread sync to reach the buffer/`AudioFrame`, and it runs on the render task's *internal-RAM* stack (fast). Task-per-script isn't blocked on memory (a task stack can live in PSRAM), but it pays two costs inline doesn't: per-task **scheduling overhead** (a context switch per task per frame — hundreds of tasks thrash the scheduler, a ceiling independent of memory), and a **PSRAM-backed stack is hot-path-slow** (PSRAM latency on every per-pixel local access, ~12 vs ~80 MB/s). So inline keeps the per-script stack fast and free, and PSRAM is spent on script *code + data* (decision 9) rather than per-script stacks. A pinned task is the narrow, documented opt-in *only* for a long/blocking *core* script (e.g. slow sensor I/O) that must not share the render tick — never the default, never for a pipeline script. 11. **Sequencing: hybrid (depth-first to hello-world, then prove the seam on a 2nd ISA early).** Build the full vertical slice on Xtensa just far enough to run hello-world native (classic/S3), then *immediately* prove a minimal second-ISA backend (P4/RISC-V, or desktop x86-64) on that same slice — before deepening to controls/math/2D/3D. This retires the project's biggest risk (does the IR seam genuinely decouple front-end from backend?) at hello-world cost, when fixing it is cheap, rather than discovering a leak after six stages. Then deepen, primarily on Xtensa; the full second backend follows later. diff --git a/docs/backlog/livescripts-analysis-top-down.md b/docs/backlog/livescripts-analysis-top-down.md index a633af3f..4aadb4b0 100644 --- a/docs/backlog/livescripts-analysis-top-down.md +++ b/docs/backlog/livescripts-analysis-top-down.md @@ -188,7 +188,7 @@ Memory placement routes through the existing `platform::` seam, so it's one poli **Memory-scaling status + the one refactor ahead (watch-item as the language grows).** A memory review at the 3-builtin stage (`setRGB`/`fill`/`random16`) pinned where the per-effect cost lives and what scales with language richness — recorded here so the optimisation is planned, not discovered: - **Already in place (don't redo):** the exec block is allocated at the *emitted length* (word-rounded), not a worst-case cap — a `fill` is ~50–70 B of native code, a four-call `setRGB` ~400 B, so per-effect heap scales with the script, not a flat reservation. And the effect reports `setDynamicBytes(codeLen())`, so the UI card's "+ dynamic" reflects the JIT'd program (0 when a compile fails and frees it). These two are the right shape for everything below. -- **Flat regardless of language size:** the engine's at-rest members (~48 B/instance) and each `Builtin` descriptor (32 B). A full math/colour library is ~30–50 builtins → a ~1.5 KB table that should become a **`static const` built once** (today `lightBuiltins()` returns a ~520 B table by value per `onBuildState` — fine at 3 entries, wasteful at 50; make it static when the library grows). +- **Flat regardless of language size:** the engine's at-rest members (~48 B/instance) and each `Builtin` descriptor (32 B). A full math/color library is ~30–50 builtins → a ~1.5 KB table that should become a **`static const` built once** (today `lightBuiltins()` returns a ~520 B table by value per `onBuildState` — fine at 3 entries, wasteful at 50; make it static when the library grows). - **Scales with script complexity (the exec block, heap, per effect):** today's one-liners are 50–400 B; a Ripples-class effect (per-pixel `sqrt`/`sin`, 3D, a fade loop, two controls) is a few hundred instructions → an estimated **1.5–4 KB of native code**, comparable to the equivalent compiled effect (the point of native). PSRAM-first `allocExec` (§ above) absorbs this. - **The one architectural change ahead — transient compile buffers must move stack → reusable heap arena.** Today `compile()` puts the staging buffer (`kCodeCap`), the `IrProgram` (`kMaxIrOps=64` ops × 32 B ≈ 2 KB), and the assembler buffer on the **stack** (~4 KB peak, off the hot path, in `onBuildState`). This is fine for the current grammar but **will not hold once scripts have loops + real expressions**: a Ripples body easily exceeds 64 IR ops, and growing `kMaxIrOps` to 256–512 makes the IR alone 8–16 KB — too large for an MCU task stack. The planned fix (matches "code+data arenas" above): a **single heap arena the compiler borrows during `onBuildState` and releases**, sized once and reused across recompiles, instead of stack arrays. Introduce it **when the op count first outgrows the stack-safe range — likely Stage 3 (math + loops)**, not before; it's a contained change (the compiler's buffers, not the IR or front-end) and the `allocExec(len)` direction already set the precedent. - **New at full language — a per-script *data* arena.** Scripts are stateless pure functions today (no per-instance data beyond the source text). Once a script declares controls (`uint8_t speed = 50;`) or state (`static float lut[256];`), each effect needs a **data arena** (`platform::alloc`, PSRAM-first) alongside its code block — a few hundred bytes to a few KB per effect, sized per script. This is the second arena §3.7 already names; it lands with Stage 1 (controls) and grows through the oscillator/LUT stages. @@ -354,7 +354,7 @@ The frame budget at 50 FPS is **20 ms/tick** for everything (render + drivers + **Don't** (in the generated code / the `run()` path): - No heap allocation during `run()` — the script's data arena is allocated at compile/bind, reused every tick. - No per-pixel function-call dispatch into the host for the common writers — `setRGBXY` lowers to inline loads/stores against the buffer, not a `call`. -- No soft-float where the FPU exists — emit FPU ops on S3/P4; integer-preferred for per-light colour work (the project rule), float only for the wavefront math, exactly as the compiled effects do. +- No soft-float where the FPU exists — emit FPU ops on S3/P4; integer-preferred for per-light color work (the project rule), float only for the wavefront math, exactly as the compiled effects do. - No blocking — a script can't `delay`; a runaway loop hits the instruction budget. **Do:** diff --git a/docs/backlog/moonlight-effect-inventory.md b/docs/backlog/moonlight-effect-inventory.md index 7a34c197..3cfa40ab 100644 --- a/docs/backlog/moonlight-effect-inventory.md +++ b/docs/backlog/moonlight-effect-inventory.md @@ -8,7 +8,7 @@ The full set of MoonLight effects to migrate, grouped by **origin library** (a * | Effect | Markers | Status | Notes | |---|---|---|---| -| Solid | | ⬜ | background/base colour | +| Solid | | ⬜ | background/base color | | Lines | | ✅ | LinesEffect | | Frequency Saws | ♫ | ⬜ | audio (Stage 3d) | | Moon Man | | ⬜ | | diff --git a/docs/backlog/moonlight-improvements.md b/docs/backlog/moonlight-improvements.md index 592d82a9..dbb662ab 100644 --- a/docs/backlog/moonlight-improvements.md +++ b/docs/backlog/moonlight-improvements.md @@ -20,7 +20,7 @@ they diverge from MoonLight — register each here. | **SphereMove** (motion smoothness) | `time_interval` did the first divide as **integer** (`ms/(100-speed)`), so the origin/diameter only advanced when that ticked to a new whole number — visible stutter (~20 updates/s at 60 FPS, worse at low speed) | Full expression in float, so the shell sweeps and breathes smoothly every frame | Smooth motion at all speeds instead of discrete jumps. (MoonLight *intended* float here, so this is also more faithful.) | | **Lissajous** (thin grids) | A 1-wide or 1-tall grid mapped every sample to coordinate **1**, which clips (only index 0 is valid) → blank | The size-1 axis maps to coordinate **0**, so the figure draws | Produces visible output on degenerate/thin grids instead of nothing; normal grids (both axes ≥2) unchanged. | | **PaintBrush** (large grids) | Oscillator endpoints truncated into `uint8_t`, so on grids >256 per axis strokes swept only a small low corner; the top band/hue were unreachable (off-by-one) | Oscillators generated 0..255 then scaled to the full grid; loop uses `numLines-1` as the map high bound | Strokes span the full grid at any size and use the full palette/band range. ≤256-per-axis grids: identical pixels. | -| **FixedRectangle** (RGBW white channel) | On RGBW with `alternateWhite`, the **W channel was written on every box cell** (tinting coloured tiles), and left stale when `white==0` | W follows the checker: only white tiles carry `W=white`; coloured tiles clear W to 0 | Coloured tiles render as pure RGB instead of RGB+white; no stale W persists. The checker actually alternates colour vs white. | +| **FixedRectangle** (RGBW white channel) | On RGBW with `alternateWhite`, the **W channel was written on every box cell** (tinting colored tiles), and left stale when `white==0` | W follows the checker: only white tiles carry `W=white`; colored tiles clear W to 0 | Colored tiles render as pure RGB instead of RGB+white; no stale W persists. The checker actually alternates color vs white. | | **GEQ3D** (projector sweep cadence) | The sweep advanced by a per-frame counter (`counter++ % (11-speed)`), so its speed tracked the device frame rate — faster on a 280 FPS board than a 30 FPS one at the same `speed` | Time-based triangle wave (`triwave8(beat8(speed·3, elapsed()))`), so the projector is at the same wall-clock position on every device | The `speed` control means the same thing on every device (frame-rate-independent, the projectMM convention). A fast board renders the same sweep more smoothly, a slow one choppier — neither is throttled to the other. Once-per-frame calc, no per-pixel cost. | | **GEQ3D** (narrow-grid bars) | Bar width is `cols / NUM_BANDS`, which truncates to 0 when `cols < numBands` (e.g. 8 cols, 16 bands) → every bar collapses to x=0 | The drawn band count is clamped to the column count, so bar width stays ≥ 1 and bars spread across the available width | Bars render on grids narrower than the band count instead of piling at x=0. Invisible on normal grids (cols ≥ numBands → the clamp is a no-op). | | **AudioFrame** (raw + smoothed level) | WLED exposes both `volumeRaw` (instant) and `volume`/`volumeSmth` (smoothed); a straight port only had our raw `level` | Added `levelSmoothed` (an EMA of `level`); each audio effect reads the value matching its intent — NoiseMeter uses raw `level` (VU snaps to beats), FreqMatrix / AudioSpectrum VU bar / AudioVolume use `levelSmoothed` (breathing/flowing) | Effects that should glide with the music no longer jitter per audio block, and beat-reactive ones stay snappy — the full WLED raw/smoothed pair instead of one value forced everywhere. | diff --git a/docs/backlog/virtual-layer-downscale-study.md b/docs/backlog/virtual-layer-downscale-study.md index 21f93d90..bb95e75c 100644 --- a/docs/backlog/virtual-layer-downscale-study.md +++ b/docs/backlog/virtual-layer-downscale-study.md @@ -9,7 +9,7 @@ Run an effect authored for **many** pixels — a virtual 32×32 grid — and **summarize** it onto a **small** number of physical lights: Philips Hue bulbs, or a handful of PAR/DMX fixtures. Each -physical light shows the **average colour of its region of the virtual frame, ignoring black +physical light shows the **average color of its region of the virtual frame, ignoring black pixels**, so a fire / plasma / GEQ effect reads as a coherent ambient wash across N bulbs instead of N arbitrary single-pixel samples. @@ -29,7 +29,7 @@ The request began as "make a modifier." Two verification steps ruled that out: walks destination pixels and asks the modifier for **one** source coordinate, then copies **one** pixel (`Layer::applyLivePass`, Layer.h:~218). Pooling *combines* many source pixels into one output value (sum + count, skip black, divide) — a computation a coordinate remap structurally can't - express (it returns a coordinate, not a colour). + express (it returns a coordinate, not a color). 2. **Not a modifier at all — the real blocker is render size.** A Layer's logical (render) box **starts at the physical light count** and modifiers only ever *shrink* it (`Layer::rebuildLUT`, diff --git a/docs/building.md b/docs/building.md index 4a794c95..ce1043a7 100644 --- a/docs/building.md +++ b/docs/building.md @@ -13,6 +13,8 @@ The scripts have two front ends with the same code and arguments: Use whichever fits. Neither path is "more official" than the other; the scripts are the source of truth and the front ends are interfaces. New work adds a script first; both interfaces follow. +**Why our own scripts, not PlatformIO:** the ESP32 build is ESP-IDF-native — projectMM tracks IDF pre-releases against a pinned commit for chips like the P4 and S31, a level of version control PlatformIO's packaged platforms don't offer, and the hot-path drivers (LCD_CAM, Parlio, GDMA) use the vendor APIs first-class rather than through an Arduino-core abstraction. The tooling surface is also far wider than compile-upload-monitor: desktop builds, unit and scenario runs, spec and boundary checks, KPI collection, the web installer, provisioning, multi-board bench orchestration. A wrapper toolchain would cover one of those tasks and still need all the scripts around it; one script per task, two front ends, keeps humans, agents, and CI on the identical path. + MoonDeck has three tabs: - **Desktop** — build, run, test. Fast iteration. @@ -257,7 +259,7 @@ The platform abstraction layer replaces what libraries typically provide. Today | Library | Why not | What replaces it | |---|---|---| -| [FastLED](https://github.com/FastLED/FastLED) | Arduino-dependent. LED protocol drivers (RMT, SPI) are available natively in ESP-IDF; FastLED's colour math is small enough to reimplement. | Own colour math in core. Own LED drivers per platform in `src/platform/`. | +| [FastLED](https://github.com/FastLED/FastLED) | Arduino-dependent. LED protocol drivers (RMT, SPI) are available natively in ESP-IDF; FastLED's color math is small enough to reimplement. | Own color math in core. Own LED drivers per platform in `src/platform/`. | | [ESPAsyncWebServer](https://github.com/ESP32Async/ESPAsyncWebServer) | Arduino-dependent. Past memory-leak issues. Ties us to Arduino. | Own HTTP server via ESP-IDF's `esp_http_server` (ESP32) or BSD sockets (desktop). Reconsider if Arduino-as-component is added. | | [ArduinoJson](https://github.com/bblanchon/ArduinoJson) | Works on ESP-IDF, but heavy: dynamic allocation, large footprint. | Own fixed-size control storage. JSON only for API serialisation, not internal state. | @@ -284,4 +286,4 @@ Both are defined in the root `CMakeLists.txt` (desktop) and `esp32/main/CMakeLis The system serves the web UI from its embedded HTTP server. Open `http://<device-ip>/` in a browser; on desktop that's typically `http://localhost:8080/`. From the UI you can change effects and modifiers, configure controls, see the 3D preview. The settings persist across reboot. -To run the test suite or any of the checks (platform boundary, specs, KPI), see the MoonDeck reference linked above. The release-readiness gates that wrap these into a checklist live in [CLAUDE.md § Lifecycle Events](../CLAUDE.md#lifecycle-events). +To run the test suite or any of the checks (platform boundary, specs, KPI), see the MoonDeck reference linked above. The release-readiness gates that wrap these into a checklist live in [CLAUDE.md § The Process](../CLAUDE.md#the-process). diff --git a/docs/coding-standards.md b/docs/coding-standards.md index 453579e3..7242e0f1 100644 --- a/docs/coding-standards.md +++ b/docs/coding-standards.md @@ -15,7 +15,12 @@ Decided once; not re-derived per file. - **No hard line wraps in markdown.** Let the editor soft-wrap. Hard wraps make diffs noisier than they need to be. - **No em-dashes (` — `) in prose.** Use a comma, semicolon, colon, parentheses, or a full stop instead, whichever the clause actually calls for. Applies to docs, comments, and commit messages. (Code is exempt: a literal `—` in a UI string or test fixture stays.) Existing em-dashes get replaced as files are touched, not in a single sweep. - **A conditional control is registered BELOW the control it depends on.** When a control's visibility (or range, or presence) is gated on another control's value, `addX()` it *after* that control in `defineDriverControls()`/`defineControls()`, so registration order matches the dependency: the driving control first, the dependent one under it. This makes the UI read top-down as cause then effect (toggle `pinExpander` on, the `latchPin` that depends on it appears just below), and it keeps the source order self-documenting: a reader sees the gate, then the thing it gates. Controls that are mutually exclusive (never visible together) still follow the rule by their shared gate, grouped after it. -- **American English spelling, everywhere.** Code identifiers, JSON/wire keys, comments, docs, and UI strings all use US spelling: `color` (not `colour`), `serialize`, `optimize`, `initialize`, `normalize`, `behavior`, `center`, `gray`, `canceled`, `analyze`. Two reasons: (1) it's the dominant technical-project convention (LLVM, Chromium, the Linux kernel are American throughout), and (2) the graphics/LED ecosystem we interop with is uniformly American (`CRGB`, `color`, `colorFromPalette`, CSS `color`), so a British identifier fights the whole toolchain. The real hazard mixing creates: a grep for `color` silently misses `colour`, and a wire key that drifts between dialects (`"colour"` vs `"color"`) breaks a cross-device contract without a compile error. So one dialect, chosen to match the ecosystem. Watch-outs: `analysis` is already US (keep it; only `analyse`→`analyze`); a proper noun keeps its own spelling (`Travelrouter`, a product name). Existing British spellings in older prose get converted **opportunistically when a file is touched**, not in one big sweep (same as the em-dash rule above); a code identifier or wire key in the wrong dialect is the higher-priority fix, since it's a correctness hazard, not just style. +- **American English spelling, everywhere.** Code identifiers, JSON/wire keys, comments, docs, and UI strings all use US spelling: `color` (not the British form), `serialize`, `optimize`, `initialize`, `normalize`, `behavior`, `center`, `gray`, `canceled`, `analyze`. Two reasons: (1) it's the dominant technical-project convention (LLVM, Chromium, the Linux kernel are American throughout), and (2) the graphics/LED ecosystem we interop with is uniformly American (`CRGB`, `color`, `colorFromPalette`, CSS `color`), so a British identifier fights the whole toolchain. The real hazard mixing creates: a grep for one dialect silently misses the other, and a wire key that drifts between dialects breaks a cross-device contract without a compile error. So one dialect, chosen to match the ecosystem. Watch-outs: `analysis` is already US (keep it; only `analyse`→`analyze`); a proper noun keeps its own spelling (`Travelrouter`, a product name). Existing British spellings in older prose get converted **opportunistically when a file is touched**, not in one big sweep (same as the em-dash rule above); a code identifier or wire key in the wrong dialect is the higher-priority fix, since it's a correctness hazard, not just style. +- **All Python through `uv run`.** Never bare `python`/`python3`: not in shell commands, not in CMake, not in docs. uv manages the project venv and is the project standard ([moondeck/MoonDeck.md](../moondeck/MoonDeck.md)); bare `python3` isn't on PATH on Windows, and the macOS Python launcher pops a Store prompt. In CMake, resolve `find_program(UV_EXECUTABLE NAMES uv REQUIRED HINTS "$ENV{USERPROFILE}/.local/bin" "$ENV{HOME}/.local/bin")` once and use `${UV_EXECUTABLE} run python …` thereafter; the shared `src/ui/embed_ui.cmake` takes a `PYTHON_CMD` parameter (desktop passes uv; ESP32 passes IDF's Python). The one exception is `esp32/main/CMakeLists.txt`: ESP-IDF builds use IDF's own bundled Python venv via `find_package(Python3)`, since IDF manages that environment itself. +- **Consider extending before creating.** When adding a feature, check whether an existing module extends cleanly; a new file is fine if genuinely cleaner, but justify it. +- **Do not remove comments** unless they are outdated or factually wrong. Comments document intent and context; removing them silently loses knowledge. +- **Reference, don't copy.** Prior art (friend repos, datasheets, our own prototype branches) holds proven approaches: study it, take the ideas, write our own code, never copy or trace the structure. Credits live in the history digests and per-module prior-art sections. +- **Present-tense litmus.** "There is no MCLK pin" states a property (keep); "no X anymore" narrates a removal (cut it; describe the path that exists). ## Prefer integers, store values in their native shape @@ -108,23 +113,49 @@ All targets build warnings-as-errors: `-Wall -Wextra -Werror` on Clang/GCC (macO - In a comparison, keep both sides the same signedness — don't mix a signed expression with an unsigned literal (`== 1`, not `== 1u`, when the other side is signed). Watch `& `, `%`, and subtraction results, which carry the signedness of their operands. - A change that only built+passed on macOS/Linux is **not** verified for Windows. The Windows CI job (`release.yml`) is the real gate for MSVC-only warnings; let it run before considering a `src/`-touching change done, or build with MSVC locally if you have it. +## Editor setup (clangd) + +Diagnostics appear **as you type**, from the same [`.clang-tidy`](../.clang-tidy) config CI +uses — so a finding shows up while the code is still in your head, not ten minutes later in a +pipeline. + +Once per machine: install the **clangd** extension (`llvm-vs-code-extensions.vscode-clangd`) +and **disable Microsoft's C/C++ IntelliSense** — running both produces duplicated and +contradictory diagnostics. Nothing else to configure: [`.clangd`](../.clangd) at the repo root +points at the compilation database, and `CMAKE_EXPORT_COMPILE_COMMANDS` (set in +`CMakeLists.txt`) means any normal build refreshes it. + +Two things worth knowing: + +- **If every file reports `'cstdint' file not found`**, the build directory was configured + with a different compiler than clangd is. `.clangd`'s `--query-driver` handles the usual + cases; if a new toolchain appears, add it there. This failure is loud and total — real + diagnostics disappear behind it — so it is worth recognising on sight. +- **clangd runs a subset of the CI check set**, skipping checks it considers slow (>10% + AST-build cost). That is deliberate and means the same config file is safe to share: CI + remains the authority. + ## Static checks - **Platform boundary** (`moondeck/check/check_platform_boundary.py`) — scans all files outside `src/platform/` for `#ifdef` / `#if defined` with platform macros and `#include` of platform-specific headers (`esp_*`, `freertos/*`, `driver/*`, `SDL.h`, `wiringPi.h`, …). Fails if any are found. The platform boundary rule itself: [architecture.md § Platform abstraction](architecture.md#platform-abstraction). -- **Hot path lint** — flags allocation calls (`new`, `malloc`, `make_unique`, `make_shared`, `push_back`, `std::string` constructors) inside functions identified as hot path (render loop and callees). A code-review convention, enforced by hand. The hot path rule itself: [architecture.md § Hot path discipline](architecture.md#hot-path-discipline). A "no blocking in the hot path" audit must sweep *every* syscall the path can reach (connect, DNS, read, *and* write), not just the loudest one: a single-threaded loop that services I/O must make no blocking call at all (a socket timeout is not a fix, it is the size of the freeze; non-blocking + poll is the only safe shape). Fixing one blocker while an equally-blocking sibling survives is a partial fix that reads as complete. +- **Hot path lint** (`moondeck/check/check_hotpath.py`) — reads the body of every `tick()` / `tick20ms()` / `tick1s()` under `src/` and flags the allocation (`new`, `malloc`, `push_back`, `std::string`, `make_unique`, `make_shared`) and blocking (`delay`, `sleep`, `mutex.lock()`) it can see. A lint, not a proof: it cannot see what a callee allocates, so a clean run means "nothing visible in the render path's own source". A justified exception is marked `// hot-path-ok: <reason>` at the line, so the reason lives at the site. The hot path rule itself: [architecture.md § Hot path discipline](architecture.md#hot-path-discipline). A "no blocking in the hot path" audit must sweep *every* syscall the path can reach (connect, DNS, read, *and* write), not just the loudest one: a single-threaded loop that services I/O must make no blocking call at all (a socket timeout is not a fix, it is the size of the freeze; non-blocking + poll is the only safe shape). Fixing one blocker while an equally-blocking sibling survives is a partial fix that reads as complete. - **Code formatting** — `clang-format` with a project `.clang-format` file. Applied in CI; code that doesn't match fails the check. Run locally via editor integration or `clang-format -i`. ## When checks run -- **Pre-commit (selective).** Build, unit tests, scenario tests, platform-boundary check, spec check, ESP32 build, KPI capture — each runs if the change makes it applicable. See [CLAUDE.md § Lifecycle Events](../CLAUDE.md#lifecycle-events). -- **Pre-push.** Opus reviewer agent over the push range, scoped to domain boundary, unnecessary abstractions, duplicated patterns, hot-path violations, spec conformance, bloat, platform boundary. -- **PR merge.** Plan reconciliation, documentation sync, live perf snapshot, permission review, README refresh — applicability-gated, see CLAUDE.md. -- **CI** — the same set, mandatory on every PR push. Exact CI configuration is set up when the repository's pipeline lands. +Which checks run at which lifecycle event is defined once, in [CLAUDE.md § The Process](../CLAUDE.md#the-process), and executed by the `moondeck/event/` scripts (`precommit.py`, `premerge.py`, `prerelease.py`) — see [MoonDeck.md](../moondeck/MoonDeck.md#event_precommit--event_premerge--event_prerelease). Each gate carries an objective trigger, so a change runs only the checks it makes applicable. CI runs the same checks on every PR ([.github/workflows/](../.github/workflows/)). + +## Tests + +- **Placement.** New core logic gets a module (unit) test; a full pipeline gets a scenario test. Inventory and strategy: [testing.md](testing.md). +- **Interim fixes.** When a per-module interim ships in place of the named core fix (see [CLAUDE.md § Principles](../CLAUDE.md#principles), Architecture first), its tests assert *behavior*, not the per-module mechanism, so they survive the later move into core unchanged. ## Documentation model Where each kind of fact lives. The guiding rule: **document a thing once, in the place closest to the thing, and generate or link the rest** — a fact the source states is never re-typed in prose. Every module has exactly **two reader surfaces**: a hand-written **summary page** (end-user, a table) and a **generated technical page** (from the `.h`). This is the docs.rs / Sphinx-autodoc / Doxygen split — a hand-written guide over a generated API reference — a pattern a web/systems contributor recognises on sight. +Two corollaries. **Reference a module generically** in prose outside its own home: say "a modifier" or "a driver", never `FooModifier`; a module's one home is its `.h`, its catalog card, its registration, and its tests, and naming it anywhere else multiplies rename cost while teaching nothing a link wouldn't. And the **spec lifecycle**: when a module ships, its final spec lands in `docs/moonmodules/<Name>.md` and any backlog draft is deleted; most small modules skip the draft and go straight there. + ### The tree mirrors `src/` [Everything is a MoonModule](architecture.md#moonmodules); `docs/moonmodules/` mirrors `src/`, a **`core/`** subtree and a **`light/`** subtree. Within each, a module is either **UI** (user-facing and configurable — the System modules like Network/System/FileSystem, the user-added [Services](architecture.md#services), and the light **catalog modules**: layouts, effects, modifiers, drivers) or **supporting** (the machinery UI modules lean on — `Control`, `Scheduler`, `Layer`, `Buffer`, the `*Base` classes). [Services](architecture.md#services) are UI modules that bridge to hardware (a sensor, a network integration). @@ -141,7 +172,7 @@ Where each kind of fact lives. The guiding rule: **document a thing once, in the 2. **Technical page (generated).** `docs/moonmodules/{core,light}/moxygen/<Module>.md`, produced from the `.h` by [`moondeck/docs/gen_api.py`](../moondeck/docs/gen_api.py): **Doxygen** (the de-facto-standard C++ parser) emits XML, **moxygen** renders Markdown through a custom Handlebars template ([`moondeck/docs/moxygen-templates/`](../moondeck/docs/moxygen-templates/)). Each page carries the module's **description, variables, and members** from their `///` comments and links to its `.h`; the summary page's per-module `Detail: [technical]` link points here. Two shaping levers beyond raw moxygen, both to keep the page lean: - **Template** (`class.md`): the base class is a one-line `> **Inherits:** [Base]` link — the full inherited-member list is *not* re-dumped on every subclass (it lives once on the base's own page; re-listing `MoonModule`/`EffectBase`'s large surface everywhere is bloat — *No duplication*). - - **Post-process** in `gen_api.py`: three plain-text directives survive Doxygen's `GENERATE_HTML=NO` XML (which drops `\image`/`@htmlonly`/raw `<img>` and relative links but keeps plain text) and are rendered here — **`@card <file.png>`** → an `<img>` of the module's UI-card screenshot (a missing asset drops the directive, no broken link); **`@moreinfo`** → splits the class description, relocating everything after it to a `## More info` section below the member lists (the deep-dive-at-the-bottom shape); **`@xref{anchor|label}`** → a page-local `[label](#anchor)` link (deliberately not `@ref`, which is a real Doxygen command). A further post-process wraps each member signature's declared **name** in `<code class="mm-sig">…<span class="mm-sig-name">name</span>…</code>` so the theme ([`extra.css`](assets/extra.css)) can highlight the name (accent, bold) while the type and arguments stay muted — moxygen emits a flat `<code>` string with no internal markup, so 'colour only the name' can't be done in CSS alone. Site-wide, `extra.css` also colours `h1`/`h2`/`h3` with the theme's primary/accent (the slate theme otherwise renders every heading the same near-white, flattening the hierarchy). + - **Post-process** in `gen_api.py`: three plain-text directives survive Doxygen's `GENERATE_HTML=NO` XML (which drops `\image`/`@htmlonly`/raw `<img>` and relative links but keeps plain text) and are rendered here — **`@card <file.png>`** → an `<img>` of the module's UI-card screenshot (a missing asset drops the directive, no broken link); **`@moreinfo`** → splits the class description, relocating everything after it to a `## More info` section below the member lists (the deep-dive-at-the-bottom shape); **`@xref{anchor|label}`** → a page-local `[label](#anchor)` link (deliberately not `@ref`, which is a real Doxygen command). A further post-process wraps each member signature's declared **name** in `<code class="mm-sig">…<span class="mm-sig-name">name</span>…</code>` so the theme ([`extra.css`](assets/extra.css)) can highlight the name (accent, bold) while the type and arguments stay muted — moxygen emits a flat `<code>` string with no internal markup, so 'color only the name' can't be done in CSS alone. Site-wide, `extra.css` also colors `h1`/`h2`/`h3` with the theme's primary/accent (the slate theme otherwise renders every heading the same near-white, flattening the hierarchy). The pages are **gitignored, regenerated on build** (flipping to committed-and-drift-gated is a one-line `.gitignore` change plus a gate like `check_firmwares.py`, if PR-review of the generated output ever earns it). Doxygen (a brew/apt binary) and moxygen (via npx) are the one justified non-uv dependency, like ESP-IDF's Python (see CLAUDE.md); absent locally the pages skip and the rest of the site builds, present in CI they render. diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md index cd9d1895..82e51d07 100644 --- a/docs/gettingstarted.md +++ b/docs/gettingstarted.md @@ -58,7 +58,7 @@ chip, and what the device can do (LEDs, WiFi, a button, a microphone…); click ![A device card with its details](assets/gettingstarted/01-05-device-details.png) -The little coloured pills are the device's capabilities, and the colour tells you +The little colored pills are the device's capabilities, and the color tells you how ready each one is: - 🟢 **Green** — set up and working the moment you install. This capability is @@ -143,7 +143,7 @@ Three regions, left to right: option and the lights react instantly. Every module header carries a **⏻ power button** — it turns that module on or off. -Bright (accent-coloured) means on; dimmed means off. A switched-off module simply +Bright (accent-colored) means on; dimmed means off. A switched-off module simply stops running — it stays in place with all its settings, so flicking it back on picks up right where it left off. It's the quick way to mute an effect or an output for a moment without deleting anything. @@ -167,7 +167,7 @@ phone, standing next to your lights: ![The 3D preview, lights numbered](assets/gettingstarted/02-04-UI-Preview.png) Drag to rotate, scroll to zoom. Each dot is one light at its real position, lit -with the colour it's showing this instant. Turn on the numbers to see each light's +with the color it's showing this instant. Turn on the numbers to see each light's index — handy when you're wiring or mapping a layout. The preview is a *view* of the device; it never slows the lights down, and it gracefully eases off (fewer updates, then fewer points) on a slow connection rather than stalling. @@ -257,7 +257,7 @@ and the MQTT broker if you don't have them, is in the ### Building a light show: layouts → layers → drivers The bottom three modules are where the fun is. They form a simple pipeline: a -**layout** says where your lights are, **layers** decide what colours play on +**layout** says where your lights are, **layers** decide what colors play on them, and **drivers** send the result out to the real world. Add modules with the dashed **+ add module** button under each one. @@ -271,14 +271,14 @@ on **serpentine** if your strip zig-zags back and forth. **Layers** — what plays on the lights. Add an **effect** (a moving pattern), stack several to blend them, and reshape them with **modifiers** (mirror, rotate, and -more). Each effect has its own controls — speed, colour mode, and so on — that you +more). Each effect has its own controls — speed, color mode, and so on — that you tweak live. ![The Layers module](assets/gettingstarted/02-09-UI-Layers.png) > [Layers](moonmodules/light/supporting.md) · [Layer](moonmodules/light/supporting.md) -**Drivers** — where the colours go. Set overall **brightness** and colour order, +**Drivers** — where the colors go. Set overall **brightness** and color order, then add an output: real LED strips on a pin, or send the frame over the network (ArtNet, E1.31/sACN, DDP) to other devices or lighting software. diff --git a/docs/history/FastLED-FastLED.md b/docs/history/FastLED-FastLED.md index 33fb2702..bd10cad6 100644 --- a/docs/history/FastLED-FastLED.md +++ b/docs/history/FastLED-FastLED.md @@ -43,7 +43,7 @@ _Auditability: 481 commits with author-date in 2026-06-01..2026-06-30 on `master **New** - New **Channels API** for managing multiple LED drivers at once — a `fl::Bus` type, `FastLED.add<Bus>(...)`, `fl::enableAllDrivers()`, and `FastLED.setExclusiveDriver(...)`, with a diagnostic that warns when a strip's driver doesn't match its bus. (The month's biggest effort.) -- **RGBW / RGBWW colour**: proper colorimetric RGB→RGBW conversion with a lookup table, colour-temperature (CCT) control, and an RGB+CCT mode. +- **RGBW / RGBWW color**: proper colorimetric RGB→RGBW conversion with a lookup table, color-temperature (CCT) control, and an RGB+CCT mode. - ESP32-P4 gains a SIMD (PIE) acceleration backend for faster pixel processing. **Faster** @@ -59,7 +59,7 @@ _Auditability: 481 commits with author-date in 2026-06-01..2026-06-30 on `master **Fixed** -- RGBW driver no longer kept a dangling pointer to its colour profile (could crash or corrupt output). +- RGBW driver no longer kept a dangling pointer to its color profile (could crash or corrupt output). - AVR boards no longer try to compile RGBWW examples that overflow their memory. ## April 2026 @@ -132,7 +132,7 @@ _Auditability: 481 commits with author-date in 2026-06-01..2026-06-30 on `master - **RP2040 automatic parallel output** using the standard FastLED API. - Per-platform ESP32 clockless controllers + configurable ESP32/ESP8266 timing; nanosecond timing support for ARM K66/KL26; SPI chipset controllers split into their own headers. - Fallback **OTA** implementation for ESP-IDF < 4.0. -- PARLIO strategic buffer-breaking at colour boundaries. +- PARLIO strategic buffer-breaking at color boundaries. - 8-bit math optimised for ATtiny; math template/float overloads; a beat-detection `AudioProcessor` facade. - New "advanced effects" and "LED cookbook" documentation chapters. diff --git a/docs/history/MoonModules-WLED-MM.md b/docs/history/MoonModules-WLED-MM.md index f21276ae..fa7cc778 100644 --- a/docs/history/MoonModules-WLED-MM.md +++ b/docs/history/MoonModules-WLED-MM.md @@ -66,7 +66,7 @@ What landed on [WLED-MM](https://github.com/MoonModules/WLED-MM)'s `mdev` (defau - **Animartrix** overhauled — optional gamma correction, always paints in 2D, big math speedups, dependency upgrade, and several bugfixes (segment-option changes now respected). - **New ESP32 node types** for ESP-NOW (WizMote data); Philips Hue robustness; PixelForge GIF tool gains image rotation. -- **Fixed:** DMX-output now rate-limited to prevent watchdog resets; "relay does not turn on" sporadic issue; stack-smashing crash risk from `notify()`; better 2D preview colour accuracy and PS Fireworks trails. +- **Fixed:** DMX-output now rate-limited to prevent watchdog resets; "relay does not turn on" sporadic issue; stack-smashing crash risk from `notify()`; better 2D preview color accuracy and PS Fireworks trails. - New V4 build environments incl. `esp32_16MB_V4_M_eth` (16 MB ESP32 with Ethernet); IR re-enabled for the Athom Music build. ## January 2026 (up to v14.7.1) @@ -74,7 +74,7 @@ What landed on [WLED-MM](https://github.com/MoonModules/WLED-MM)'s `mdev` (defau *Summarised from 32 first-parent commits on `mdev`, 2026-01-01 … 2026-01-13 — released as **v14.7.1**.* - **Release v14.7.1.** -- Random per-LED colours via the JSON API (`"col":["r","r","r"]`); manual/dual auto-white modes work with palettes; segment-palette functions inlined for speed. +- Random per-LED colors via the JSON API (`"col":["r","r","r"]`); manual/dual auto-white modes work with palettes; segment-palette functions inlined for speed. - PixelForge effect adjustments; GIFs no longer blurred by default; the Colors column stays visible when toggling the GFX button. - **Fixed:** preset/file-access glitches, status-LED stops blinking; clearer "Connection to light failed!" message; Info page shows the GitHub repo, build flags, and status-LED pin. @@ -115,7 +115,7 @@ What landed on [WLED-MM](https://github.com/MoonModules/WLED-MM)'s `mdev` (defau **New / effects** -- ParticleFX better defaults for 64-px-height matrices and a framebuffer memory-calc fix; HUB75 drops colour-temperature correction for performance. +- ParticleFX better defaults for 64-px-height matrices and a framebuffer memory-calc fix; HUB75 drops color-temperature correction for performance. - WLED-MM-specific error effect instead of the plain orange flash on effect-memory failure; low-brightness gradient "jumpyness" fixed. **Fixed** diff --git a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md index 262f222d..e459fcc1 100644 --- a/docs/history/PlummersSoftwareLLC-NightDriverStrip.md +++ b/docs/history/PlummersSoftwareLLC-NightDriverStrip.md @@ -42,7 +42,7 @@ Auditability: ~40 first-parent merges/commits on `main` with author-date in 2026 **New** -- **RGBWW / SK6812 white-channel support** — W/WW helpers, a WarmGlow test effect, configurable SK6812 white extraction; colour-temperature naming cleanup. +- **RGBWW / SK6812 white-channel support** — W/WW helpers, a WarmGlow test effect, configurable SK6812 white extraction; color-temperature naming cleanup. - **New Setup Wizard / guided-installer WebUI** — and a push to make the UI a "non-special consumer": everything the official UI needs now comes from the firmware over the wire (spec/schema), not baked into `app.js`. - M5 Stick S3 support (IR + WS2812B); optimised WS2812B draw path; `ACTIVITY_PIN` support; allow zero effects in the table. diff --git a/docs/history/README.md b/docs/history/README.md index 2d17ee8f..af2b13e9 100644 --- a/docs/history/README.md +++ b/docs/history/README.md @@ -6,13 +6,13 @@ The backward-looking half of the docs (the forward-looking half is [`../backlog/ ## What's here -Three kinds of document: +Four kinds of document: ### Friend-repo activity digests Monthly logs of what shipped on related open-source LED projects — the live landscape projectMM watches to sharpen its own designs under the *Industry standards, our own code* principle ([CLAUDE.md § Principles](../../CLAUDE.md#principles)): study to think, write fresh, never copy. Generated by the [digest prompt](#digest-prompt-reusable) below. -- [FastLED-FastLED.md](FastLED-FastLED.md) — the LED-animation library; ESP32/Arduino driver + colour math. +- [FastLED-FastLED.md](FastLED-FastLED.md) — the LED-animation library; ESP32/Arduino driver + color math. - [wled-WLED.md](wled-WLED.md) — upstream WLED firmware. - [MoonModules-WLED-MM.md](MoonModules-WLED-MM.md) — MoonModules' WLED fork (the direct lineage). - [troyhacks-WLED.md](troyhacks-WLED.md) — troyhacks' personal fork of WLED-MM (PixelForge, RMTHI, audio-reactive hardening). @@ -31,6 +31,10 @@ One-time surveys of earlier projects, used to decide what to harvest into projec - [leddriver-analysis-bottom-up.md](leddriver-analysis-bottom-up.md) / [leddriver-analysis-top-down.md](leddriver-analysis-top-down.md) — the LED-driver design analyses (landscape survey + protocol-first study). The drivers shipped (RMT/MultiPin/Moon/Parlio on a shared base); kept as the how-we-got-there record. - [shift-register-driver-analysis.md](shift-register-driver-analysis.md) — the 74HCT595 pin-expander design analysis + lab-notebook of the ring's early transport bugs. The expander + streaming ring shipped; §7.5 records what NOT to re-try. +### 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. + ### Our own lessons - [lessons.md](lessons.md) — hard-won debugging lessons and gotchas (a bug, its cause, the fix), recorded with the code that proved them and pruned as they are absorbed (the PR-merge carry-forward gate writes here). Genuine architectural *decisions* live in [`../adr/`](../adr/README.md) instead; a lesson that hardened into a *rule* lives in CLAUDE.md / coding-standards.md. @@ -42,7 +46,7 @@ Reading across the friend-repo digests, the themes the wider ESP32-LED ecosystem - **ESP32-P4 / S3 parallel output.** FastLED poured effort into the PARLIO and LCD_CAM drivers (P4/S3 parallel LED output, big encode speedups); NightDriverStrip added custom RMT output; hpwit's I2SClocklessLedDriver pushed IDF-5.5 + arduino-less-ESP-IDF support for its I2S/LCD DMA driver (the canonical implementation of this technique), and troyhacks ran ESP32-P4 bring-up branches. The frontier is parallel, DMA-driven output on the newer chips. - **PSRAM strategy is unsettled everywhere.** All four wrestled with PSRAM this cycle — WLED-MM moved preview buffers into PSRAM, NightDriverStrip did a full PSRAM-default reversal (then tuned the threshold), WLED added S3-no-PSRAM builds. Nobody has a clean answer; the cache-disabled-during-flash hazard recurs across repos. - **Audio-reactive maturing.** FastLED added a silence-gate + ESP-DSP FFT backend; WLED-MM and WLED both refined audio sync and auto-disable-during-realtime; NightDriverStrip modernised its SoundAnalyzer/FFT. Audio-reactive is table stakes now, and the polish is in *not* reacting to noise/silence. -- **The FastLED dependency question.** WLED merged a *full FastLED replacement* (its own colour/math); projectMM already made the same call (own colour math, no FastLED in core). Two independent projects concluded the dependency wasn't worth it. +- **The FastLED dependency question.** WLED merged a *full FastLED replacement* (its own color/math); projectMM already made the same call (own color math, no FastLED in core). Two independent projects concluded the dependency wasn't worth it. - **UI as a firmware-driven consumer.** Both WLED and NightDriverStrip pushed toward "the official UI knows nothing the firmware doesn't publish over the wire" — exactly projectMM's MoonModule-driven, no-hardcoded-knowledge UI principle. Convergent design. NightDriverStrip's **2.0.0** (June 2026) crystallised this: a brand-new web UI, a browser-based installer, and settings (like strip type) moved from compile-time to *runtime-selectable* on the device — the same "reconfigure live, no reflash" direction projectMM builds around. - **Effect velocity.** WLED and WLED-MM shipped many new effects (PacMan, Color Clouds, Shimmer, the user_fx pack); new effects remain the most visible user-facing output. - **Display / HDMI output beyond LED strips.** troyhacks ran a cluster of branches probing HDMI video output and large hardware panels (WaveShare 10.1″, M5Stack, ESP32-P4 panels) — driving *displays*, not just addressable strips, off the same firmware. diff --git a/docs/history/hpwit-I2SClocklessLedDriver.md b/docs/history/hpwit-I2SClocklessLedDriver.md index 84572fb6..af8e8edc 100644 --- a/docs/history/hpwit-I2SClocklessLedDriver.md +++ b/docs/history/hpwit-I2SClocklessLedDriver.md @@ -52,7 +52,7 @@ _Auditability: commits on `main` author-dated 2026-06-01..2026-06-30 = 0 (0 merg **New** - **IDF 5.5 support** — version checks, dynamic DMA-buffer allocation (PSRAM-preferred), `NUM_STRIPS` as a global, `IRAM_ATTR` removed from forwards to compile warning-free. -- `updateDriver` / `deleteDriver` gained length/size and per-strip offset parameters; `initled` with custom colour arrangement; `isVirtualDriver` flag; `setDelay()`. +- `updateDriver` / `deleteDriver` gained length/size and per-strip offset parameters; `initled` with custom color arrangement; `isVirtualDriver` flag; `setDelay()`. - Split a `Driver.cpp` out of the header; added clang-format and removed the `COLOR_ORDER_` compiler directives. **Fixed** diff --git a/docs/history/leddriver-analysis-bottom-up.md b/docs/history/leddriver-analysis-bottom-up.md index a826d8e6..fbed1bb2 100644 --- a/docs/history/leddriver-analysis-bottom-up.md +++ b/docs/history/leddriver-analysis-bottom-up.md @@ -276,14 +276,14 @@ Metadata carried by the driver as bound MoonModule controls, **not** through `pu - `pinMap[]` — which GPIO each strip lives on (or, for virtual driver: which shift-register output). - `stripLengths[]` — per-strip light counts, sums to `bufferBytes / channelsPerLight`. - `protocol` — WS2812 / WS2815 / SK6812 / APA102 / SK9822 / HD107S / etc. -- `colourOrder` — GRB / RGB / GRBW / etc. +- `colorOrder` — GRB / RGB / GRBW / etc. - `gamma` (1.0-3.0). - `globalBrightness` (0-255 uint8). - *Backend-specific*: each backend may expose its own controls (RMT clock divider, LCD overclock factor, FlexIO timer divider). These live on the backend, not the base. Per-frame: just the span + an implicit "size matches the bound metadata". -> **Superseded — `colourOrder` / `gamma` / `globalBrightness` shipped differently.** These three are now the **shared output correction** (`src/light/drivers/Correction.h`): the `Drivers` container owns one `Correction` (brightness LUT + a `lightPreset` covering channel order *and* RGBW) and hands each child a `const Correction*`, rather than each driver binding its own `colourOrder`/`gamma`/`brightness`. One source of truth across all physical drivers; ArtNet already uses it. The future LED driver consumes the same `Correction` — see [leddriver-analysis-top-down.md § 4.6](leddriver-analysis-top-down.md) and [architecture.md § Drivers](../architecture.md#drivers). Gamma is not implemented yet (the LUT is brightness-only; gamma folds in later as a per-channel R/G/B split). +> **Superseded — `colorOrder` / `gamma` / `globalBrightness` shipped differently.** These three are now the **shared output correction** (`src/light/drivers/Correction.h`): the `Drivers` container owns one `Correction` (brightness LUT + a `lightPreset` covering channel order *and* RGBW) and hands each child a `const Correction*`, rather than each driver binding its own `colorOrder`/`gamma`/`brightness`. One source of truth across all physical drivers; ArtNet already uses it. The future LED driver consumes the same `Correction` — see [leddriver-analysis-top-down.md § 4.6](leddriver-analysis-top-down.md) and [architecture.md § Drivers](../architecture.md#drivers). Gamma is not implemented yet (the LUT is brightness-only; gamma folds in later as a per-channel R/G/B split). ### Identity-mapping fast path preserved @@ -413,7 +413,7 @@ What IDF does **not** provide: - **No LED-strip driver in core IDF.** No `esp_ws2812.h`, no built-in WS281x bit encoder, no APA102 helper. The `led_strip` community component (`espressif/idf-extra-components`, Apache-2.0) gives a basic single-strip handle on top of RMT or SPI MOSI, but it's a managed-component dependency we'd pull in, not built into IDF. - **No transposition helpers.** If you want to drive 16 parallel WS281x strips through I2S-LCD or LCD-CAM, you write the bit-interleaving yourself. That's the work hpwit's lib does. - **No multi-protocol abstraction.** "Drive WS2812 here, APA102 there, DMX over there" is your problem; IDF gives you the peripherals, not the protocol mapping. -- **No colour-order / gamma / brightness layer.** Up to us. +- **No color-order / gamma / brightness layer.** Up to us. - **No hot-reconfigure pattern.** Each peripheral driver's API is "create_unit → enable → transmit → delete"; toggling pin maps at runtime means delete + recreate, and the discipline of doing that without dropping a frame is on us. **What that means for Scenario B effort estimate:** @@ -493,7 +493,7 @@ enum class LedProtocol : uint8_t { Custom, // backend-specific timing in its own controls }; -enum class ColourOrder : uint8_t { +enum class ColorOrder : uint8_t { RGB, RBG, GRB, GBR, BRG, BGR, RGBW, GRBW, // … }; @@ -504,7 +504,7 @@ struct StripSpec { uint16_t lightCount; // logical lights on this strip uint8_t pin; // GPIO or shift-register output index LedProtocol protocol; - ColourOrder order; + ColorOrder order; }; // LedDriver — abstract base for hardware-specific LED-strip output. @@ -553,7 +553,7 @@ public: Notes on the shape: - `std::span<const uint8_t>` for `pixels` — caller-owned, zero-copy, lifetime tied to the call. ESP-IDF allows `std::span` (C++20 is the project baseline). -- No `colour_order` / `gamma` / `brightness` argument to `push()` — those are controls on the driver and applied during push by the implementation. Keeps the boundary narrow. +- No `color_order` / `gamma` / `brightness` argument to `push()` — those are controls on the driver and applied during push by the implementation. Keeps the boundary narrow. - `StripSpec.pin` is a uint8 because the virtual driver indexes shift-register outputs (0..63), not real GPIOs. Real-GPIO backends just use it as a GPIO number. - `onTopologyChange()` returns bool so the UI can surface "this combination isn't possible on this chip" instead of silently truncating. - No CRTP, no templates — straightforward virtual dispatch, one call per frame. Runtime driver switch is `delete oldDriver; oldDriver = makeDriverByName(...)` — trivially supported. @@ -610,7 +610,7 @@ Specific to the LED-driver architecture; ranked by potential blast radius. 4. **LCD overclock non-portability.** hpwit overclocks the S3 LCD clock to ~1.125 MHz for WS2812 timing headroom. This is undocumented behaviour; Espressif could change it without warning. The non-overclocked path works; "overclock for headroom" is performance-only and falls back cleanly if removed. 5. **Hot-reconfigure during DMA in-flight.** What happens if the user changes `pinMap` via the UI while the *current* frame's DMA is mid-transmission? The architecture must define this: most likely "topology changes are taken into account on the next frame's `push()`; the in-flight frame completes on the old topology". `onTopologyChange()` blocks until DMA-quiescent before re-allocating peripheral resources. 6. **WiFi-coexistence empirically variable.** Different routers, different switches, different cable conditions all interact with the WiFi stack's CPU-burst patterns. Our two tracks (DMA-driven + core pinning) cover the known modes; there's an unknown unknown around very-large installs (50K+) that no public library has fully solved. -7. **Identity-mapping path subtlety.** The fast path requires the Layer's buffer layout to *exactly* match the wire layout (no gamma, no brightness, no colour-order swap on the way out). If the driver wants to apply gamma/brightness in `push()`, the identity path is gone — we re-introduce the copy. The control-binding shape should make this trade-off explicit per backend. **Resolved for clockless LEDs:** a WS2812-class driver must encode RGB into a pulse pattern anyway, so it already allocates a separate DMA buffer — the `Correction` (brightness/reorder/white) fuses into that encode pass at no extra buffer cost. The true zero-copy-to-wire identity path only ever applied to byte-identical protocols (ArtNet/DMX, APA102-SPI), where there's no encode and correction is the only reason to copy. So "identity → no copy" is a property of the *protocol*, not the mapping. +7. **Identity-mapping path subtlety.** The fast path requires the Layer's buffer layout to *exactly* match the wire layout (no gamma, no brightness, no color-order swap on the way out). If the driver wants to apply gamma/brightness in `push()`, the identity path is gone — we re-introduce the copy. The control-binding shape should make this trade-off explicit per backend. **Resolved for clockless LEDs:** a WS2812-class driver must encode RGB into a pulse pattern anyway, so it already allocates a separate DMA buffer — the `Correction` (brightness/reorder/white) fuses into that encode pass at no extra buffer cost. The true zero-copy-to-wire identity path only ever applied to byte-identical protocols (ArtNet/DMX, APA102-SPI), where there's no encode and correction is the only reason to copy. So "identity → no copy" is a property of the *protocol*, not the mapping. 8. **Build vs borrow lock-in.** Whichever scenario Stage 2 picks, switching later is expensive (months, not weeks). The hybrid escape hatch (Scenario A for some backends, Scenario B for others) reduces this but doesn't eliminate it. ## Stage-2 candidates (revised) diff --git a/docs/history/leddriver-analysis-top-down.md b/docs/history/leddriver-analysis-top-down.md index a01f4887..fd235d2a 100644 --- a/docs/history/leddriver-analysis-top-down.md +++ b/docs/history/leddriver-analysis-top-down.md @@ -145,7 +145,7 @@ Contributing factors, ranked: - Set `pm_config.min_freq_mhz` ≥ 80. - Prefer DMA-fed peripherals (RMT-with-DMA, I2S, LCD_CAM, PARLIO) so the CPU is out of the timing loop. -Diagnostic signature for WiFi-induced corruption (vs power or signal-integrity faults): first frames after boot look perfect; symptoms appear only after WiFi associates; pixels are shifted/stuck *from a position outward* (one bit slipped, everything downstream inherits it); colour flashes correlate with traffic bursts. Compare against power-fault signature: brownout, whole-strip dim, first-pixel corruption. +Diagnostic signature for WiFi-induced corruption (vs power or signal-integrity faults): first frames after boot look perfect; symptoms appear only after WiFi associates; pixels are shifted/stuck *from a position outward* (one bit slipped, everything downstream inherits it); color flashes correlate with traffic bursts. Compare against power-fault signature: brownout, whole-strip dim, first-pixel corruption. For projectMM, this means: **the LED driver task lives on core 1** (the quiet core, away from the WiFi stack and its interrupts) and **the effects / network path lives on core 0** (it can tolerate latency spikes — a late effect frame is invisible, a late driver bit is a corrupted pixel). This is the inverse of the WLED render-on-core-1 pattern, applied to a different observation about which task has the harder deadline; see § 7.2 for the full rationale and the projectMM-specific per-module core-affinity story. diff --git a/docs/history/lessons.md b/docs/history/lessons.md index 0adc67cb..15a67bff 100644 --- a/docs/history/lessons.md +++ b/docs/history/lessons.md @@ -141,9 +141,9 @@ Bench notes: a board that drops STA mid-session falls back to softAP silently, p ## Diagnosing LED flicker: eliminate firmware with hardware tests before blaming (or fixing) the wire -A classic ESP32 driving WS2812 on RMT showed random wrong colours on LEDs the effect left black. Instead of guessing (WiFi, buffering, timing), a four-step elimination each a *measurement*: (1) capture the source/preview buffer, clean, so corruption is downstream of the logical buffer; (2) whole-frame loopback self-test through a jumper, bit-exact PASS even under WiFi load, so the firmware/peripheral is innocent; (3) sweep `txPowerSetting` 20→1 dBm, flicker constant, so it's NOT WiFi coupling; (4) check pulse timing, 350/700/1250 ns spec-exact, not a timing-margin bug. Every firmware cause eliminated by test, the remaining cause is the physical data path, and "constant regardless of TX power" fingers signal integrity, a missing 3.3→5 V level shift. Fix is hardware (documented in [LED signal integrity](../usecases/led-signal-integrity.md)). +A classic ESP32 driving WS2812 on RMT showed random wrong colors on LEDs the effect left black. Instead of guessing (WiFi, buffering, timing), a four-step elimination each a *measurement*: (1) capture the source/preview buffer, clean, so corruption is downstream of the logical buffer; (2) whole-frame loopback self-test through a jumper, bit-exact PASS even under WiFi load, so the firmware/peripheral is innocent; (3) sweep `txPowerSetting` 20→1 dBm, flicker constant, so it's NOT WiFi coupling; (4) check pulse timing, 350/700/1250 ns spec-exact, not a timing-margin bug. Every firmware cause eliminated by test, the remaining cause is the physical data path, and "constant regardless of TX power" fingers signal integrity, a missing 3.3→5 V level shift. Fix is hardware (documented in [LED signal integrity](../usecases/led-signal-integrity.md)). -**Red-herring note:** the flicker's *appearance* shifted (blue-only → random) after an unrelated `lightPreset` RGB→GRB default change plus a pin swap. The electrical fault was identical; only the colour mapping changed. A changed symptom is not proof a code change caused it. +**Red-herring note:** the flicker's *appearance* shifted (blue-only → random) after an unrelated `lightPreset` RGB→GRB default change plus a pin swap. The electrical fault was identical; only the color mapping changed. A changed symptom is not proof a code change caused it. ## ESP32-P4 support, round 1, per-board Ethernet pin config, and the P4's WiFi reality @@ -261,9 +261,9 @@ The catalog/summary pages hand-author each card image as a raw `<img src="../../ A live-in-Home-Assistant debugging pass over the WLED-compat surface, plus a mic bring-up that ate an afternoon. The gotchas that a green build didn't catch. -- **HA reads WLED `info.leds.seglc[]` as a per-segment capability CODE, not an LED count.** The `/json` writer put the LED count in `seglc` (`seglc:[24]` on a 24-LED strip). HA's WLED integration indexes `seglc[segment_id]` and maps it through `LIGHT_CAPABILITIES_COLOR_MODE_MAPPING` (a small enum: 1 = RGB, etc.) — a `24` has no mapping, so `WLEDSegmentLight` ends up with *no supported colour modes*, HA raises `does not set supported color modes`, and the light entity stays `restored`/**unavailable** while the sensors keep working. It hid for ages because a single-light board (`count:1`) sends `seglc:[1]`, which is accidentally the valid RGB code, so those boards worked; only a *multi-LED* board tripped it. Fix: `seglc` is the constant `1`, matching `lc`; the count lives only in `count`. General shape: when impersonating another device's API, a field that *looks* numeric may be an enum/bitmask in the consumer — validate against the consumer's parser (here `frenck/python-wled` + ha-core `wled/const.py`), not against what the number "obviously" means. Pinned by `test/python/test_wled_json_shape.py` parsing a golden `/json` through the real library. +- **HA reads WLED `info.leds.seglc[]` as a per-segment capability CODE, not an LED count.** The `/json` writer put the LED count in `seglc` (`seglc:[24]` on a 24-LED strip). HA's WLED integration indexes `seglc[segment_id]` and maps it through `LIGHT_CAPABILITIES_COLOR_MODE_MAPPING` (a small enum: 1 = RGB, etc.) — a `24` has no mapping, so `WLEDSegmentLight` ends up with *no supported color modes*, HA raises `does not set supported color modes`, and the light entity stays `restored`/**unavailable** while the sensors keep working. It hid for ages because a single-light board (`count:1`) sends `seglc:[1]`, which is accidentally the valid RGB code, so those boards worked; only a *multi-LED* board tripped it. Fix: `seglc` is the constant `1`, matching `lc`; the count lives only in `count`. General shape: when impersonating another device's API, a field that *looks* numeric may be an enum/bitmask in the consumer — validate against the consumer's parser (here `frenck/python-wled` + ha-core `wled/const.py`), not against what the number "obviously" means. Pinned by `test/python/test_wled_json_shape.py` parsing a golden `/json` through the real library. - **HA's WLED light entity needs a *complete* segment, not a valid one.** After `seglc` was fixed the light was still stuck, because the segment carried `pal` but no `fx` — a shape real WLED never emits (it always reports both). python-wled's `Segment` half-populated, and setup still failed silently (no log line). Real WLED always pairs effect + palette; sending one without the other is the trap. Fix: emit `fx:0` alongside `pal`. When mimicking a device, match the *full* field set of the real thing for any object the consumer parses, not the subset you happen to use. -- **A WLED-shim device shows up in HA over TWO independent discovery paths, and both fire at once.** A projectMM board announces via WLED (mDNS `_wled._tcp` + `/json`, no broker) *and*, if the `haDiscovery` control is on, via MQTT discovery (retained `homeassistant/light/<id>/config`). HA creates a light entity from *each* — so the device lists **twice** (one `platform=wled`, one `platform=mqtt`), which reads as a bug. The WLED path is richer (colour, palette, sensors) and needs no broker; MQTT is the fallback for broker-only / cross-subnet setups where mDNS can't reach. Resolution: `haDiscovery` now defaults **off** so a device appears once by default; MQTT discovery is opt-in. (Recorded as a decision in [ADR-0012](../adr/0012-ha-discovery-wled-default-mqtt-opt-in.md).) General: when a device speaks two auto-discovery protocols to the same hub, exactly one should be the default or the hub double-lists it. +- **A WLED-shim device shows up in HA over TWO independent discovery paths, and both fire at once.** A projectMM board announces via WLED (mDNS `_wled._tcp` + `/json`, no broker) *and*, if the `haDiscovery` control is on, via MQTT discovery (retained `homeassistant/light/<id>/config`). HA creates a light entity from *each* — so the device lists **twice** (one `platform=wled`, one `platform=mqtt`), which reads as a bug. The WLED path is richer (color, palette, sensors) and needs no broker; MQTT is the fallback for broker-only / cross-subnet setups where mDNS can't reach. Resolution: `haDiscovery` now defaults **off** so a device appears once by default; MQTT discovery is opt-in. (Recorded as a decision in [ADR-0012](../adr/0012-ha-discovery-wled-default-mqtt-opt-in.md).) General: when a device speaks two auto-discovery protocols to the same hub, exactly one should be the default or the hub double-lists it. - **An Ethernet device sending a zeroed `wifi` block makes HA render greyed Wi-Fi sensors; omit the block instead.** `/json` unconditionally emitted `wifi:{bssid:00:…, rssi:0, channel:0, signal:0}` — correct-but-empty on an eth board (no AP). HA's `info.wifi` is *optional* in python-wled, so a present-but-zero block still spawns Wi-Fi RSSI/BSSID/channel sensors that sit greyed. Fix: on `ethConnected()`, drop the `wifi` object entirely — HA then creates no Wi-Fi sensors for an eth device (what a real WLED-on-eth does). General: for an optional field a consumer keys entity-creation off, *absent* and *present-but-empty* are different — absent is the honest signal for "this capability doesn't exist here." - **A silkscreen `45` read as `15` put an I²S mic pin on a boot strap, and the mic read dead-silent — not a bad mic.** Hours went into a "broken" mic (dead-flat RMS on every pin, phantom signal that hopped between pins run-to-run) before the cause turned out to be the WS wire on **GPIO45** (an S3 boot strap, driven at reset → silent) misread off the header as GPIO15. Two diagnostic traps compounded it: (1) `RMS>0` — or even `RMS varies` — does NOT confirm a mic, because a floating/strap/mis-numbered input pins RMS at ~255 or bounces erratically; only RMS *tracking sound* on ONE fixed pin does; (2) an earlier *actual* solder fault on the SD line masked the real issue for a while. Fixes: a mic-health status (`no samples` = clocks dead / `data line silent` = SD dead) that self-reports which wire is at fault; the working bench config is SCK=47/WS=48/SD=21 (48 is free on this rev v1.0 board, LED on 38). General: re-read physical pin numbers against the silkscreen *first* on a dead peripheral, and A/B against a known-good board on the same firmware before suspecting software. @@ -362,3 +362,17 @@ The MoonI80 shift ring "wedged" after a loopback ON→OFF (and after any control - **One control edit rebuilt the bus TWICE, and the rapid second rebuild wedged.** Within a single `Scheduler::prepareTree()` sweep, the Drivers container's `prepare()` pushes every child's correction (`passBufferToDrivers` → `rebuildCorrection` → the driver's `onCorrectionChanged()`, which did a full `reinit()`), and THEN the same sweep runs the child's own `prepare()` → `reinit()` again. Two peripheral teardown+rebuilds back-to-back; the second came up before the first's ring had fired a single frame, and its GDMA EOF never fired. The fix is a dirty-gate: `onCorrectionChanged()` reinits only when `frameBytes_` actually changed (a real RGB↔RGBW switch), so a light-count/window edit — which the correction push does NOT resize — rebuilds once, in `prepare()`. **General: a cross-cutting "rebuild on change" that fires from two independent triggers in the same sweep must be idempotent or gated on a real delta; two reinits of a stateful peripheral in quick succession is its own failure mode, distinct from either reinit alone.** Both bugs presented identically ("output stalled, `wireUs=—`, reboot to fix"), which is why the peripheral-level theories stuck: the *symptom* was at the peripheral, the *cause* was two layers up in memory-fit routing and control-flow. **General: a symptom that manifests in an interrupt/peripheral is not evidence the bug lives there — instrument the decision that led to the peripheral call (which path was chosen, how many times it ran) before diffing registers.** The false-positive risk cuts the other way too: the bench probe flagged "WEDGE? first frame advanced EOF by 0" on every rebuild, which was just the first-frame-has-no-predecessor artifact of the probe, not a real wedge — the ring was driving fine underneath. + +## `/api/types` froze the LEDs on every UI refresh — a throwaway probe reached the LIVE render split through a global hook + +"Refresh the web UI and the LEDs freeze" — reported first on an ESP32-S31, so it read as S31-specific silicon: a new board on an IDF-beta. It was not. It reproduced on the P4 too (there the fast core recovered so fast the freeze was invisible to the eye, but the multicore worker task `mmEncode` provably vanished from the FreeRTOS task list on every refresh), and it fired with **every** LED driver, at the stock clock, on a flash-erased board. The one invariant: `multicore` ON + a UI refresh. + +**Root cause.** A browser refresh fetches `GET /api/types`, and `HttpServerModule::writeTypeDefaults` builds a **throwaway probe** of each registered type (`ModuleFactory::create` → `defineControls` → read defaults → destroy) to publish its default control values. Constructing a `ParallelLedDriver` probe runs `selectDefaultPeripheral` → `swapPeripheral`, which called `MoonModule::notifyQuiesceRender()` **unconditionally**. That hook is a process-global that resolves to the *live* Drivers via the static `ActiveInstance<Drivers>` seat (`Drivers::active()`), and it calls `stopEncodeTask()`. So a detached probe — an instance that was never in the tree — **tore down the running render split**, and no `prepareTree()` re-engaged it. `multicore` still read `true` (the switch state is not the engaged state), so the UI showed nothing wrong; on a slow-enough core the un-overlapped output visibly froze. + +**Fix (one line).** `swapPeripheral` fires the quiesce hook only when `peripheral_ != nullptr` — i.e. only when a real backend is about to be `delete`d, which is the exact use-after-free the hook exists to prevent. A fresh instance's first swap (the default-peripheral build, no prior backend) no longer touches the live worker; a genuine live swap (POST `/api/control` on `peripheral`, with a backend to free) still quiesces. The existing "a live swap quiesces the worker" test still passes; a new `unit_Drivers_rendersplit` case pins that a detached ParallelLedDriver's swap leaves a live split untouched (fails without the guard). + +**General lessons:** +- **A `create → introspect → destroy` probe must be side-effect-free, and "read the defaults" is not.** Any lifecycle call on a throwaway instance (`defineControls`, `release`, a constructor that selects a default) can reach global/static state — a registry, an active-instance seat, a hook — and mutate the *live* system. If a type publishes to a process-global on construction or teardown, probing it is not read-only. (Same family as the earlier registerType `T probe` stack-overflow: a probe instance is a real object with real side effects.) +- **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 `—`). diff --git a/docs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning (shipped).md b/docs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning (shipped).md index e1e7c996..e32ed1cc 100644 --- a/docs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning (shipped).md +++ b/docs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning (shipped).md @@ -39,7 +39,7 @@ Closed scope (locked by product owner): - **Per-release board list**: derived from the release's `manifest-<board>.json` assets (strict regex parse). - **Caching**: sessionStorage, 5-minute TTL. Dev escape hatch: `?nocache=1`. - **Yanked releases**: delete the GitHub release; API stops returning it; cumulative-stage step trims it on next deploy. No special UI. -- **Two dropdowns: release + board.** Single flat release dropdown listing every release newest-first; RCs flagged with a `(beta)` suffix on the option text (e.g. `v1.0.0-rc1 (beta) — 12 hours ago`). Smart default selects the newest stable; falls through to the newest prerelease if none exists. **Reconciled mid-implementation**: the original plan called for a separate channel select + `<details>` "Pick specific release" expand, but the two-axis layout (stability vs version) was confusing; the single flat dropdown is simpler, the compatibility filter on the board step still does the protection work, and an RC remains visually distinct via the suffix + colour. +- **Two dropdowns: release + board.** Single flat release dropdown listing every release newest-first; RCs flagged with a `(beta)` suffix on the option text (e.g. `v1.0.0-rc1 (beta) — 12 hours ago`). Smart default selects the newest stable; falls through to the newest prerelease if none exists. **Reconciled mid-implementation**: the original plan called for a separate channel select + `<details>` "Pick specific release" expand, but the two-axis layout (stability vs version) was confusing; the single flat dropdown is simpler, the compatibility filter on the board step still does the protection work, and an RC remains visually distinct via the suffix + color. - **Relative-time display** ("2 days ago") next to each release. - **`app.js` becomes a module** (`<script type="module">`) to import `release-picker.js` cleanly. Single-attribute HTML change. - **File-upload OTA route (`POST /api/firmware`) is skipped.** Picker drives `/api/firmware/url` only. v1 had both; v3 doesn't need the file-picker affordance on day one. diff --git a/docs/history/plans/Plan-20260622 - Non-blocking preview send (shipped).md b/docs/history/plans/Plan-20260622 - Non-blocking preview send (shipped).md index d898db6b..637635ef 100644 --- a/docs/history/plans/Plan-20260622 - Non-blocking preview send (shipped).md +++ b/docs/history/plans/Plan-20260622 - Non-blocking preview send (shipped).md @@ -40,7 +40,7 @@ The preview is a *live view* — one viewer at a time is the real use case, and ### 3. PreviewDriver: raise the cap empirically, widen count to u32, keep downsample fallback - Replace fixed `MAX_PREVIEW_POINTS = 1800` with a **RAM-derived cap** (`platform::hasPsram`/`freeInternalHeap()`/`maxAllocBlock()` with a margin for stack/HTTP/WiFi), **tuned by measurement**. The spatial-lattice downsample **stays** and engages beyond the cap. -- **Widen the frame `count` field `u16 → u32`** (both 0x02 colour and 0x03 coord headers) on device and browser. +- **Widen the frame `count` field `u16 → u32`** (both 0x02 color and 0x03 coord headers) on device and browser. - **Does NOT touch the `rgb_`/`coords_` build buffers** — only how the built frame is *sent* and the count width. Zero-copy producer-buffer reuse + channelsPerLight/offset wire model are a separate deferred step. ## Files diff --git a/docs/history/plans/Plan-20260623 - Stream preview from buffers, zero preview buffers (shipped).md b/docs/history/plans/Plan-20260623 - Stream preview from buffers, zero preview buffers (shipped).md index 652c5929..38638a6e 100644 --- a/docs/history/plans/Plan-20260623 - Stream preview from buffers, zero preview buffers (shipped).md +++ b/docs/history/plans/Plan-20260623 - Stream preview from buffers, zero preview buffers (shipped).md @@ -4,18 +4,18 @@ ## Context -The non-blocking preview rework (committed 1e48e92) made the WebSocket preview stream without stalling the render tick, but it introduced/retained **frame-sized buffers** in the preview path: `coords_` (~49 KB packed positions), `rgb_` (per-frame colour copy), `sampledIdx_` (the lattice index map), and the HttpServer **staging buffer** (~49 KB). On a no-PSRAM classic ESP32 these compete for scarce *contiguous* internal RAM: at 128² (16384 lights) the render buffer (49 KB) and a preview buffer (49 KB) can't both find a contiguous block once the heap fragments from grid-resize churn — so the preview (and sometimes the render) fails to allocate. Measured on the bench: a clean boot has a 108 KB contiguous block (128² fits); after resize churn it collapses to ~20–40 KB (128² fails). +The non-blocking preview rework (committed 1e48e92) made the WebSocket preview stream without stalling the render tick, but it introduced/retained **frame-sized buffers** in the preview path: `coords_` (~49 KB packed positions), `rgb_` (per-frame color copy), `sampledIdx_` (the lattice index map), and the HttpServer **staging buffer** (~49 KB). On a no-PSRAM classic ESP32 these compete for scarce *contiguous* internal RAM: at 128² (16384 lights) the render buffer (49 KB) and a preview buffer (49 KB) can't both find a contiguous block once the heap fragments from grid-resize churn — so the preview (and sometimes the render) fails to allocate. Measured on the bench: a clean boot has a 108 KB contiguous block (128² fits); after resize churn it collapses to ~20–40 KB (128² fails). -The principle this violates is CLAUDE.md's **minimal memory / data-over-objects / hot-path** rules, applied to the preview: the colours already live in the **producer/consumer buffer** (the Layer's logical buffer, or the blend buffer for multi-layer/non-identity mapping). The preview should **stream that buffer to the client**, holding no frame-sized copy of its own. Positions are communicated **once** (event-based: on a layout/modifier change, or when a client connects/refreshes); after that the per-frame stream is just the buffer, 1:1, and the client already knows where each light goes. +The principle this violates is CLAUDE.md's **minimal memory / data-over-objects / hot-path** rules, applied to the preview: the colors already live in the **producer/consumer buffer** (the Layer's logical buffer, or the blend buffer for multi-layer/non-identity mapping). The preview should **stream that buffer to the client**, holding no frame-sized copy of its own. Positions are communicated **once** (event-based: on a layout/modifier change, or when a client connects/refreshes); after that the per-frame stream is just the buffer, 1:1, and the client already knows where each light goes. architecture.md describes the preview *mechanism* (a one-time coord table + per-frame RGB, §"Output stage"/§UI) but does **not** state the memory model — that the per-frame stream is the producer buffer with no intermediate buffer, and downsampling is "send every Nth light." This plan implements that, and the doc gets updated to capture it. ## The model (settled with the product owner) - **Coordinates are sent once** (0x03), on geometry change or client (re)connect. They need positions → built from `Layouts::forEachCoord` (a cold/rate-limited path, never the LED render hot path — verified: forEachCoord callers are LUT build, status, and the preview coord build only). -- **Colours are streamed per frame** (0x02) straight from the producer/consumer buffer the driver already holds (`sourceBuffer_`), **1:1, no copy**. The client places colour[i] at coord[i] from the table it already has. -- **Downsampling = send every Nth light**, applied identically to the coord table and the colour frame so they match by construction. Two regimes: - - **Full resolution (the common case, stride 1):** colour frame is a pure 1:1 buffer stream — no `forEachCoord`, no skip, no buffers. +- **Colors are streamed per frame** (0x02) straight from the producer/consumer buffer the driver already holds (`sourceBuffer_`), **1:1, no copy**. The client places color[i] at coord[i] from the table it already has. +- **Downsampling = send every Nth light**, applied identically to the coord table and the color frame so they match by construction. Two regimes: + - **Full resolution (the common case, stride 1):** color frame is a pure 1:1 buffer stream — no `forEachCoord`, no skip, no buffers. - **Downsampled (rare: grid > cap, or link too slow):** to avoid the diagonal moiré that flat `i % N` striding causes on a 2D grid, both passes use the **spatial-lattice** skip (`x%s && y%s && z%s`) via `forEachCoord`. This walks positions (cheap integer loop, rate-limited, off the LED hot path) but still streams — no stored index map. - **No preview-side frame buffers at all**: streaming via the broadcaster's begin/push/end means neither pass ever holds a frame-sized buffer. `coords_`, `rgb_`, `sampledIdx_`, and the HttpServer staging buffer are all removed. @@ -26,9 +26,9 @@ architecture.md describes the preview *mechanism* (a one-time coord table + per- ### 2. PreviewDriver: stream both passes, drop the buffers - **Coord table** (`buildAndSendCoordTable` → `streamCoordTable`): compute the per-axis lattice step `s` (1 = full res; >1 when the light count exceeds the cap or adaptive downscale raised it). `beginBinaryFrame(coordCount*3 + …)`, then walk `forEachCoord` pushing scaled (x,y,z) for lights on the lattice (`x%s && y%s && z%s`); `endBinaryFrame`. No `coords_`. -- **Colour frame** (`sendFrame`): +- **Color frame** (`sendFrame`): - **stride 1:** `beginBinaryFrame(n*3)`, then push the producer buffer directly. If `cpl==3` it's one push of `sourceBuffer_->data()`; if `cpl!=3` (RGBW) push per-light 3 bytes through a tiny stack temp. No `rgb_`. - - **stride > 1:** walk `forEachCoord`; for each light on the lattice push its 3 colour bytes from `sourceBuffer_[idx]`. Same predicate as the coord table → same subset/order. No `sampledIdx_`. + - **stride > 1:** walk `forEachCoord`; for each light on the lattice push its 3 color bytes from `sourceBuffer_[idx]`. Same predicate as the coord table → same subset/order. No `sampledIdx_`. - Remove members: `coords_`, `rgb_`, `sampledIdx_`/`sampledIdxCap_`, and the `~PreviewDriver` delete. - Keep: the **cap** (now just "downsample above N points" — bounds the per-frame work/wire size, not a buffer), the **adaptive downscale** (latency + pending-drop driven), `coordPending_` retry, the u32 count, the browser count/stride guard. @@ -36,8 +36,8 @@ architecture.md describes the preview *mechanism* (a one-time coord table + per- With both passes streamed, the staging buffer + `wsPreviewBuf_/Cap_/Len_/Sent_[]`, the stage-vs-DIRECT branch in `broadcastBinary`, `drainWsSends`, `directBroadcast`, and the drain-tick/stuck-client guard are no longer used by the preview. `broadcastBinary` (the chunk-array form) and `lastDrainTicks` may become unused → remove what's dead. (Adaptive downscale now keys off `endBinaryFrame()` returning false / the coord-pending retry, not `lastDrainTicks` — confirm and simplify.) ### 4. Tests + docs -- `unit_PreviewDriver`: update the `CaptureBroadcaster` mock to implement begin/push/end (accumulate pushed bytes into `lastCoord`/`lastFrame`). Keep the assertions (count, header sizes, lattice regularity, full-res-not-downsampled, coord-pending retry). Add: colour frame at stride 1 equals the source buffer (1:1, no copy). -- `docs/moonmodules/light/drivers/PreviewDriver.md` + `core/HttpServerModule.md`: rewrite to the streamed model (no buffers; positions once; colours 1:1 from the producer buffer; every-Nth downsample; begin/push/end wire). +- `unit_PreviewDriver`: update the `CaptureBroadcaster` mock to implement begin/push/end (accumulate pushed bytes into `lastCoord`/`lastFrame`). Keep the assertions (count, header sizes, lattice regularity, full-res-not-downsampled, coord-pending retry). Add: color frame at stride 1 equals the source buffer (1:1, no copy). +- `docs/moonmodules/light/drivers/PreviewDriver.md` + `core/HttpServerModule.md`: rewrite to the streamed model (no buffers; positions once; colors 1:1 from the producer buffer; every-Nth downsample; begin/push/end wire). - `docs/architecture.md`: add the preview **memory model** to the output-stage/UI section — the preview streams the producer buffer with no intermediate copy; this is the data-over-objects / minimal-memory principle applied to the preview. ## Files @@ -50,11 +50,11 @@ With both passes streamed, the staging buffer + `wsPreviewBuf_/Cap_/Len_/Sent_[] ## Verification - Host: build (-Werror), ctest, scenarios, spec, platform-boundary. - ESP32: S3 + classic build. -- **Classic 128² (the target):** with `coords_`/`rgb_`/`sampledIdx_`/staging gone, confirm the render buffer allocates AND the preview streams at 128² without those competing 49 KB blocks; measure `freeInternal`/`maxBlock` to confirm the contiguous-RAM pressure is relieved. Confirm full-res streams 1:1 (no moiré) and a grid past the cap downsamples cleanly (no moiré, matched colour/coord counts). +- **Classic 128² (the target):** with `coords_`/`rgb_`/`sampledIdx_`/staging gone, confirm the render buffer allocates AND the preview streams at 128² without those competing 49 KB blocks; measure `freeInternal`/`maxBlock` to confirm the contiguous-RAM pressure is relieved. Confirm full-res streams 1:1 (no moiré) and a grid past the cap downsamples cleanly (no moiré, matched color/coord counts). - S3/P4: confirm no regression (full-res 128²+ still streams; adaptive downscale still engages on a slow link). ## Risks / notes - **Streaming is synchronous on the preview loop** (rate-limited ≤ fps, off the LED render tick). A slow client is closed (bounded), never an unbounded tick stall. The adaptive downscale shrinks frames on slow links so per-tick send stays small. - **Multi-client**: each pushed slice fans to all clients in order; a forward-only producer (forEachCoord / buffer walk) is walked once per frame, slices sent to all. Fine for the handful of WS clients. - **cpl≠3 (RGBW)** stays a per-light 3-byte push (no buffer); cpl==3 is the bulk 1:1 push. -- This is a net **subtraction**: removes ~3 buffers + the staging/drain code; the colour hot path becomes "stream the buffer." +- This is a net **subtraction**: removes ~3 buffers + the staging/drain code; the color hot path becomes "stream the buffer." diff --git a/docs/history/plans/Plan-20260626 - MoonLive Stage 0 (native codegen spike) (shipped).md b/docs/history/plans/Plan-20260626 - MoonLive Stage 0 (native codegen spike) (shipped).md index ad27a87d..538adf24 100644 --- a/docs/history/plans/Plan-20260626 - MoonLive Stage 0 (native codegen spike) (shipped).md +++ b/docs/history/plans/Plan-20260626 - MoonLive Stage 0 (native codegen spike) (shipped).md @@ -24,7 +24,7 @@ This is "small in depth AND broad": depth = one statement; broad = the whole ver ## Decisions locked with the product owner - **First commit = hand-emitted bytes (Stage −1), then grow into the doc's Stage 0** (a tiny real front-end). Not one-shot Stage 0 — the two risks are separated so a first-pixel failure is unambiguous, and 1a is the test oracle for 2. -- **Solid colour first (1a), then per-frame hue (1b)** — a static fill answers "does our native code run and write the buffer" unambiguously; passing `elapsed()` then proves dynamic input reaches native code, a distinct fact worth isolating. +- **Solid color first (1a), then per-frame hue (1b)** — a static fill answers "does our native code run and write the buffer" unambiguously; passing `elapsed()` then proves dynamic input reaches native code, a distinct fact worth isolating. - **S3 only.** Xtensa backend + the desktop x86-64 backend (needed anyway for in-process unit tests); no other ISA this spike. - **Aligns with the precompiled-effect surface.** The emitted function uses the exact `buffer()` + `nrOfLights()*channelsPerLight()` raw-write surface a compiled effect uses today, so swapping hand-bytes → codegen later changes nothing host-side. If the producer-buffer set/get surface wants to change (the RGB-into-buffer question the product owner flagged), this spike is the cheapest place to discover it. @@ -73,7 +73,7 @@ public: **Binding (`MoonLiveEffect.h`):** a normal `EffectBase`; `setup()`→`engine_.compile()`; `loop()`→ `if (engine_.ok()) engine_.run(buffer(), nrOfLights(), channelsPerLight())`; `teardown()`→`engine_.free()`. Register in `main.cpp` + `scenario_runner.cpp`. -**Acceptance:** desktop unit test — compile, run over a known buffer, assert every light == the fill colour. On the **S3**: add `MoonLiveEffect` to a Layer (via API), grid lights **solid blue**, fps stable, no crash, survives add/delete/replace. +**Acceptance:** desktop unit test — compile, run over a known buffer, assert every light == the fill color. On the **S3**: add `MoonLiveEffect` to a Layer (via API), grid lights **solid blue**, fps stable, no crash, survives add/delete/replace. ### Step 1b — per-frame hue (dynamic input reaches native code) @@ -91,7 +91,7 @@ A bare recursive-descent slice in `src/core/moonlive/` (neutral): tokenize → p - `test/unit/core/unit_moonlive_emit.cpp` — desktop: `compile()` produces a non-empty exec block; `run()` over a known buffer yields the exact fill (golden buffer). Step 2 adds: parsed-source bytes == hand-emitted golden bytes. - `test/unit/core/unit_platform_allocexec.cpp` — `allocExec` returns executable, writable memory; a trivial emitted "return 42" function called through it returns 42 (desktop); nullptr-on-exhaustion path degrades. -- `test/scenarios/light/scenario_moonlive_hello.json` — wire `MoonLiveEffect` as a real MoonModule: add, measure (buffer non-zero == fill colour), delete, re-add (robustness), at a couple grid sizes incl 0×0×0 (no crash). Runs in-process (desktop backend) on every commit; runs live on the S3 over REST for the ISA backend. +- `test/scenarios/light/scenario_moonlive_hello.json` — wire `MoonLiveEffect` as a real MoonModule: add, measure (buffer non-zero == fill color), delete, re-add (robustness), at a couple grid sizes incl 0×0×0 (no crash). Runs in-process (desktop backend) on every commit; runs live on the S3 over REST for the ISA backend. - Robustness: add/delete/replace the scripted effect in any order alongside compiled effects — the hard rule. ## Validation diff --git a/docs/history/plans/Plan-20260627 - MoonLive expressions + host-bound functions (domain-neutral core) (shipped).md b/docs/history/plans/Plan-20260627 - MoonLive expressions + host-bound functions (domain-neutral core) (shipped).md index 64eebfbb..3ee6850b 100644 --- a/docs/history/plans/Plan-20260627 - MoonLive expressions + host-bound functions (domain-neutral core) (shipped).md +++ b/docs/history/plans/Plan-20260627 - MoonLive expressions + host-bound functions (domain-neutral core) (shipped).md @@ -4,8 +4,8 @@ ## The three remarks, one root cause -1. **`setRGB(random16(256), random16(256), 30, 0)` doesn't work** — the parser only allows `random16` in the *index* slot; colour slots are literal-only. Bespoke per-slot rules instead of "every argument is an expression." -2. **`random16(255)` caps at 255** — the index/colour validators conflate ranges; `random16` returns uint16 (0..65535). +1. **`setRGB(random16(256), random16(256), 30, 0)` doesn't work** — the parser only allows `random16` in the *index* slot; color slots are literal-only. Bespoke per-slot rules instead of "every argument is an expression." +2. **`random16(255)` caps at 255** — the index/color validators conflate ranges; `random16` returns uint16 (0..65535). 3. **The core is light-specific** — `setRGB`/`fill`/the `Store` IR op / `buf[i*cpl]` are baked into `src/core/moonlive/`, violating *Domain-neutral core*. The engine should know *language* + *ISA*, never *LEDs*. Root cause: the compiler was built around the *statement shape* (`setRGB(idx, r, g, b)`) rather than around **expressions + a generic call mechanism**. The fix is the architecture ESPLiveScript/ARTI-FX use and the MoonLive doc §3.4 specifies: the core knows expressions + `call(builtin, args…)`; the **host registers the functions**. @@ -68,7 +68,7 @@ expr := number // 0..65535 (uint16) — range checked at ``` - Every argument slot parses an `expr`, so `setRGB(random16(256), random16(256), 30, 0)` works (#1). -- A number literal is a uint16 (0..65535); `random16(N)` accepts N up to 65535 (#2). A value used as a colour is masked to a byte at the store (the inline writer does `& 0xFF`), so out-of-byte colours wrap rather than erroring — consistent, no bespoke per-slot range rule. +- A number literal is a uint16 (0..65535); `random16(N)` accepts N up to 65535 (#2). A value used as a color is masked to a byte at the store (the inline writer does `& 0xFF`), so out-of-byte colors wrap rather than erroring — consistent, no bespoke per-slot range rule. - Each `expr` lowers to a vreg (a `Const`, or a `Call` result). `setRGB`/`fill` then consume those vregs via their InlineOp. The bounds guard wraps the inline write as before. ## Steps (desktop-first, each green) @@ -83,7 +83,7 @@ expr := number // 0..65535 (uint16) — range checked at ## Validation - Desktop: `setRGB(random16(256), random16(256), 30, 0)` writes a random pixel with a random red+green; `random16(65535)` accepted; behavioral golden (fill output unchanged) holds. All unit tests green. -- **Domain-neutral check** (the #3 fix, mechanised): a test/grep asserts `src/core/moonlive/` contains no LED vocabulary ("setRGB", "fill", "RGB" in the colour sense, "cpl"/"buffer" semantics) — only `Call`, `InlineOp`, arithmetic, the neutral opcode enum. +- **Domain-neutral check** (the #3 fix, mechanised): a test/grep asserts `src/core/moonlive/` contains no LED vocabulary ("setRGB", "fill", "RGB" in the color sense, "cpl"/"buffer" semantics) — only `Call`, `InlineOp`, arithmetic, the neutral opcode enum. - Xtensa: the failing cases from the remarks work live on the Olimex; no crash. - P4/RISC-V still builds (stub). diff --git a/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md b/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md index 7b98dc27..8891c2db 100644 --- a/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md +++ b/docs/history/plans/Plan-20260630 - HueDriver (Hue lights as an effect output) (shipped).md @@ -2,7 +2,7 @@ ## Context -The product owner has Hue lights and a bridge ("Hue Ewoud", BSB002, API 1.77, at 192.168.1.143). The reframe that drives this plan, from the product owner: **Hue is an *output*, not a device to list.** projectMM already drives "an array of lights" through the effect → layout → buffer → driver pipeline; Hue maps onto that directly — a handful of bulbs are a small **grid** (e.g. 5×1×1), an **effect** runs on them, and a **`HueDriver`** (a sibling of `RmtLedDriver` / `NetworkSendDriver` in the Drivers container) reads its window of the output buffer and pushes each pixel's colour to the corresponding bulb. The bulbs are *pixels of an effect*, not rows in DevicesModule. +The product owner has Hue lights and a bridge ("Hue Ewoud", BSB002, API 1.77, at 192.168.1.143). The reframe that drives this plan, from the product owner: **Hue is an *output*, not a device to list.** projectMM already drives "an array of lights" through the effect → layout → buffer → driver pipeline; Hue maps onto that directly — a handful of bulbs are a small **grid** (e.g. 5×1×1), an **effect** runs on them, and a **`HueDriver`** (a sibling of `RmtLedDriver` / `NetworkSendDriver` in the Drivers container) reads its window of the output buffer and pushes each pixel's color to the corresponding bulb. The bulbs are *pixels of an effect*, not rows in DevicesModule. This is *Common patterns first* + *Concrete first, abstract later*: a new driver is the recognised unit of "a new output target," and the architecture already has the seam. No new core concept — one new `DriverBase` subclass + a small outbound-HTTP helper. @@ -15,8 +15,8 @@ This is *Common patterns first* + *Concrete first, abstract later*: a new driver ## Decisions locked (product owner) - **Hue is an output driver** (`HueDriver : DriverBase`), sibling of `NetworkSendDriver`. Not a DevicesModule entry. (Listing the bridge in DevicesModule + auto-filling the driver's IP from discovery is a **follow-up**, per *concrete first* — build the working output, add the discovery nicety after.) -- **Scope: on/off + brightness** from the effect's per-pixel value (luminance → `bri`). Colour (`xy`/`hue`/`sat`) is a clean later extension on the same PUT. -- **Update model: throttled, changed-lights-only.** Hue's bridge rate-limits to ~10 commands/s/light; a real-time stream would need the Entertainment API (DTLS) — out of scope. The driver samples its window on a **slow tick** (target ≤ ~10 Hz total across its lights) and PUTs **only the lights whose colour changed** since the last push. This is the standard way apps drive Hue from animations. +- **Scope: on/off + brightness** from the effect's per-pixel value (luminance → `bri`). Color (`xy`/`hue`/`sat`) is a clean later extension on the same PUT. +- **Update model: throttled, changed-lights-only.** Hue's bridge rate-limits to ~10 commands/s/light; a real-time stream would need the Entertainment API (DTLS) — out of scope. The driver samples its window on a **slow tick** (target ≤ ~10 Hz total across its lights) and PUTs **only the lights whose color changed** since the last push. This is the standard way apps drive Hue from animations. - **Plain-HTTP Hue v1 API** (no TLS). The bridge IP + app key are **controls on the HueDriver** (self-contained config, like NetworkSendDriver owns its target IP/universe); a **Pair button** runs the link-button POST to fill the app key. Persisted with the module. ## Design @@ -60,7 +60,7 @@ Header-only light module, mirroring `NetworkSendDriver`'s shape: ## Files - **New:** `src/light/drivers/HueDriver.h` (the driver), `docs/moonmodules/light/drivers/HueDriver.md` (spec — controls, the Hue v1 wire contract, pairing flow, the rate-limit rationale, prior art). -- **Edit:** `src/platform/platform.h` (+ `src/platform/esp32/` + `src/platform/desktop/` impls) for `httpRequest`; the driver registration in `src/main.cpp`; `test/CMakeLists.txt` + a `test/unit/light/unit_HueDriver.cpp` (request formatting + changed-only diff + window mapping); `docs/backlog/backlog-light.md` (mark the Hue-driver item building / add the follow-ups: colour, DevicesModule bridge discovery, Entertainment-API streaming). +- **Edit:** `src/platform/platform.h` (+ `src/platform/esp32/` + `src/platform/desktop/` impls) for `httpRequest`; the driver registration in `src/main.cpp`; `test/CMakeLists.txt` + a `test/unit/light/unit_HueDriver.cpp` (request formatting + changed-only diff + window mapping); `docs/backlog/backlog-light.md` (mark the Hue-driver item building / add the follow-ups: color, DevicesModule bridge discovery, Entertainment-API streaming). ## Riskiest parts @@ -77,7 +77,7 @@ Header-only light module, mirroring `NetworkSendDriver`'s shape: ## Out of scope (clean follow-ups) -- **Colour** (`xy` / `hue`/`sat` from the pixel RGB) — same PUT, one more field; the obvious next slice. +- **Color** (`xy` / `hue`/`sat` from the pixel RGB) — same PUT, one more field; the obvious next slice. - **DevicesModule lists the Hue bridge** + auto-fills the driver's `bridgeIp` from discovery (the product owner's "list it in devices" idea, done as the second step — discovery feeds the output). - **Hue Entertainment API** (DTLS streaming, ~25–50 Hz) for true real-time effect sync — a major separate feature (TLS-PSK on ESP32, entertainment-area setup, v2 API). - **DMX lights** as another such output driver (the product owner noted this is coming — Hue maps the "array of foreign lights" pattern that DMX will reuse). diff --git a/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md b/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md index e640c086..6e981bb9 100644 --- a/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md +++ b/docs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).md @@ -9,7 +9,7 @@ Bring MoonLight's full library of **effects, modifiers and layouts** into projec Two cross-cutting rules govern every stage, from [CLAUDE.md](../../../CLAUDE.md): - **Industry standards, our own code.** MoonLight effects are studied for *behaviour and algorithm*, then written **fresh** against our architecture (our `EffectBase`, our primitives, our names). We do **not** trace MoonLight/WLED/FastLED structure or copy code. For *effects specifically* the **visual behaviour is the spec** — we reproduce what the effect looks like faithfully (the product owner's clarification), but the implementation is ours. Prior art credited per-module + in `history/`. -- **A shared light primitive library.** Effects need a common set of small math/colour helpers (a beat/sine oscillator, integer noise, saturating add/subtract, scale, fade, a colour blend, a fast PRNG, draw primitives). projectMM provides these, extending the `color.h` set (`scale8`, `sin8`, `cos8`, `hsvToRgb` already there): **hot-path-tuned** (integer-only, LUT-backed, no float in the per-light path) and **dimension-agnostic where it makes sense** (the product owner's steer: our 3D-native model means a primitive like `drawLine` works 1D→3D, written once, not re-implemented per effect). +- **A shared light primitive library.** Effects need a common set of small math/color helpers (a beat/sine oscillator, integer noise, saturating add/subtract, scale, fade, a color blend, a fast PRNG, draw primitives). projectMM provides these, extending the `color.h` set (`scale8`, `sin8`, `cos8`, `hsvToRgb` already there): **hot-path-tuned** (integer-only, LUT-backed, no float in the per-light path) and **dimension-agnostic where it makes sense** (the product owner's steer: our 3D-native model means a primitive like `drawLine` works 1D→3D, written once, not re-implemented per effect). - **Naming follows *Common patterns first* + *Industry standards, our own code*: the recognisable name AND our own implementation.** The LED-embedded world's canonical resource is FastLED, and its names (`beatsin8`, `inoise8`, `qadd8`, `nscale8`, `random8`/`random16`, `ColorFromPalette`) are exactly the ones a contributor recognises in 30 seconds — and consistent with the `scale8`/`sin8` we already ship. So **we use those names** (carrying the established convention), **write our own implementation** against our engine, and **credit FastLED as prior art** in each module's "Prior art" section. The point of the principle is independence-by-construction (own code, own architecture, behaviour pinned by tests), *not* a renamed copy — so the names stay recognisable; only the implementation is ours. Each primitive's design is justified at its introduction site, and we reorganise a borrowed concept when ours is genuinely cleaner (e.g. the dimension-agnostic draw set). ## What exists today (baseline) @@ -24,7 +24,7 @@ Two cross-cutting rules govern every stage, from [CLAUDE.md](../../../CLAUDE.md) ## Dependency analysis (what must come first) -1. **Palette** — hard prerequisite. Many MoonLight effects colour via `ColorFromPalette`. Nothing palette-dependent can be faithfully ported until this lands. **Stage 1.** +1. **Palette** — hard prerequisite. Many MoonLight effects color via `ColorFromPalette`. Nothing palette-dependent can be faithfully ported until this lands. **Stage 1.** 2. **The shared primitive library** (beat / noise / blend / scale / random / draw) — most effects need several. **Stage 1.** 3. **Tags/emoji legend** — must be settled before batch-migrating, so every migrated module is consistent from the first batch. Cheap; **Stage 1** (a doc + a sweep of existing `tags()`). 4. **Doc model change** — must land before the doc explosion, i.e. before batch migration. A page per **library** (type-first name, underscore-joined): `effects_moonlight.md`, `effects_wled.md`, … (and `modifiers_<lib>.md` etc. only where a library has them; most are effects-only). Library is a *doc* split only — NOT a `src`/`assets`/`tests` folder (those stay `domain/type` flat; library is the `tags()` emoji there). Fixed by the [folder-structure decision](../../adr/0015-library-is-a-tag-not-a-folder.md). **Stage 2**. @@ -48,7 +48,7 @@ The proving-ground stage: build the shared tools, prove them on one hard effect. - *blend/scale:* `qadd8`/`qsub8` (saturating), `nscale8`, `fadeToBlackBy`, `blend(RGB, RGB, amt)` (`scale8` already in `color.h`). - *random:* `random8`/`random16` — a small fast seedable PRNG, hot-path-cheap (not `std::rand`). - *draw (the dimension-agnostic part the product owner called out):* `drawPixel`/`drawLine` (and later `drawCircle`/fill) operating on `Coord3D`, working **1D→3D** against the `Buffer`, so effects and modifiers share one set instead of re-rolling Bresenham per effect. This is the "core absorbs the hard part" principle — geometry primitives live once. -- **Re-port Game of Life** properly — the *real* MoonLight GoL algorithm (the cellular-automaton rules + its palette colouring + blur/mutation it actually uses), on top of the new palette + primitives, replacing the current 272-line version. This is the stage's proof: a real effect that exercises palette + random + neighbour math, done faithfully. +- **Re-port Game of Life** properly — the *real* MoonLight GoL algorithm (the cellular-automaton rules + its palette coloring + blur/mutation it actually uses), on top of the new palette + primitives, replacing the current 272-line version. This is the stage's proof: a real effect that exercises palette + random + neighbour math, done faithfully. - **Tags/emoji legend.** Write the canonical legend (MoonLight as basis) into architecture.md § Web UI / a tags reference, and sweep existing effects' `tags()` to match. Lightweight. Stage-1 exit: palette + primitives compile (-Werror), are unit-tested (each primitive pinned: `beatsin8` range, `inoise8` determinism, `qadd8` saturation, `drawLine` endpoints in 1D/2D/3D), GoL re-port renders correctly + has a scenario, tags legend documented. **No doc explosion yet** (GoL keeps its existing single `.md`; the doc-model change is Stage 2). @@ -69,7 +69,7 @@ Stage-2 exit: the library pages render with gifs, `check_specs.py` green on the With foundations + doc model in place, migrate MoonLight effects in **themed batches**, each a stage/commit: study behaviour → write fresh on our primitives → unit + scenario test → add to `effects.md` + gif. Batching keeps each commit reviewable. **Scope: ALL effects across MoonLight's `Nodes/Effects/E_*.h` files**, not a cherry-picked subset — the [breadth-parity gate](../../backlog/rename-to-moonlight.md) needs the full set. The source files (each an effect library, mapped to our origin sections + future per-library doc pages): -- **`E_MoonModules.h`** (MoonModules-authored, 3): **GameOfLife** (Conway, 2D/3D, rulesets/wrap/colour-aging/infinite-mode), **GEQ3D** ♫ (perspective 3D equalizer bars), **PaintBrush** ♫ (frequency-modulated animated lines, chaos/softness). — verified 2026-06-30 from source. +- **`E_MoonModules.h`** (MoonModules-authored, 3): **GameOfLife** (Conway, 2D/3D, rulesets/wrap/color-aging/infinite-mode), **GEQ3D** ♫ (perspective 3D equalizer bars), **PaintBrush** ♫ (frequency-modulated animated lines, chaos/softness). — verified 2026-06-30 from source. - **`E_MoonLight.h`** (MoonLight-original geometric set). - **`E_WLED.h`** (WLED ports/enhancements). - moving-head / DMX effect files → Stage 5. @@ -96,7 +96,7 @@ The MoonLight modifiers (mirror/tile/kaleidoscope/pinwheel/transpose…) and lay 2. **Primitive implementation is ours** — the temptation under deadline is to copy a source's implementation, not just its recognisable name. The names follow the established FastLED convention (what a contributor recognises); the *code* is written fresh against our engine, behaviour pinned by tests, FastLED credited as prior art. Guard: independence-by-construction (own implementation + own architecture), not a renamed copy and not a traced one. 3. **Dimension-agnostic draw** — making `drawLine` etc. genuinely 1D→3D (not 2D with a z-loop bolted on) needs thought; get the abstraction right in Stage 1 or effects will work around it. 4. **Doc-model migration is a one-way door** — deleting 21 per-module `.md`s and rewriting the spec-check; do it as one coherent Stage-2 change, not piecemeal, so docs are never half-migrated. -5. **GoL "done right"** — we already got it wrong once; Stage 1 must pin the real algorithm against a reference (the actual rules + colouring), tested, so it's faithful this time. +5. **GoL "done right"** — we already got it wrong once; Stage 1 must pin the real algorithm against a reference (the actual rules + coloring), tested, so it's faithful this time. 6. **Scope discipline** — "migrate all of it" is dozens of modules. The batching is what keeps it from becoming one un-reviewable mega-diff; resist merging batches. ## Verification (per stage) diff --git a/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md b/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md index 1e42593a..fd852a6c 100644 --- a/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md +++ b/docs/history/plans/Plan-20260630 - Stage 1 palette (shipped).md @@ -25,13 +25,13 @@ The first executable slice of the [migration plan](Plan-20260630%20-%20MoonLight ## Riskiest parts -1. **The expansion + lookup must be correct *and* cheap** — pin both with tests (endpoints exact, a mid-gradient colour interpolates, the wheel wraps at 255→0, brightness folds correctly). It runs per-light, so eyeball the KPI tick after wiring PlasmaPalette. +1. **The expansion + lookup must be correct *and* cheap** — pin both with tests (endpoints exact, a mid-gradient color interpolates, the wheel wraps at 255→0, brightness folds correctly). It runs per-light, so eyeball the KPI tick after wiring PlasmaPalette. 2. **`colorFromPalette` dispatchable without a per-pixel cost** — Stage 1 has only the gradient case, so it's a direct call now; the *interface shape* (so MoonLivePalette slots in later) must not impose a per-pixel branch today. Keep it a plain function over the 16-entry `Palette`; the static/dynamic dispatch is a later per-frame concern, not built now. 3. **PlasmaPalette visual change** — it goes from a fixed palette to the active one; confirm it still looks good on the default palette and that the effect's index math still maps sensibly. ## Verification -Desktop build (-Werror); `ctest` incl. `unit_Palette` (expand/lookup/wrap/brightness) + the existing PlasmaPalette test still green; scenarios; spec-check (new `Palette.md`); ESP32 build; KPI (watch the per-light cost — PlasmaPalette is the canary). Bench: on desktop, switch the `palette` control and confirm PlasmaPalette (and the preview) recolours live. +Desktop build (-Werror); `ctest` incl. `unit_Palette` (expand/lookup/wrap/brightness) + the existing PlasmaPalette test still green; scenarios; spec-check (new `Palette.md`); ESP32 build; KPI (watch the per-light cost — PlasmaPalette is the canary). Bench: on desktop, switch the `palette` control and confirm PlasmaPalette (and the preview) recolors live. ## Out of scope (later Stage-1 slices / later stages) diff --git a/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md b/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md index 44f906ec..b0c950b9 100644 --- a/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md +++ b/docs/history/plans/Plan-20260630 - Stage 3 E_MoonModules batch (GameOfLife + GEQ3D + PaintBrush) (shipped).md @@ -15,8 +15,8 @@ The first effect-migration batch of [MoonLight migration Stage 3](./Plan-2026063 ### GameOfLifeEffect (2D/3D, 💫🌙 MoonModules) - **Controls:** `backgroundColor` (Coord3D/RGB), `ruleset` (select: Custom/Conway B3-S23/HighLife/InverseLife/Maze/Mazecentric/DrighLife), `customRule` (text "B#/S#"), `speed` (0-100, gen/s), `density` (10-90% initial life), `mutation` (0-100%), `wrap` (bool), `colorByAge` (bool: green→red), `infinite` (bool: respawn), `blur` (0-255 dead-cell fade). (`disablePause` dropped — UI nicety, not behaviour.) - **State (heap, onBuildState — like Fire's heat_/Wave's trail_):** `cells_`/`future_` bit-packed (count/8 bytes each), `colors_` palette-index per cell (count bytes), `generation_`, `step_` ms, parsed `birth_[9]`/`survive_[9]` bool arrays, CRC history (`oscCrc_`/`shipCrc_`) for stasis detection. ~20 KB at 128² → PSRAM via `platform::alloc`, freed in teardown + dtor. NOT inline members (the HueDriver stack lesson). -- **Algorithm:** gen 0 = random fill by `density`, random palette colour per live cell. Each gen (throttled by `speed`): count neighbours (8 in 2D, 26 in 3D, `wrap` toroidal), apply `birth_/survive_`, newborns inherit a neighbour's colour with `mutation` chance of a fresh one; dead cells fade toward `backgroundColor` by `blur`. `colorByAge` overrides colour (green new → red aging). After each gen, CRC16 the grid; on detected oscillation/extinction, `infinite` ? place an R-pentomino/glider : reset to gen 0. -- **New primitive needed:** `crc16` (textbook CCITT) — add to a small `core/crc.h` (a hash, not math8; reusable, ~15 lines). Uses `Random8` (math8) for fill/mutation/respawn placement, `colorFromPalette` for colour, `fadeToBlackBy`/`blend` for the dead-cell blur. +- **Algorithm:** gen 0 = random fill by `density`, random palette color per live cell. Each gen (throttled by `speed`): count neighbours (8 in 2D, 26 in 3D, `wrap` toroidal), apply `birth_/survive_`, newborns inherit a neighbour's color with `mutation` chance of a fresh one; dead cells fade toward `backgroundColor` by `blur`. `colorByAge` overrides color (green new → red aging). After each gen, CRC16 the grid; on detected oscillation/extinction, `infinite` ? place an R-pentomino/glider : reset to gen 0. +- **New primitive needed:** `crc16` (textbook CCITT) — add to a small `core/crc.h` (a hash, not math8; reusable, ~15 lines). Uses `Random8` (math8) for fill/mutation/respawn placement, `colorFromPalette` for color, `fadeToBlackBy`/`blend` for the dead-cell blur. ### GEQ3DEffect (2D, audio ♫, 💫🌙) - **Controls:** `speed` (1-10 projector sweep), `frontFill` (0-255), `horizon` (0..width-1 vanishing row), `depth` (0-255 perspective), `numBands` (2-16), `borders` (bool). (`softHack` dropped.) @@ -26,7 +26,7 @@ The first effect-migration batch of [MoonLight migration Stage 3](./Plan-2026063 ### PaintBrushEffect (3D, audio ♫, 💫🌙) - **Controls:** `oscillatorOffset` (0-16 phase mult), `numLines` (2-255), `fadeRate` (0-128 bg decay), `minLength` (0-255 draw threshold), `colorChaos` (bool per-line hue), `phaseChaos` (bool per-frame jitter). (`soft` dropped.) - **State:** `hue_` (cycles per frame), `chaos_` (per-frame random phase or 0). -- **Algorithm:** each frame: advance `hue_`, fade the whole field by `fadeRate` (`fadeToBlackBy` over the buffer). Per line (0..numLines): map line→audio band; build two 3D endpoints via `beatsin8(... oscillatorOffset, ms)` modulated by band amplitude; Euclidean distance × band magnitude = length; if length > `minLength` draw `draw::line(a, b, colour)`. Colour = `colorChaos` ? per-line hue+`hue_` : per-band gradient. Uses `beatsin8` (math8), `AudioFrame::bands[]`, `draw::line`, `fadeToBlackBy`. +- **Algorithm:** each frame: advance `hue_`, fade the whole field by `fadeRate` (`fadeToBlackBy` over the buffer). Per line (0..numLines): map line→audio band; build two 3D endpoints via `beatsin8(... oscillatorOffset, ms)` modulated by band amplitude; Euclidean distance × band magnitude = length; if length > `minLength` draw `draw::line(a, b, color)`. Color = `colorChaos` ? per-line hue+`hue_` : per-band gradient. Uses `beatsin8` (math8), `AudioFrame::bands[]`, `draw::line`, `fadeToBlackBy`. ## Files diff --git a/docs/history/plans/Plan-20260706 - Home Assistant MQTT Discovery (shipped).md b/docs/history/plans/Plan-20260706 - Home Assistant MQTT Discovery (shipped).md index 10a2c1f9..3ba1a25f 100644 --- a/docs/history/plans/Plan-20260706 - Home Assistant MQTT Discovery (shipped).md +++ b/docs/history/plans/Plan-20260706 - Home Assistant MQTT Discovery (shipped).md @@ -14,7 +14,7 @@ gap, not a capability gap**. The device is *controllable* from HA today; it just correctly-wired light entity. **JSON schema** (PO decision), matching the modern auto-discovery peer group (Tasmota / ESPHome / Zigbee2MQTT) — *Common patterns first* — and chosen for **extensibility**: a JSON light carries all state in one atomic message and has native `effect`/`effect_list`, so future -controls (presets, effects, palette-as-colour) add a key, not a new topic + custom HA config. The +controls (presets, effects, palette-as-color) add a key, not a new topic + custom HA config. The existing `projectMM/<mac6>/…` mqttthing topics stay byte-identical (the user's working setup is untouched); Discovery lands *alongside* them. @@ -118,7 +118,7 @@ ungraceful drop — no polling/timers on our side. Zero hot-path / memory cost. JSON schema grows by adding a key to the config + the state/command JSON — no new topic, no new HA entity type. Concretely: **presets/effects** → `effect_list:[…]` in the config + `{"effect":"Fire"}` -on the wire (HA renders a dropdown natively); **colour** (when palette→colour matures) → +on the wire (HA renders a dropdown natively); **color** (when palette→color matures) → `{"color":{"h":…,"s":…}}`. The default schema has no `effect` support at all — this is the deciding reason for JSON. diff --git a/docs/history/plans/Plan-20260709 - PinsModule (ownership map, strap-conflict flags, live-state; 4 increments) (shipped).md b/docs/history/plans/Plan-20260709 - PinsModule (ownership map, strap-conflict flags, live-state; 4 increments) (shipped).md index 72afb096..735c2e10 100644 --- a/docs/history/plans/Plan-20260709 - PinsModule (ownership map, strap-conflict flags, live-state; 4 increments) (shipped).md +++ b/docs/history/plans/Plan-20260709 - PinsModule (ownership map, strap-conflict flags, live-state; 4 increments) (shipped).md @@ -26,4 +26,4 @@ Adds the **second axis** — *what is GPIO N doing right now.* Per §6 this is * ## The through-line (why these four cohere) -Each increment is read-only and additive on the last, and the recurring discipline is the **UI-sidestep rule**: every richer-UI need was met by a *generic* list affordance (the `severity`→colour convention in #2, reused unchanged by #3; the scalar-fields path needing nothing in #4), never a pins-specific control. The map surfaces ownership (#1), safety (#2), conflicts (#3), and live electrical state (#4) — and at no point becomes an allocation subsystem or a policy engine, exactly the scope guard the top-down study draws. Later phases (reject-on-add on the installer path, the `pinConflicts()` validator authority, output-suppression, the board-diagram view, ADC/continuity live-state) remain in the [top-down study](../../backlog/pins-analysis-top-down.md) as the forward scope. +Each increment is read-only and additive on the last, and the recurring discipline is the **UI-sidestep rule**: every richer-UI need was met by a *generic* list affordance (the `severity`→color convention in #2, reused unchanged by #3; the scalar-fields path needing nothing in #4), never a pins-specific control. The map surfaces ownership (#1), safety (#2), conflicts (#3), and live electrical state (#4) — and at no point becomes an allocation subsystem or a policy engine, exactly the scope guard the top-down study draws. Later phases (reject-on-add on the installer path, the `pinConflicts()` validator authority, output-suppression, the board-diagram view, ADC/continuity live-state) remain in the [top-down study](../../backlog/pins-analysis-top-down.md) as the forward scope. diff --git a/docs/history/plans/Plan-20260711 - Flexible light profile (channel-role offsets) (shipped).md b/docs/history/plans/Plan-20260711 - Flexible light profile (channel-role offsets) (shipped).md index 9f352a56..a2b75ca4 100644 --- a/docs/history/plans/Plan-20260711 - Flexible light profile (channel-role offsets) (shipped).md +++ b/docs/history/plans/Plan-20260711 - Flexible light profile (channel-role offsets) (shipped).md @@ -36,7 +36,7 @@ static constexpr uint8_t kAbsent = 255; // role not present on this fixture struct Correction { uint8_t briLut[256]; uint8_t channelsPerLight = 3; // fixture width (3 strip … up to 32 moving head) - // RGB(W) roles — the SOURCE→OUTPUT offset for each colour role. kAbsent = not emitted. + // RGB(W) roles — the SOURCE→OUTPUT offset for each color role. kAbsent = not emitted. uint8_t offRed = 1, offGreen = 0, offBlue = 2; // GRB default (matches today's default 2) uint8_t offWhite = kAbsent; bool deriveWhite = false; diff --git a/docs/history/plans/Plan-20260711 - Migrate MoonLight fixture presets as built-ins (shipped).md b/docs/history/plans/Plan-20260711 - Migrate MoonLight fixture presets as built-ins (shipped).md index bfb516ba..399254e9 100644 --- a/docs/history/plans/Plan-20260711 - Migrate MoonLight fixture presets as built-ins (shipped).md +++ b/docs/history/plans/Plan-20260711 - Migrate MoonLight fixture presets as built-ins (shipped).md @@ -4,11 +4,11 @@ MoonLight's `DriverNode.cpp` (@ `6586921770`) defines 17 `lightPreset_*` fixture channel-maps via named offsets (`offsetRed`, `offsetPan`, `offsetRGBW`, `offsetBrightness`, …). projectMM's `LightPresetsModule` seeds only 5 (RGB/GRB/BGR/RGBW/GRBW) as read-only `locked` rows. The product owner selected which to migrate under three tiers, with these resolved decisions: -- **Tier A (colour orders): WRGB only.** MoonLight's own comments name real hardware only for WRGB ("rgbw ws2814 LEDs"). RBG/GBR/BRG are bare permutations with no named fixture → **not seeded** (a user adds a custom preset if they ever hit one). +- **Tier A (color orders): WRGB only.** MoonLight's own comments name real hardware only for WRGB ("rgbw ws2814 LEDs"). RBG/GBR/BRG are bare permutations with no named fixture → **not seeded** (a user adds a custom preset if they ever hit one). - **Tier B (multi-channel LED/par): migrate GRB6, RGBWYP, RGBCCT, IRGB.** - **Tier C (moving heads): migrate BeeEyes-15, BeTopper-32, 19x15W-24, tagging only channels whose role we support**; everything else `None`. - **Intensity/Brightness master channel → `Dimmer`** (existing role; inert until moving-head effect writers land — a correct map that nothing animates yet). -- **Extend the colour vocabulary with `WarmWhite`, `Yellow`, `UV`** so RGBCCT (cold+warm white) and RGBWYP (adds Y+UV) migrate fully rather than half-dark. The existing `White` is kept as-is (a normal/cold white) — NOT renamed to ColdWhite — so existing RGBW/GRBW presets' persisted role bytes and labels are unchanged; the second white is the new `WarmWhite`. Industry naming (CW/WW), our PascalCase (`White`/`WarmWhite`), option strings `"W"`/`"WW"`. +- **Extend the color vocabulary with `WarmWhite`, `Yellow`, `UV`** so RGBCCT (cold+warm white) and RGBWYP (adds Y+UV) migrate fully rather than half-dark. The existing `White` is kept as-is (a normal/cold white) — NOT renamed to ColdWhite — so existing RGBW/GRBW presets' persisted role bytes and labels are unchanged; the second white is the new `WarmWhite`. Industry naming (CW/WW), our PascalCase (`White`/`WarmWhite`), option strings `"W"`/`"WW"`. Design record for the LightPresets library itself: [Plan-20260711 - LightPresets reusable named-preset library (shipped).md](Plan-20260711%20-%20LightPresets%20reusable%20named-preset%20library%20(shipped).md). @@ -22,19 +22,19 @@ Design record for the LightPresets library itself: [Plan-20260711 - LightPresets ## Design -### 1. Extend the colour vocabulary — `src/light/ChannelRole.h` +### 1. Extend the color vocabulary — `src/light/ChannelRole.h` -Append three colour roles after `White`, before the fixture roles, so plain strips still use the low values and existing persisted role bytes are unchanged (append-only — never renumber): +Append three color roles after `White`, before the fixture roles, so plain strips still use the low values and existing persisted role bytes are unchanged (append-only — never renumber): ```cpp enum class ChannelRole : uint8_t { None, - Red, Green, Blue, White, WarmWhite, Yellow, UV, // colour roles (White = normal/cold) + Red, Green, Blue, White, WarmWhite, Yellow, UV, // color roles (White = normal/cold) Pan, Tilt, Zoom, Rotate, Gobo, Dimmer, // fixture roles }; ``` -⚠️ **Append-only is load-bearing.** Persisted presets store role bytes as these indices. Inserting WarmWhite/Yellow/UV *between* the existing colour roles and the fixture roles shifts Pan…Dimmer up by 3 — which silently corrupts any persisted moving-head custom a user already made. Verified safe here because the fixture roles have **no seeded built-ins yet and no effect writers**, so no persisted data references them. `White` keeps index 4, so every existing RGBW/GRBW preset's persisted bytes are untouched. (If a fixture role were already persisted, the new roles would have to go at the END.) Note this reasoning at the enum. +⚠️ **Append-only is load-bearing.** Persisted presets store role bytes as these indices. Inserting WarmWhite/Yellow/UV *between* the existing color roles and the fixture roles shifts Pan…Dimmer up by 3 — which silently corrupts any persisted moving-head custom a user already made. Verified safe here because the fixture roles have **no seeded built-ins yet and no effect writers**, so no persisted data references them. `White` keeps index 4, so every existing RGBW/GRBW preset's persisted bytes are untouched. (If a fixture role were already persisted, the new roles would have to go at the END.) Note this reasoning at the enum. `kChannelRoleOptions[]` gets the matching strings in the same slots: `"WW","Y","UV"` after `"W"`. Update `presetHasWhite` to also match `WarmWhite`. @@ -84,11 +84,11 @@ That's **5 existing + 8 new = 13 seeded built-ins** (WRGB, GRB6, RGBWYP, RGBCCT, ## Files -- **`src/light/ChannelRole.h`** — +3 colour roles (White2, Yellow, UV) + option strings; append-only note. +- **`src/light/ChannelRole.h`** — +3 color roles (White2, Yellow, UV) + option strings; append-only note. - **`src/light/drivers/LightPresetsModule.h`** — data-driven `seedBuiltins()` (role-array table, width-agnostic); `presetHasWhite` also matches White2; skip-comment for the un-migrated orders. - **`src/light/drivers/Correction.h`** — no change needed for seeding (the library owns its own seeds now). *If* `fillRolesFromPreset`/`LightPreset` end up with zero remaining callers after step 2, remove them (subtraction) — verify with grep first; out of scope if `DriverBase` still uses them. - **`test/unit/light/unit_LightPresetsModule.cpp`** — assert the new built-in count + names, WRGB/RGBCCT/IRGB role arrays, White2 counts as white in `presetHasWhite`, a moving-head preset's width + tagged channels (Pan@0 etc.) + that unsupported channels are `None`, and that built-ins stay `locked`/unmovable. -- **`test/unit/light/unit_Correction.cpp`** — if roles were appended, assert an existing persisted role byte still resolves to the same colour (append-didn't-shift regression). +- **`test/unit/light/unit_Correction.cpp`** — if roles were appended, assert an existing persisted role byte still resolves to the same color (append-didn't-shift regression). - **Docs:** `docs/moonmodules/light/supporting.md` — refresh the LightPresets card's built-in list (it names "RGB, GRB, BGR, RGBW, GRBW"). The `///` on the module + the generated technical page follow. ## Verification @@ -101,4 +101,4 @@ That's **5 existing + 8 new = 13 seeded built-ins** (WRGB, GRB6, RGBWYP, RGBCCT, ## Scope guard -Channel-map migration + colour-vocabulary extension ONLY. Do NOT add effect-side fixture writers (`setPan`…), the multi-RGBW-cell concept, or the RGB2040 layout remap — those are the deferred moving-head *effect/fixture-model* increment this seeds toward. Seeding a moving-head preset gives a correct DMX map that current effects drive only on its R/G/B/W channels; Pan/Tilt/Zoom/Gobo/Dimmer stay inert until writers land. That inertness is expected, not a bug. +Channel-map migration + color-vocabulary extension ONLY. Do NOT add effect-side fixture writers (`setPan`…), the multi-RGBW-cell concept, or the RGB2040 layout remap — those are the deferred moving-head *effect/fixture-model* increment this seeds toward. Seeding a moving-head preset gives a correct DMX map that current effects drive only on its R/G/B/W channels; Pan/Tilt/Zoom/Gobo/Dimmer stay inert until writers land. That inertness is expected, not a bug. diff --git a/docs/history/plans/Plan-20260726 - S31 RGMII eth DHCP at 100M.md b/docs/history/plans/Plan-20260726 - S31 RGMII eth DHCP at 100M.md new file mode 100644 index 00000000..34a20f11 --- /dev/null +++ b/docs/history/plans/Plan-20260726 - S31 RGMII eth DHCP at 100M.md @@ -0,0 +1,70 @@ +# Plan: ESP32-S31 RGMII Ethernet DHCP at 100 Mbps (fix the TXC-speed mismatch) + +## Context + +The ESP32-S31's on-chip EMAC is 1 Gb RGMII (YT8531 PHY). On a **gigabit** switch it leases fine (link at 1000M, MAC Tx clock TXC = 125 MHz, the driver's install default). On a **10/100** switch — like the bench GL-AR300M — the link negotiates to **100M**, where RGMII requires **TXC = 25 MHz**. The MAC never gets reconfigured to 25 MHz, so every Tx frame clocks out garbled and the switch drops it. Symptom: link up, DHCP DISCOVER sent, **no OFFER ever** → `NetworkModule: Ethernet no IP (DHCP timeout), cascading` → falls back to WiFi. + +**This is not a regression.** Verified on hardware at commit `d5ee07c` built against its exact pinned IDF (`0d928780081` / v6.1-dev-5215, confirmed via both `setup_esp_idf.py` and `release.yml:169`): eth DHCP times out on the 10/100 router there too. Eth has **never** worked at 100M on this firmware; the earlier "DHCP confirmed" success was on a 1 Gb switch. So the goal is a genuine new capability: **eth DHCP at 100M**, without regressing 1000M. + +**Root cause (confirmed by IDF source trace).** The MAC's `emac_esp32_set_speed` (`esp_eth_mac_esp.c:422`) reprograms TXC per link rate. It runs **only** via `on_state_changed(ETH_STATE_SPEED)`, emitted from the generic 802.3 PHY driver's `updt_link_dup_spd` (`esp_eth_phy_802_3.c:217`) — and that is gated on a link_status **transition** (`if (phy_802_3->link_status != link)`, `:235`). Our firmware manually re-enables the YT8531's auto-negotiation (a documented YT8531-reset quirk) *before* `esp_eth_start`; negotiation can complete and latch link-UP **before** the PHY poll task's first read, so the poll sees no transition, so `set_speed(100M)` never runs, so TXC stays at its 125 MHz install default. RX still works (it rides the PHY-recovered RXC), which is why DHCP OFFERs to the *WiFi* interface kept masking this. + +**Why the current WIP is not the final fix.** The stashed WIP corrects TXC with an `esp_eth_stop()` + `esp_eth_start()` bounce placed after the netif glue is attached. It *did* produce the first-ever eth lease (`.125`) — proving the mechanism — but `esp_eth_stop` unconditionally tears the netif down: it releases the DHCP lease, resets `dhcpc_status` to INIT, and sets the netif IP to `0.0.0.0` (`esp_netif_lwip.c:1308-1360, 1899-1948`). That re-acquisition races the NetworkModule cascade's 15 s eth-DHCP window and the double CONNECTED event (which re-runs `applyHostname`'s dhcp stop/restart), giving the non-deterministic "sometimes eth `.125` with a half-applied netif, sometimes WiFi `.212`" behaviour observed on the bench. + +## The fix: force one PHY link-state transition, without touching the netif + +IDF exposes exactly the public surface to re-drive the speed push without a driver stop/start (all confirmed present in the pinned IDF): +- `esp_eth_get_phy_instance(handle, &phy)` — `esp_eth_driver.h:403` +- `esp_eth_phy_into_phy_802_3(phy)` — public inline cast, `esp_eth_phy_802_3.h:372` +- `phy_802_3_t::link_status` — public member, `esp_eth_phy_802_3.h:27` +- `phy->get_link(phy)` — public fn-ptr, `esp_eth_phy.h:130` + +**Mechanism:** after `esp_eth_start` and once the link has settled UP, obtain the PHY instance, set `link_status = ETH_LINK_DOWN` **directly** (NOT via `esp_eth_phy_802_3_set_link`, which would post DISCONNECTED and disturb the netif), then call `phy->get_link(phy)`. The poll now sees a fresh DOWN→UP transition, re-reads the negotiated 100M, emits `ETH_STATE_SPEED`, and `emac_esp32_set_speed` lands TXC at 25 MHz — **the netif, DHCP client, and any lease are never touched.** This removes both remaining problems (netif teardown race + IP-not-applied) by construction, because there is no teardown. + +A no-op at 1000M (the re-detected speed is 1000M, TXC already 125 MHz). S31-only (`#ifdef CONFIG_IDF_TARGET_ESP32S31`); the classic/P4 RMII path derives Tx clock from the fixed 50 MHz REF_CLK and has no per-speed TXC. + +## Design + +**File: `src/platform/esp32/platform_esp32.cpp`** (the S31 eth init `ethInitEmac`, ~line 703-744) + +1. **Remove** the stashed `esp_eth_stop()` + `esp_eth_start()` bounce block (lines ~723-740). +2. **Add** a new S31-only helper `ethYt8531ForceSpeedResync(esp_eth_handle_t)` that: + - `esp_eth_get_phy_instance(handle, &phy)`; on error, log-warn and return (non-fatal, as with `ethYt8531BoardInit`). + - `phy_802_3_t* p = esp_eth_phy_into_phy_802_3(phy);` + - `p->link_status = ETH_LINK_DOWN;` + - `phy->get_link(phy);` — re-detects and pushes speed→MAC. + - Keep the existing `ETH-DIAG`/diagnostic prints controlled by the temp-debug flags until the PO signs off end-to-end. +3. **Call site:** the link settles UP a few hundred ms after `esp_eth_start`. Rather than a fixed long `vTaskDelay` in `ethInitEmac` (which eats the cascade's eth-DHCP budget and blocks boot), invoke the resync from the **`ETHERNET_EVENT_CONNECTED` handler** (`ethEventHandler`, ~line 485) on the S31 — that fires exactly when the link first comes UP, i.e. the first (speed-less) transition. Forcing `link_status = DOWN` + `get_link` there triggers the immediate second transition that carries the speed. Guard it to run **once** per link-up (a static/So-far flag reset on DISCONNECTED) so it doesn't recurse. This also means it self-heals on a live cable re-plug, not just at boot — consistent with the "no reboot to apply" principle. + - Confirm ordering against `applyHostname(ethNetif_)` already in that handler: run the speed resync **before** `applyHostname`, so the DHCP client that `applyHostname` (re)starts runs on a MAC whose TXC is already correct. + +**Keep the confirmed-good WIP fixes** (independent of this change, already validated): +- `ethYt8531BoardInit` (autoneg re-enable + RGMII delays) — unchanged; still needed. +- `ethPhyAddr` `int16_t` + `addInt16(-1,31)` + `numberField` — unchanged. +- The `/api/types` probe-freeze guard in `ParallelLedDriver.h` + its regression test — unrelated, keep. + +**Temp debug (remove only on PO end-to-end sign-off, per standing instruction):** the ARP/netif/IP/UDP/ETH/DHCP debug flags in `sdkconfig.defaults.esp32s31` + `sdkconfig.defaults`, and the `ETH-DIAG`/`ETH-MAC` printfs. Leave in place through bench verification. + +## Regression tests (per PO directive: every fix pinned in a test) + +The TXC-resync is platform/hardware logic (esp_eth calls) that can't run on the desktop host. Pin what *can* be pinned at the seam, host-side: +1. **`ethPhyAddr` sentinel** (`test/unit/core/` NetworkModule control test): assert the control is `int16` with range `[-1, 31]` and default `-1` (so a `uint8` cast can never again mangle `-1`→31), and that `numberField` is set on it. This is pure control-metadata, host-testable. +2. **Speed-resync contract** (documented + asserted where possible): the platform desktop stub already returns `ethConnected()/ethLinkUp() == false`; add a focused unit or a documented invariant that the S31 eth CONNECTED path calls the resync before `applyHostname`. Since the esp_eth calls are ESP32-only, pin the *ordering/contract* via a small seam (e.g. a testable free function or a documented sequence asserted by a comment + a platform-boundary check) rather than mocking IDF. Exact seam chosen during implementation; the bar is "a future edit that drops the resync or reorders it fails a check," not "mock the whole IDF." +3. Keep the existing render-split probe-freeze regression test (`test/unit/light/unit_Drivers_rendersplit.cpp`) — already green. + +Document the root cause + fix in `docs/history/lessons.md` (S31 RGMII 100M TXC), and update the memory note `s31-ethernet-dhcp-rx-open` outcome once bench-verified. + +## Verification + +1. **Host gates:** `cmake --build build` (zero warnings) + `ctest` + `uv run moondeck/scenario/run_scenario.py` green. +2. **S31 build:** `uv run moondeck/build/build_esp32.py --firmware esp32s31 --skip-idf-pin-check` (current IDF release/v6.1) — zero warnings. +3. **Bench (the real test), on the 10/100 GL-AR300M:** flash the S31, capture serial. Success criteria: + - Serial shows the speed resync running and (with debug on) `working in 100Mbps` from the EMAC driver, i.e. TXC reconfigured. + - `NetworkModule: Connected via Ethernet — Eth: <ip>` and an `IP_EVENT_ETH_GOT_IP` — a **deterministic** eth lease across repeated reboots (not sometimes-WiFi). + - **PO-observed:** the S31 is reachable over the Ethernet cable at its leased IP (`http://<ip>` / `MM-S31.local`) — ARP resolves, ping replies, UI loads. This is the measurement; agent serial logs are supporting evidence only. + - Re-plug the cable live → eth re-leases without reboot (self-heal). +4. **No 1000M regression:** when a gigabit switch is available (PO has one, not on hand now), confirm eth still leases at 1000M (the resync is a no-op there). + +## Notes / risks + +- **Direct `link_status` write** touches a public struct member but bypasses the `set_link` helper deliberately (to avoid the DISCONNECTED post that would disturb the netif). This is a bespoke touch of IDF internals — carry a one-line comment at the site naming *why* (netif-preserving) per the "bespoke choices carry their reason" principle. It uses only public headers (`esp_eth_phy_802_3.h`), not private ones. +- If `phy->get_link(phy)` inside the event handler proves to re-enter awkwardly (it runs on the event-loop task, same as the poll), fall back to just `link_status = DOWN` and let the **next scheduled poll** (`check_link_period_ms`) do the re-detect — one poll interval later, still well inside the 15 s cascade window. Decide by bench observation. +- The stashed WIP's `esp_eth_stop/start` bounce is **removed**, not kept as a fallback — it's the racy path this replaces (no-hacks floor). diff --git a/docs/history/plans/Plan-20260726 - Static IP for WiFi and Ethernet (shipped).md b/docs/history/plans/Plan-20260726 - Static IP for WiFi and Ethernet (shipped).md new file mode 100644 index 00000000..8728ebf8 --- /dev/null +++ b/docs/history/plans/Plan-20260726 - Static IP for WiFi and Ethernet (shipped).md @@ -0,0 +1,64 @@ +# Plan: Static IP support for WiFi STA + Ethernet (wire the existing controls to the netif) + +## Context + +The Network module already shows an `addressing` dropdown (**DHCP / Static**) and four IPv4 controls (`ip`, `gateway`, `subnet`, `dns`), stored as `staticIp_/staticGateway_/staticSubnet_/staticDns_` (uint8[4] octets) in `NetworkModule.h` and persisted. But **nothing applies them**: `esp_netif_set_ip_info` is called only for the SoftAP (`platform_esp32.cpp:1122`). Neither `wifiStaInit` nor `ethInitEmac` reads `addressing_` or the static octets, so both the STA and ETH netifs always run their DHCP client regardless of the dropdown. The controls are inert on both interfaces. NetworkModule's own docstring (`:89`) says the `addressing` selector + static-IP controls are meant to "remain" and apply to whichever interface is active, so this closes a gap that was designed for but never wired. + +The product owner asked for static support on Ethernet "just like wifi"; since WiFi static is *also* inert, the agreed scope is to make static actually work for **both** STA and ETH. + +**Bonus experiment (S31 at 100M).** A static IP bypasses DHCP entirely. The S31 links at 100M but can't complete the DHCP handshake (the open TXC issue, see backlog-core.md). If a static IP makes Ethernet reachable at 100M, that proves the RX/unicast path works and only the DHCP handshake was the 100M blocker: a simpler path than the TXC resync, and possibly enough to call 100M "supported via static." The plan flags this as a bench test. + +## Design + +Mirror the AP static path (`platform_esp32.cpp:1115-1124`: `dhcps_stop` → build `esp_netif_ip_info_t` → `set_ip_info` → restart) in the **client** form for STA and ETH: `esp_netif_dhcpc_stop(netif)` → `esp_netif_set_ip_info(netif, &info)` → `esp_netif_set_dns_info(netif, ...)`. No dhcpc restart — static means the client stays stopped. + +**Stage 1 — platform seam.** Add one narrow platform function, `platform.h`: +``` +void netSetStaticIPv4(NetIface iface, const uint8_t ip[4], const uint8_t gw[4], + const uint8_t mask[4], const uint8_t dns[4]); +``` +`NetIface` is a tiny enum (`NetSta`, `NetEth`) so one function serves both, resolved to `staNetif_` / `ethNetif_` internally. If all-zero `ip` is passed, treat as "no static / use DHCP" (defensive). The ESP32 impl does the dhcpc_stop/set_ip_info/set_dns sequence on the resolved netif; the desktop stub is a no-op (like the other net stubs). This is a domain-neutral, single-purpose primitive — the recognizable "set static addressing on an interface" call, not a bespoke per-interface duplicate. (Naming: `netSetStaticIPv4` matches the existing `wifiStaGetIPv4` / `ethGetIPv4` octet-getter convention in `platform.h`.) + +**Stage 2 — apply on bring-up.** In `NetworkModule`, after each interface is initialized and its netif exists, when `addressing_ == 1` (Static) call `platform::netSetStaticIPv4(...)` for that interface: +- **STA:** right after `wifiStaInit` succeeds (the STA netif exists once `esp_wifi_start` ran; apply before/instead of the DHCP client taking a lease). Because the static apply stops the dhcp client, it must run once the netif is created — call it from NetworkModule right after `wifiStaInit()` returns true, guarded by `addressing_ == 1`. +- **ETH:** the eth netif exists after `ethInit()`; apply when Static. The eth DHCP client is started from the IDF CONNECTED handler, so for a clean static setup the apply must also run on link-up. Simplest robust approach: NetworkModule calls `netSetStaticIPv4` for eth when it observes eth link-up in Static mode (in the `WaitingEth` / cascade path), and the platform's eth CONNECTED handler skips `applyHostname`'s dhcpc_start when a static IP is set. Exact wiring settled in implementation; the invariant is "Static mode → dhcpc stopped + ip_info set, on whichever netif is active, without a DHCP round." +- On Static, `ethConnected_` / the STA "got IP" state must be considered connected **without** waiting for a DHCP `GOT_IP` event (there won't be one). NetworkModule treats "Static + netif has the static IP applied" as connected: set the connected state directly after applying, rather than waiting on `ethConnected()` / `wifiStaConnected()` which key off DHCP/association-IP events. + +**Stage 3 — apply live on toggle.** Toggling `addressing` (or editing a static field) already triggers `rebuildControls()` (the Select→hidden re-eval). Extend the live path: when `addressing_` flips to Static, apply the static config to the currently-active interface immediately; when it flips back to DHCP, restart the DHCP client (`esp_netif_dhcpc_start`) so the device re-leases without a reboot (the "no reboot to apply" principle). This routes through the existing `onControlChanged` / dirty path NetworkModule already uses for live network reconfig. + +**Stage 4 — status.** `updateStatusIP()` already reads the netif IP via `currentIp()` → `ethGetIPv4`/`wifiStaGetIPv4`, which returns the *applied* static IP (set_ip_info makes it the netif address). So the status line shows the static IP with no change. The eth-degraded warning path only fires in DHCP mode (Static won't hit the DHCP-timeout branch), so it composes cleanly. + +## Files + +- `src/platform/platform.h` — declare `NetIface` enum + `netSetStaticIPv4(...)`. +- `src/platform/esp32/platform_esp32.cpp` — implement it (dhcpc_stop/set_ip_info/set_dns on the resolved netif); teach the eth CONNECTED handler + `applyHostname` to skip dhcpc_start when static is active (a `bool ethStatic_` / STA equivalent set by the setter, or a param). +- `src/platform/desktop/platform_desktop.cpp` — no-op stub. +- `src/core/NetworkModule.h` — call `netSetStaticIPv4` on bring-up + on live toggle for STA and ETH; treat Static as connected without a DHCP event; DHCP-restart on toggle back. + +## Reuse (don't reinvent) + +- The AP static block (`platform_esp32.cpp:1115-1124`) is the exact server-side shape; the client side is the same minus dhcps→dhcpc and plus DNS. +- `formatDottedQuad` / the `ControlType::IPv4` octet storage already parse/hold the addresses; the octets go straight into `esp_netif_ip_info_t` via `IP4_ADDR` / `esp_netif_set_ip_info`. +- `currentIp()` / `updateStatusIP()` already surface the netif IP — no status rework. + +## Tests (regression, per the project rule) + +Host-testable seam (`test/unit/core/unit_NetworkModule_ethernet.cpp` or a new `unit_NetworkModule_static.cpp`): +- The `addressing` Select is index 0=DHCP / 1=Static, defaults to DHCP; the four static IPv4 controls exist with the documented defaults (subnet `255.255.255.0`), and are hidden when `addressing_ != 1` (pins the control contract + the visibility rule). +- The desktop `netSetStaticIPv4` stub is a safe no-op (accepts any octets, doesn't bring an interface up) — mirrors the existing "desktop net seam is inert" tests. +The actual dhcpc_stop/set_ip_info is ESP32-only (bench-verified, not host-mockable) — pin the control/seam contract host-side, verify the apply on hardware. + +## Verification + +1. **Host:** `cmake --build build` (0 warnings) + `ctest` + scenarios green. +2. **S31 build:** `uv run moondeck/build/build_esp32.py --firmware esp32s31 --skip-idf-pin-check` (0 warnings). +3. **Bench (WiFi static):** on a WiFi board, set addressing=Static + a valid static IP/gw/mask/dns on the LAN, confirm the device comes up at that IP (reachable, UI loads), and that toggling back to DHCP re-leases live (no reboot). PO-observed. +4. **Bench (Ethernet static) + the 100M experiment:** on the S31 at 100M, set a static IP in the LAN's range and confirm Ethernet becomes **reachable** (ping + UI at the static IP) — the decisive test: if it works, unicast/RX is fine and DHCP-handshake was the only 100M gap. PO-observed; this is the measurement, serial is supporting evidence. +5. **No-reboot toggle both ways** and **persistence across reboot** (the octets already persist; confirm they re-apply on boot in Static mode). + +## Risks / notes + +- **Static "connected" without a DHCP event** is the main behavioral change: the cascade's connected-detection keys off DHCP/association IP events today; Static must mark connected right after the apply. Keep this contained in NetworkModule's state machine (don't fake a platform event). +- **DNS optional:** if `dns` is all-zero, skip `set_dns_info` (leave whatever's there) rather than setting 0.0.0.0. +- **Gateway/subnet sanity:** a static IP outside the gateway's subnet silently won't route; out of scope to validate, but the status still shows the applied IP so the user can tell it took. +- **Platform boundary:** all IDF calls stay in `platform_esp32.cpp`; NetworkModule only calls `platform::netSetStaticIPv4` + the existing init functions (no `#ifdef` leaks). diff --git a/docs/history/plans/Plan-20260727 - Static analysis and repo health tooling.md b/docs/history/plans/Plan-20260727 - Static analysis and repo health tooling.md new file mode 100644 index 00000000..63ac2f23 --- /dev/null +++ b/docs/history/plans/Plan-20260727 - Static analysis and repo health tooling.md @@ -0,0 +1,367 @@ +# Plan: static analysis + repo-health tooling + +**Date:** 2026-07-27 · **Status:** in progress — clang-tidy, lizard and clang-query landed (steps 2, 5, 8). Open: warnings tier-zero (1), `[[clang::nonblocking]]` (3), RTSan (4), CodeQL housekeeping (6) + +Temporary document: this text becomes the PR description and the file is deleted once the +plan is realized (CLAUDE.md § Branch). + +## The question + +*Is lizard the right tool, and how do we monitor the repo against our own principles?* + +Answered by researching what serious C++ projects actually **configure** — LLVM, Chromium, +ClickHouse, SerenityOS, Godot, ESPHome, Zephyr, ESP-IDF — rather than by running each tool +once and scoring the output. That distinction matters, and getting it wrong the first time is +what produced the earlier draft of this plan (see *What we got wrong* at the end). + +## The stack + +Five layers, cheapest and most immediate first. Each catches something the layer above cannot. + +| # | Layer | Catches | Where | +|---|---|---|---| +| 0 | **Compiler warnings** | shadowing, double promotion, fallthrough, null deref | Every build, all targets | +| 1 | **clang-tidy** (curated) | bug patterns, performance, portability | Editor as you type **+** CI | +| 2 | **Sanitizers** (ASan+UBSan, TSan) | real memory/threading faults at run time | Desktop CI lanes | +| 3 | **RTSan + `[[clang::nonblocking]]`** | allocation/blocking in the render loop, **transitively** | Compile time + run time | +| 4 | **CodeQL** (stock suite) | untrusted input, whole-program taint, use-after-free | CI, Security tab | + +Alongside them, three tools that measure or extend rather than analyse: + +| Tool | Job | Status | +|---|---|---| +| **lizard** | Complexity *number* per commit, for the trend | Keep, baselined | +| **Our Python checks** | Cross-artifact contracts nothing else can see | Keep as-is | +| **clang-query** | Home for the next bespoke rule | Shipped — 3 rules in `check_clang_query.py` | + +**Our Python checks** stay untouched: ~0.4 s for all four, and they cover contracts whose +other half is a Markdown page, a JSON catalog or a built binary — not a C++ question, so no +analyser can replace them. + +**clang-query** is the designated home for the *next* bespoke rule: the stack enforces the +rules we have, this is how we add the ones we invent. Matchers are plain text in the repo, +minutes to write, no plugin to build and no LLVM ABI coupling — which is why it beats a +compiled clang-tidy check (LLVM-version-locked, and **clangd will not load one**, so a custom +check would never appear in the editor). Same compilation database as layer 1, so adopting it +later costs nothing now. Off until a rule needs it: a tool running zero rules is overhead. + +**First named candidate: large static array declarations** (`char buf[512]` and friends), which +bloat RAM on a 180 KB-heap device and which nothing else in the stack reports. PROBED and it +works — `varDecl(hasType(constantArrayType()))` matches, and `set output dump` yields +`file:line`, the name, and the full type (`char[80]`), so the byte size parses straight out. + +Two things that probe established, both of which shape the eventual script: + +- **There is no size-threshold matcher.** `hasSize(64)` is exact-match only; `sizeGreaterThan` + does not exist. So the matcher takes ALL constant arrays and the "> N bytes" filter, the + our-files-only filter, and the dedupe (template instantiations repeat a declaration) happen + in Python — the same shape as `check_lizard.py`, which already parses a tool's raw output + and applies our policy on top. +- **It needs the same `-isysroot` fix as clang-tidy.** Without it, `Control.cpp` reported 5 + matches; with it, 14. Identical silent-under-report trap to the one recorded below — a wrong + answer, not an error. Any clang-query script must reuse `check_clang_tidy._toolchain_args()`. + +Unprobed: whether stack arrays, class members and statics are worth separating (they are +different problems — a 512-byte local is a stack-depth risk, a 512-byte member is per-instance +RAM), and where the threshold should sit. Both are policy questions for when the rule lands. + +### Is lizard the right tool? Yes — as a counter, not an analyser + +The question this plan opened with, answered directly. + +**Keep it.** It is actively maintained (1.23.0, June 2026), runs in ~1 s, and it does one +thing the rest of the stack does not: it produces a **number per commit** that we can trend. +clang-tidy tells you a function is too complex *today*; lizard tells you whether the codebase +is getting worse *over time*. Those are different jobs, and `repo-health.json` needs the +second. + +**But only as a counter.** Its own README warns it is a fuzzy tokenizer, not a parser — no +macro expansion, confused by heavy templates. It can never express an architectural rule, so +it is not a platform to build on. + +**Its 162 warnings are a threshold artifact, not a verdict:** 161 come from `CCN>10` alone, +and 80% of 2,185 functions sit at CCN ≤ 5. The real tail is 4 functions above CCN 50. A metric +that can never reach zero is a poor gate, which is what baselining fixes. + +**Its VS Code extension is dead** (v1.0.1, Oct 2022) — do not build on it. The live in-editor +equivalent is clang-tidy's `readability-function-*` through clangd, which is layer 1. + +**Overlap with clang-tidy, settled:** both can measure complexity, so they do not both gate. +**lizard owns the metric and the trend**; clang-tidy's complexity checks stay off. One number, +one owner. + +**Deleted:** `check_hotpath.py` (170 lines) once layer 3 is green — the compiler subsumes it +transitively, which a regex never could. + +**Declined:** cppcheck (modest unique yield next to a clean clang-tidy), SonarCloud (a second +findings home; no custom C++ rules at any tier), custom CodeQL queries (the one rule we named +is owned by layer 3), Semgrep (C++ GA is paywalled), Aikido (AppSec aggregator, wrong +category), PVS-Studio / CodeScene / MISRA suites (built for certification, not for us), +`-Weverything` and GCC `-fanalyzer` (Clang's and GCC's own docs advise against, respectively). + +### One rule, one owner + +| Rule | Enforced by | +|---|---| +| No allocation/blocking in the render path | `[[clang::nonblocking]]` + RTSan | +| No platform code outside `src/platform/` | `check_platform_boundary.py` | +| Specs match the code | `check_specs.py` | +| Catalog matches the modules | `check_devices.py` | +| Untrusted input is memory-safe | CodeQL | +| Bug patterns / performance | clang-tidy | +| Complexity does not grow | lizard (baselined) | +| Size/LOC/docs do not grow silently | `repo_health.py` | + +## How clang-tidy gets configured + +The centrepiece, and the part the first attempt botched. Real projects use one of three +shapes; ours is **archetype B — enable families, disable individually with a stated reason** +(the [SerenityOS](https://github.com/SerenityOS/serenity/blob/master/.clang-tidy) shape). + +Nobody serious enables `cppcoreguidelines-*` or `hicpp-*` wholesale: mostly aliases plus +bounds/cast rules that firmware register access must violate. ClickHouse disables the family +as *"impractical… also slow"*; ESPHome — a large ESP32 C++ codebase, our closest peer — does +the same and still runs `WarningsAsErrors: '*'`. + +Starting config, **derived bottom-up from ESPHome's** (their `.clang-tidy` is `*` minus 175 +checks with `WarningsAsErrors: '*'` — battle-tested on a large ESP32 C++ codebase, the closest +peer we have), then tuned top-down against our own tree. The full file is written at +implementation time; the shape is `*` minus ~78 disables, `HeaderFilterRegex: 'src/(core|light)/'`. + +**Tuning it was iterative, and the numbers show why the first attempt failed:** + +| Config | Unique findings in our code | +|---|---:| +| My original shotgun (`bugprone-*,performance-*,concurrency-*`) | 384, of which 66% one noisy check | +| `*` + ESPHome's family disables only | **6,073** | +| + their style-check disables | 3,949 | +| + the `cert-*` family (`cert-err33-c` alone was 3,678 — every `snprintf`) | **131** | + +**131 real findings** — a tractable, mostly-actionable list. Three checks ESPHome disables that +I had wrongly called useful in the first evaluation: `performance-enum-size`, +`bugprone-narrowing-conversions`, `bugprone-easily-swappable-parameters`. They run the same +class of memory-constrained device and still reject all three. + +**Triage outcome: 125 → 47.** Every finding was read against the actual code rather than +trusted. The remaining 47 are all `clang-analyzer-*` — the path-sensitive family — and they +were invisible until `WarningsAsErrors` was switched on: the report parser rejected the +`,-warnings-as-errors` suffix clang-tidy then appends and dropped every finding. So the +"0" this section originally claimed was partly a parser bug, which is the sixth silent-zero +this exercise produced. Backlogged: *clang-tidy: triage the 47 clang-analyzer findings*. +The split, which is the number worth remembering for the next tool evaluation: + +| Disposition | Count | Examples | +|---|---:|---| +| Real defects, fixed | 2 | `std::forward` inside a loop (below); a duplicated `TEST_CASE` + its duplicate include | +| Genuine improvements, applied | ~20 | `localtime`→`localtime_r`, `std::numbers::pi`, `scoped_lock`, `ranges::any_of`, two accidentally-private overrides, a throwing test-rig destructor | +| Deliberate convention → check disabled with a measured reason | ~80 | see the disable table in `.clang-tidy` | +| Deliberate at one site → `NOLINT` with a reason | 12 | Bresenham's assign-and-test; asserting moved-from state *is* the test | + +**The real bug it found** is in `HttpServerModule.cpp`: `visitModuleLeaves(mod, std::forward<Fn>(fn))` +called inside a `for` loop, at two levels. Forwarding moves the callable into the first module, +so every later sibling receives a moved-from object. It survived because the callables in use +happen to be cheap-to-copy lambdas, which is precisely the kind of latent, works-by-luck defect +a reviewer skims past. + +**The disabled checks are the more interesting result.** Four families were wrong on *every* +occurrence, each because it collides with a deliberate convention: `bugprone-signed-char-misuse` +(12/12 — and its suggested fix would turn the `-1` unset-pin sentinel into GPIO 255, a real bug), +`performance-no-int-to-ptr` (9/9, the `uintptr_t` tagged-pointer field), `bugprone-infinite-loop` +(28/28, `uint8_t` counters), `bugprone-implicit-widening-of-multiplication-result` (47 findings, +0 reachable — margin ~87,000×). A check that is wrong every time trains you to ignore its family, +which costs more than it catches; the reasoning for each is recorded in `.clang-tidy` so the next +reader does not re-litigate it. + +A sample false positive for contrast: `WledPacket.h:80` "memcpy result is not +null-terminated" — the buffer is pre-zeroed by a `memset`, as the adjacent comment says. That +one gets a `NOLINTNEXTLINE` with the reason, which is the intended workflow. + +`bugprone-infinite-loop` **stays enabled** despite being 26/28 false on our `uint8_t` counters +in the first run: the FPs are a known LLVM issue with fixes still landing, and an FP there +sometimes reveals a genuinely missing `volatile`. Each gets a `NOLINTNEXTLINE` with a reason. + +Rejected checks stay listed in the config with their reason, so they are never re-litigated — +[the Chromium pattern](https://github.com/chromium/chromium/blob/main/.clang-tidy). + +**A structural catch for our layout:** clang-tidy picks its config from the *translation +unit's main file*, so a `src/light/.clang-tidy` would **not** govern our header-only light +modules — they are compiled as part of some `.cpp` elsewhere. Per-directory strictness on the +core/light split therefore does not work as one might assume. The working shape is one root +config plus a relaxed `test/.clang-tidy` (`InheritParentConfig: true`), with +`HeaderFilterRegex` covering the headers. + +## Implementation + +Each step is independent and revertible. **✅ done · ◻ not started · ◐ partly done.** + +1. ◻ **Warnings tier-zero** — add `-Wshadow -Wnon-virtual-dtor -Wdouble-promotion + -Wimplicit-fallthrough -Wnull-dereference` to the existing `-Wall -Wextra -Werror`. + `-Wdouble-promotion` catches accidental `double` math: real cost on Xtensa, and it enforces + the integer-math rule. Trial `-Wconversion` separately — expect to reject it for `light/`. +2. ✅ **Baseline lizard** — DONE. `docs/metrics/whitelizard.txt` freezes today's 162 and + `check_lizard.py` fails only on new violations (verified both ways: a probe function makes it + exit 1, removing it returns green). Deliberately NOT at the repo root — that is lizard's + default whitelist path, and a baseline that applies itself silently zeroed the KPI's + complexity count. The KPI and repo-health now record the RAW number via shared code; the + baseline is applied only by the gate. `repo-health.json` gained a `complexity` block, so the + trend the plan asked for actually exists now. +3. ◻ **`[[clang::nonblocking]]`** on `MoonModule::tick/tick20ms/tick1s` (three lines; the + attribute is inherited by overrides, so ~90 modules are covered) + `-Wfunction-effects` on + the desktop build. Then delete `check_hotpath.py`. +4. ◻ **RealtimeSanitizer** — add `realtime` to the sanitizer matrix in `test.yml`. Only + meaningful after step 3, since it keys off the same attributes. +5. ◐ **clang-tidy** — config landed, clangd wired, and the tree triaged from 125 findings to + **0** (see the triage table above). What remains is the ratchet: `WarningsAsErrors` is still + `''`, and cannot go to `'*'` until the 47 clang-analyzer findings are triaged — switching it + on today fails the gate. One line, and it is what stops a zero decaying, so it lands WITH that + triage. Original text: land the config above, wire **clangd first** (editor-only, gates nothing, + and it filters slow checks automatically). Then one full run per family: real bug → fix; + FP → `NOLINTNEXTLINE` with a reason; loud-and-useless → the disable list with a comment. + When a family is clean it joins `WarningsAsErrors`. Target end state is **zero baseline** — + at 50k LOC that is reachable, which is why we skip CodeChecker and diff-only CI entirely. +6. ◻ **CodeQL housekeeping** — exclude vendored code (`test/doctest.h` produced the only + critical we do not own) and decide whether it gates PRs or stays a weekly sweep. +7. ◐ **Triage the 4 real CodeQL findings** — the 3 `localtime` criticals are DONE (a portable + `isoTimestamp` helper in `main_desktop.cpp`, `localtime_r`/`localtime_s` behind the file's + existing `_WIN32` branch); clang-tidy's `concurrency-mt-unsafe` flagged the identical three, + so two independent tools agreeing was the signal to fix rather than suppress. Remaining: file + modes `0666` → `0600` where it matters (desktop only; meaningless on LittleFS). + +8. ✅ **clang-query — the bespoke-rule report. DONE.** `check_clang_query.py`, MoonDeck card + "AST Rules". Shipped with two rules and the frame for more. Measured on this tree: 290 + RAM-costing arrays over 10 bytes (193 local, 97 member) and 78 heap allocation sites. + Thresholds settled from the real numbers, not in advance — excluding constexpr/static + storage took the array list from >1000 to 362, which is what made a 10-byte default usable. + Two traps hit while building it, both silent-zero: a bare top-level `anyOf()` of `Stmt` + matchers is ambiguous and matches NOTHING while exiting 0 (needs a `stmt(...)` wrapper), and + the guard against that must key on more than `"error:"` — clang-query says "Input value has + unresolved overloaded type". Original design: One MoonDeck entry, + `check_clang_query.py`, holding a GROWING LIST of rules we invent — not one script per rule. + Each rule is a matcher plus a Python predicate, and the report prints a section per rule, so + rule two costs a list entry rather than a new script, a new card and a new help page. + + **First rule: large array declarations** (`char buf[512]`), the RAM-bloat question nothing + else in the stack answers. Probed and working (§ clang-query above): match all + `constantArrayType()` declarations, then filter in Python — there is no size-threshold + matcher, so `> N bytes`, our-files-only, and the dedupe of repeated template instantiations + are ours to do. Reuse `check_clang_tidy._toolchain_args()`: without `-isysroot` the same + silent under-report appears (5 matches instead of 14). + + **PO's categories, each probed against the real tree:** + + | Ask | Verdict | + |---|---| + | Stack vs heap vs member | ✅ Separable. `varDecl` = locals/statics, `fieldDecl` = members (my first probe used only `varDecl` and silently missed EVERY class member). `hasStaticStorageDuration()` and `isConstexpr()` split flash-resident tables from real RAM. | + | Fixed number vs named constant | ❌ **Not recoverable.** The AST folds `kMaxLanes` to `[16]` before we see it — `busPinBuf_` reads as `uint16_t[16]` with no trace of the spelling. Source text has it (730 literal-sized vs 105 `kConstant`-sized) but only via regex, which is the fragile approach this whole plan moved away from. Recommend dropping. | + | Heap allocs and frees | ✅ Works. `cxxNewExpr()` / `cxxDeleteExpr()` match, as does `callExpr(callee(functionDecl(hasAnyName("malloc","calloc","realloc","free","heap_caps_malloc",…))))` with exact locations. Needs the our-files filter — a raw `cxxNewExpr()` run returns standard-library internals like `new _Codecvt`. | + | 10-byte threshold | ⚠️ **Measured, and it is too low as a flat rule.** Three sampled files alone hold 245 array declarations: 212 are >10 bytes, 106 >64, 25 >256. Extrapolated across `src/`, >10 bytes is over a thousand findings. | + | Nothing fixed, all dynamic | Agreed as the principle — but see below on what the 10-64 byte band actually contains. | + + **Why a flat 10-byte threshold would misfire.** Sampling the 10-64 byte band shows it is + almost entirely small fixed string buffers and constant lookup tables, and several are the + minimalism principle already applied rather than violated: + - `MoonModule::name_[16]` — its own comment records it was shrunk FROM `char[24]` to save + 8 bytes per module (~240 bytes across a typical tree). Flagging it reports a past win as a + problem. + - `kRGB[3]` / `kGRB[3]` / `kBuiltins[]` — `static constexpr`, so they live in FLASH and cost + no RAM at all. 16 of 17 array decls in `LightPresetsModule.h` are static-storage. + + So the useful report is not "arrays over N bytes" but **arrays that cost RAM**, which the + probed matchers can express: exclude `isConstexpr()` / `hasStaticStorageDuration()`, then + report locals (stack-depth risk), members (per-instance RAM × instance count) and mutable + statics (unconditional RAM) as three separate lists. At that point a threshold near 10 bytes + is plausible, because the flash-resident noise is already gone. **Measure before fixing it.** + + Still open, and to be decided on the first real run rather than now: the exact threshold per + category, and whether the count needs `check_lizard.py`'s baseline shape or is small enough + to be a plain report. + + The rule after that is unassigned on purpose — the point of the step is the *frame*, so the + next rule is a matcher and a threshold rather than a project. + +## Evidence + +### CodeQL — run, and it delivered + +CodeQL 2.26.1, `build-mode: none`: ~3 min, **179 rules, 867 results**, no build required. + +- **3 × critical** `localtime` in `main_desktop.cpp` — real (shared static, not thread-safe), + desktop-only. FIXED via a portable `isoTimestamp` helper; clang-tidy independently flagged the + same three as `concurrency-mt-unsafe`. +- **1 × critical** use-after-free in `test/doctest.h` — vendored; exclude it. +- **5 × high** files created mode `0666` — meaningless on LittleFS, minor hardening on desktop. +- **1 × high** `Control.cpp:168`, `uint8_t o < c.max` where `max` is `int32_t` — benign today + (`addSelect` takes a `uint8_t` count) but a latent trap if that signature widens. + +**The six network packet parsers produced no taint-flow findings** — positive evidence about +~22 `memcpy` calls on LAN data that nothing else in the stack could have given. + +### clang-query — probed as the bespoke-rule engine + +The one part of the first evaluation worth keeping, because it tested *authoring a rule* +rather than reading default output. + +A matcher took minutes and no build: +`match callExpr(callee(functionDecl(hasName("malloc"))), hasAncestor(functionDecl(matchesName("tick.*"))))` +→ 0 matches, a true clean result (broad control matchers returned 42 and 245, proving the +machinery works rather than silently passing). It reaches the **245 control registrations** a +future "a conditional control is registered below the control it depends on" rule would need. + +Class of rule reachable: per-function AST and lexical position — **not** whole-program +reachability. That limit is fine, because the one rule needing reachability (allocation +reachable from `tick()`) is owned by layer 3. + +### `[[clang::nonblocking]]` — verified locally + +Clang 22 caught a `push_back` two levels deep through a helper, tracing the chain into libc++. +The attribute is **inherited by overrides**, so one annotation on the base covers every module. + +### Two gotchas worth keeping + +- **The compilation database's compiler must match the tool's.** A database generated by Apple + Clang while analysing under Homebrew LLVM produced `'cstdint' file not found` on every file + and silently blocked clang-query entirely. +- **`workflow_dispatch` only offers a "Run workflow" button on the default branch**, so a POC + workflow on a feature branch needs a `push:` trigger. And `/code-scanning/alerts` returns + `0` for a non-default branch unless you pass `?ref=refs/heads/<branch>` — that zero does not + mean "clean". + +## What we got wrong + +Recorded because the mistake is instructive, and because the first draft of this plan +**declined clang-tidy on bad evidence**. + +The first evaluation ran each tool *once, unconfigured*, and scored the raw output. For +clang-tidy that meant enabling `bugprone-*,performance-*,concurrency-*` — a shotgun — then +counting the noise that choice produced and calling it a tool defect. 66% of the findings came +from `bugprone-easily-swappable-parameters`, **a check that SerenityOS, ClickHouse, ESPHome, +Godot and the Zephyr template all disable by name**. Turning it off is standard practice. + +The comparison was also structurally unfair: CodeQL was given a written workflow with a +curated query set, clang-tidy got one command line, and the two outputs were then compared as +though that were like-for-like. + +The lesson, and the reason this plan now leads with configuration: **a static-analysis tool's +default output is not its verdict.** Evaluating one without a curated check list measures the +evaluator's config, not the tool. + +### The silent-failure modes, all of which read as "clean" + +Five separate defects in the tooling each produced a plausible-looking report rather than an +error. This is the part worth carrying forward, because every one of them would have let a +green check certify an unanalysed tree: + +| Defect | What it looked like | +|---|---| +| `#` inside a YAML `>-` folded scalar is not a comment | Every disable after the first comment was folded into the check string; `abseil-*` stayed on → 12,181 findings | +| `run-clang-tidy` shells out to `clang-tidy` **by name** | Not on PATH → exits 0 having analysed nothing → "0 findings" | +| `-extra-arg VALUE` (space form) is silently ignored | Only `-extra-arg=VALUE` works → 0 findings | +| Same trap in `-checks` | `--check X` filtered nothing; the filter had never worked | +| Compilation database recorded a *different* compiler | `'cstdint' file not found` on 129/129 files; unparsed files are never analysed, so the findings were debris | + +The defence now in `check_clang_tidy.py`: it refuses to report when more than ten files fail to +compile, because "most files errored" is a broken run, not a result. The general rule — **verify +a zero before believing it.** After reaching 0 findings, running a deliberately-disabled check +(`--check readability-magic-numbers`) returned 3,307, which is what proves the pipeline is +actually reading the code. A zero with no control is indistinguishable from a tool that ran +nothing. diff --git a/docs/history/release-notes-v1.0.0.md b/docs/history/release-notes-v1.0.0.md index aa86895c..b8207b91 100644 --- a/docs/history/release-notes-v1.0.0.md +++ b/docs/history/release-notes-v1.0.0.md @@ -33,7 +33,7 @@ Full set: Lines, Rainbow, Noise, Plasma, PlasmaPalette, Metaballs, Fire, Particl ## Under the hood -What makes projectMM different: **16,384 LEDs on a classic ESP32** (not just the S3), **pure ESP-IDF v6.x with no Arduino**, **no third-party libraries** (own colour math, HTTP/WebSocket server, control storage), and **one module model** — every effect, modifier, layout, and driver is a `MoonModule`, which is why the UI renders any of them with zero per-module code. Full rationale in the [README § Under the hood](https://github.com/MoonModules/projectMM#under-the-hood). +What makes projectMM different: **16,384 LEDs on a classic ESP32** (not just the S3), **pure ESP-IDF v6.x with no Arduino**, **no third-party libraries** (own color math, HTTP/WebSocket server, control storage), and **one module model** — every effect, modifier, layout, and driver is a `MoonModule`, which is why the UI renders any of them with zero per-module code. Full rationale in the [README § Under the hood](https://github.com/MoonModules/projectMM#under-the-hood). Two things worth calling out for this first release: diff --git a/docs/history/troyhacks-WLED.md b/docs/history/troyhacks-WLED.md index 28631ae8..937dcd32 100644 --- a/docs/history/troyhacks-WLED.md +++ b/docs/history/troyhacks-WLED.md @@ -51,7 +51,7 @@ _Checked: merged commits on `mdev` for author-date 2026-06-01..2026-06-30 (0 com - **PixelForge** image/GIF tooling gained image rotation; WLED-MM-specific adjustments. - **RMTHI** (high-speed RMT LED output) now works on ESP32-S2 and S3; new 16 MB ESP32-with-Ethernet build target. -- Random colours via the JSON API (`"col":["r","r","r"]`); Animartrix optional gamma correction + math-optimization speedups. +- Random colors via the JSON API (`"col":["r","r","r"]`); Animartrix optional gamma correction + math-optimization speedups. - Nightly-build automation: automatic version stamping, cleaner release notes, "Nightly mdev Build" titling. **Fixed** @@ -71,7 +71,7 @@ _Checked: merged commits on `mdev` for author-date 2026-06-01..2026-06-30 (0 com **New** - **WLEDPixelForge** — a new image and scrolling-text interface (`pxmagic`), with 1D GIF support, blur option, and version-14.x adaptations. -- Effect math sped up (up to ~3× faster); inlined hot-path colour/segment functions; more segment/effect data allowed on PSRAM boards. +- Effect math sped up (up to ~3× faster); inlined hot-path color/segment functions; more segment/effect data allowed on PSRAM boards. - DDP-over-websockets / DDP-over-WS stability; E1.31 kill switch; `dnrgbw` realtime mode. **Fixed / hardened** @@ -103,7 +103,7 @@ _Checked: merged commits on `mdev` for author-date 2026-06-01..2026-06-30 (0 com *~32 commits on `mdev`, 2025-10-01 … 2025-10-31.* -- **DDP-over-websockets** added; HUB75 skips colour-temperature correction for performance. +- **DDP-over-websockets** added; HUB75 skips color-temperature correction for performance. - `setPixelColor` / `getPixelColor` hot-path optimizations (cached-bus path, `colorKtoRGB` fix, IRAM placement); particle-FX framebuffer memory-calc fix. - Bugfixes: preset-corruption prevention, IR-JSON buffer-overrun, low-brightness gradient smoothness. diff --git a/docs/history/v1-inventory.md b/docs/history/v1-inventory.md index e0ca22ac..395a4e15 100644 --- a/docs/history/v1-inventory.md +++ b/docs/history/v1-inventory.md @@ -122,7 +122,7 @@ This is a throwaway reference document — not committed. Used to decide what to The v1 `index.html` was fully reverse-engineered. The notes below capture mechanisms worth not rediscovering and, where v3 chose differently, why. The **forward-looking** UI gap analysis (what to still adopt) lives in the [backlog](../backlog/README.md) (UI chapter of backlog-core.md). **Engine-side data fields v1 exposed that v3 doesn't (yet):** -- `setup_ok` (bool) + `health` (string) per module — drove a setup-dot colour and tooltip. v3 would add `bool setupOk()` + `const char* health()` to MoonModule when a real failure mode exists. +- `setup_ok` (bool) + `health` (string) per module — drove a setup-dot color and tooltip. v3 would add `bool setupOk()` + `const char* health()` to MoonModule when a real failure mode exists. - `parent_id` per module — v1 shipped a *flat* array and the UI rebuilt the tree by walking `parent_id` (`buildTree()`). v3's `/api/state` returns a nested tree, so this is unnecessary. - `core` per module (FreeRTOS affinity 0/1) with inheritance (`propagateCore` — children take the parent's core). Irrelevant until core pinning is a real v3 engine feature. - Distinct `id` (stable, machine-friendly) vs `name` (human-friendly, editable). v3 uses `name` for both; split only when disambiguating two instances of one type, or when external configs reference ids. @@ -131,7 +131,7 @@ The v1 `index.html` was fully reverse-engineered. The notes below capture mechan **Rendering quirks worth keeping (or rejecting):** - v1 used `innerHTML` heavily with an `esc()` helper to neutralise HTML in user strings. v3 prefers `textContent` for all dynamic strings — no escaping, no XSS surface; reserve `innerHTML` for static templates in the JS source. - Log stick-to-bottom: tracks `logAtBottom` via `scrollTop + clientHeight >= scrollHeight - 5`; auto-scrolls newest only if the user hasn't scrolled up. Adopt with the log panel. -- Log severity colouring: `appendLogLine` lowercases and substring-matches `error`/`fail` → red, `warn` → yellow. No structured levels — cheap and matches how embedded logs read. Adopt with the log panel. +- Log severity coloring: `appendLogLine` lowercases and substring-matches `error`/`fail` → red, `warn` → yellow. No structured levels — cheap and matches how embedded logs read. Adopt with the log panel. - v1 auto-generated module ids: `type.toLowerCase().replace(/[^a-z0-9]/g,'') + '_' + (Date.now()%100000)`. v3 uses the typeName as the name at factory-create; a similar generator is needed if v3 ever splits id-from-name. - v1's `POST /api/modules/reorder {parent_id, ids:[…]}` reordered a whole sibling group at once; v3's `POST /api/modules/<n>/move {to:N}` moves one at a time. v3's form is simpler (up/down + drag call the same endpoint). diff --git a/docs/history/wled-WLED.md b/docs/history/wled-WLED.md index 5053d566..d07ab5d8 100644 --- a/docs/history/wled-WLED.md +++ b/docs/history/wled-WLED.md @@ -27,7 +27,7 @@ Post-16.0 stabilisation month: no new version tag (v16.0.0 shipped 2026-05-03 of **Watching** - Discussion opened on switching from plain gamma to an sRGB transfer function for better low-brightness accuracy (#5707), and on improving the Nodes/Instances page (#5711) — no shipped outcome yet. -- Several v16.0 field reports still open: multi-controller sync losing colour (#5705), UDP sync failing in AP mode (#5709), and OTA-update trouble on some boards (#5682, #5702). +- Several v16.0 field reports still open: multi-controller sync losing color (#5705), UDP sync failing in AP mode (#5709), and OTA-update trouble on some boards (#5682, #5702). _Auditability: 43 commits on `main` with author-date 2026-06-01..2026-06-30 (`repos/wled/WLED/commits?sha=main`, first-line view; a few older-dated cherry-picks appear in-range and were excluded as non-June). Issues via `search/issues` for repo:wled/WLED created:2026-06-01..2026-06-30 (18 opened) and closed:2026-06-01..2026-06-30 (25 closed); only user-facing ones surfaced. No versioned release published in June (v16.0.0 was 2026-05-03), so no month split._ @@ -41,7 +41,7 @@ _Auditability: 43 commits on `main` with author-date 2026-06-01..2026-06-30 (`re **Fixed / hardened** -- Effects: restored palette wrap in `color_wheel()` (a regression since 0.15.x), Twinkle fixes, Dissolve "Complete" same-colour-as-background fix, gravity audio-reactive top-LED fix. +- Effects: restored palette wrap in `color_wheel()` (a regression since 0.15.x), Twinkle fixes, Dissolve "Complete" same-color-as-background fix, gravity audio-reactive top-LED fix. - Audio-reactive auto-suspends in realtime modes (but stays on with "use main segment only"). - DDP and all realtime protocols: relaxed-but-safer header acceptance + bounds checks; Improv/UDP parsing hardened; `/reset` auth clarified. - Auto-migration for legacy sunrise/sunset config; animated-staircase inverted-PIR support. @@ -66,14 +66,14 @@ _Auditability: 43 commits on `main` with author-date 2026-06-01..2026-06-30 (`re **New / effects** -- **Full FastLED replacement** merged (#4615) — WLED's own colour/math instead of the FastLED dependency. +- **Full FastLED replacement** merged (#4615) — WLED's own color/math instead of the FastLED dependency. - Many new user_fx effects: Spinning Wheel, Color Clouds, Lava Lamp, Magma, Ants, Morse Code, Comet (fire particle system), a slow >4-hour transition FX, Tetris line-clear flash. - Scrolling-text FX gains custom fonts + international UTF-8; stencil blending mode; ESP32-C3 audio-reactive (DSP FFT + integer math); more macro/timer slots; longer max playlist duration. - OTA update page restyled (auto-sets download URL from `info.repo`); clearer UI tool icons. **Fixed** -- Segment-index misalignment; hostname/DNS cleanup; hue preservation in colour fade; array-bounds on short WS payloads; DDP rejects unsupported/non-display packets. +- Segment-index misalignment; hostname/DNS cleanup; hue preservation in color fade; array-bounds on short WS payloads; DDP rejects unsupported/non-display packets. ## February 2026 @@ -84,7 +84,7 @@ _Auditability: 43 commits on `main` with author-date 2026-06-01..2026-06-30 (`re - **Version scheme changed to Major.minor** (dropped the leading "0."), heading toward v16; bumped to 16.0.0-alpha. - New **Pin Info** page (used/available pins overview); UI settings readability improvements. - Improved bus handling — free choice of bus driver in any order, better memory calculations; gamma lower-limit removed (enables inverse gamma correction, applied to segment brightness too). -- Extended CCT blending (exclusive blend, colour-jump fix); full WiFi scan with BSSID apply; new ESP32-S3 8MB QSPI build; experimental ESP32-C5/C6 in the node list. +- Extended CCT blending (exclusive blend, color-jump fix); full WiFi scan with BSSID apply; new ESP32-S3 8MB QSPI build; experimental ESP32-C5/C6 in the node list. **Fixed** @@ -96,7 +96,7 @@ _Auditability: 43 commits on `main` with author-date 2026-06-01..2026-06-30 (`re **New** -- **New custom-palettes editor** (#5010); WPA-Enterprise WiFi support; random per-LED colours via JSON API; option to save unmodified presets to autosave; PixelForge GIF image rotation. +- **New custom-palettes editor** (#5010); WPA-Enterprise WiFi support; random per-LED colors via JSON API; option to save unmodified presets to autosave; PixelForge GIF image rotation. - Removed the MAX_LEDS_PER_BUS limit for virtual buses; new ESP32 node types; JSON validation + minify on file upload in the UI. **Fixed** @@ -147,4 +147,4 @@ _Auditability: 43 commits on `main` with author-date 2026-06-01..2026-06-30 (`re **Fixed** -- Tri Fade FX; custom-palette colour picker; Colortwinkles; LED buffer-size calculation; UDP name-sync rework; crash debug output added. +- Tri Fade FX; custom-palette color picker; Colortwinkles; LED buffer-size calculation; UDP name-sync rework; crash debug output added. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json new file mode 100644 index 00000000..d90c7d3e --- /dev/null +++ b/docs/metrics/repo-health.json @@ -0,0 +1,73 @@ +{ + "commit": "3666e4ab", + "flash": { + "esp32": 1684240, + "esp32p4-eth": 1497264, + "esp32p4-eth-wifi": 1793760, + "esp32s3-n16r8": 1667104, + "esp32s3-n8r8": 1666592, + "esp32s31": 1919136, + "desktop": 1413464 + }, + "perf": { + "desktop": { + "tick_us": 130, + "fps": 7692 + }, + "esp32": { + "tick_us": 4218, + "fps": 237 + } + }, + "loc": { + "core": 14819, + "light": 19511, + "platform": 11842, + "ui": 5811, + "test": 34420, + "moondeck": 18055 + }, + "comments": { + "core": { + "lines": 5541, + "ratio": 0.408 + }, + "light": { + "lines": 7433, + "ratio": 0.421 + }, + "platform": { + "lines": 3892, + "ratio": 0.364 + }, + "ui": { + "lines": 1518, + "ratio": 0.278 + }, + "test": { + "lines": 5874, + "ratio": 0.197 + }, + "moondeck": { + "lines": 2719, + "ratio": 0.173 + } + }, + "tests": { + "cases": 970, + "scenarios": 22 + }, + "docs": { + "md_files": 169, + "md_lines": 22540, + "plans_files": 89, + "backlog_lines": 3021, + "lessons_lines": 378, + "claude_md_lines": 126 + }, + "complexity": { + "functions": 2186, + "over_threshold": 162, + "worst_ccn": 93 + } +} diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md new file mode 100644 index 00000000..4e9841e2 --- /dev/null +++ b/docs/metrics/repo-health.md @@ -0,0 +1,62 @@ +# Repo health + +Measured at `3666e4ab`. 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. + +## Firmware size + +| Target | Flash | +|---|---:| +| desktop | 1,380 KB | +| esp32 | 1,645 KB | +| esp32p4-eth | 1,462 KB | +| esp32p4-eth-wifi | 1,752 KB | +| esp32s3-n16r8 | 1,628 KB | +| esp32s3-n8r8 | 1,628 KB | +| esp32s31 | 1,874 KB | + +## Render performance + +| Target | Tick | FPS | +|---|---:|---:| +| desktop | 130 µs (+4 µs) ⚠ | 7,692 (−244) ⚠ | +| esp32 | 4,218 µs | 237 | + +## Code + +| Area | Lines | Comments | Comment share | +|---|---:|---:|---:| +| core | 14,819 | 5,541 | 40.8 % | +| light | 19,511 | 7,433 | 42.1 % | +| platform | 11,842 | 3,892 | 36.4 % | +| ui | 5,811 | 1,518 | 27.8 % | +| test | 34,420 | 5,874 | 19.7 % | +| moondeck | 18,055 (+5) ⚠ | 2,719 | 17.3 % | + +## Tests + +| Kind | Count | +|---|---:| +| unit cases | 970 | +| scenarios | 22 | + +## Complexity + +| Metric | Value | +|---|---:| +| functions | 2,186 | +| over threshold | 162 | +| worst CCN | 93 | + +## Documentation + +| Metric | Value | +|---|---:| +| markdown files | 169 | +| markdown lines | 22,540 | +| plan files | 89 | +| backlog lines | 3,021 | +| lessons lines | 378 | +| CLAUDE.md lines | 126 | + diff --git a/docs/metrics/whitelizard.txt b/docs/metrics/whitelizard.txt new file mode 100644 index 00000000..1e284483 --- /dev/null +++ b/docs/metrics/whitelizard.txt @@ -0,0 +1,173 @@ +# lizard complexity baseline — the over-threshold functions as of this commit. +# +# Generated by `uv run moondeck/check/check_lizard.py --baseline`. Format is lizard's +# own whitelist (`file:function`, matched by NAME so it survives edits above the +# function). A line here means 'known, not yet fixed' — NOT 'acceptable'. +# +# This list only shrinks: simplify a function, delete its line. Adding a line means +# admitting a new violation, which is what the check exists to prevent. +# +# Thresholds: CCN > 10 or NLOC > 60. + +src/core/HttpServerModule.cpp:mm::HttpServerModule::handleConnection # CCN 93, NLOC 178 +src/light/effects/GameOfLifeEffect.h:mm::GameOfLifeEffect::evolveAutomaton # CCN 67, NLOC 93 +src/core/Control.cpp:mm::applyControlValue # CCN 56, NLOC 111 +src/core/NetworkModule.h:mm::NetworkModule::tick1s # CCN 55, NLOC 149 +src/light/effects/GEQ3DEffect.h:mm::GEQ3DEffect::tick # CCN 46, NLOC 106 +src/core/JsonUtil.h:mm::json::parseString # CCN 40, NLOC 47 +src/platform/esp32/platform_esp32_rmt.cpp:mm::platform::detail::captureAndVerifyFrame # CCN 39, NLOC 98 +src/light/effects/GameOfLifeEffect.h:mm::GameOfLifeEffect::tick # CCN 37, NLOC 47 +src/light/drivers/PreviewDriver.h:mm::PreviewDriver::buildAndSendCoordTable # CCN 36, NLOC 99 +src/light/effects/SolidEffect.h:mm::SolidEffect::tick # CCN 36, NLOC 99 +src/platform/desktop/platform_desktop.cpp:mm::platform::httpRequest # CCN 36, NLOC 78 +src/light/effects/LinesEffect.h:mm::LinesEffect::tick # CCN 36, NLOC 60 +src/light/drivers/PreviewDriver.h:mm::PreviewDriver::sendFrame # CCN 34, NLOC 63 +src/platform/esp32/platform_esp32_rmt.cpp:mm::platform::rmtWs2812LoopbackFrame # CCN 33, NLOC 85 +src/light/drivers/ParallelLedDriver.h:mm::ParallelLedDriver::reinit # CCN 33, NLOC 74 +src/platform/esp32/platform_esp32_moon_i80.cpp:mm::platform::createRingState # CCN 32, NLOC 110 +src/core/JsonUtil.h:mm::json::detail::JsonParser::parseValue # CCN 31, NLOC 58 +src/core/MqttPacket.h:mm::buildMqttConnect # CCN 31, NLOC 44 +src/platform/esp32/platform_esp32_moon_i80.cpp:mm::platform::moonI80Ws2812Loopback # CCN 30, NLOC 90 +src/platform/esp32/platform_esp32.cpp:mm::platform::httpRequest # CCN 30, NLOC 74 +src/light/drivers/PinList.h:mm::assignCounts # CCN 30, NLOC 61 +src/light/drivers/ParallelLedDriver.h:mm::ParallelLedDriver::parseConfig # CCN 29, NLOC 54 +src/light/effects/GEQEffect.h:mm::GEQEffect::tick # CCN 29, NLOC 52 +src/platform/esp32/platform_esp32_i80.cpp:mm::platform::createState # CCN 27, NLOC 84 +src/light/layers/BlendMap.h:mm::blendMap # CCN 27, NLOC 71 +src/core/moonlive/MoonLiveCompiler.cpp:mm::moonlive::Lexer::advance # CCN 27, NLOC 42 +src/core/Control.cpp:mm::writeControlMetadata # CCN 26, NLOC 59 +src/core/MqttModule.cpp:mm::MqttModule::routePublish # CCN 25, NLOC 61 +src/core/HttpServerModule.cpp:mm::HttpServerModule::parseFilePath # CCN 25, NLOC 33 +src/main.cpp:mm_main # CCN 24, NLOC 148 +src/light/layers/Layer.h:mm::Layer::buildFoldedLUT # CCN 24, NLOC 68 +src/light/effects/FreqSawsEffect.h:mm::FreqSawsEffect::tick # CCN 24, NLOC 62 +src/light/draw.h:mm::draw::line # CCN 24, NLOC 43 +src/light/drivers/Drivers.h:mm::Drivers::prepare # CCN 24, NLOC 36 +src/light/drivers/ParallelLedDriver.h:mm::ParallelLedDriver::runLoopbackSelfTest # CCN 23, NLOC 65 +src/light/effects/TetrixEffect.h:mm::TetrixEffect::tick # CCN 23, NLOC 56 +src/core/ImprovFrame.h:mm::ImprovFrameParser::feed # CCN 22, NLOC 59 +src/light/effects/AudioSpectrumEffect.h:mm::AudioSpectrumEffect::tick # CCN 22, NLOC 59 +src/platform/esp32/platform_esp32_improv.cpp:mm::platform::improvTask # CCN 22, NLOC 58 +src/light/drivers/NetworkSendDriver.h:mm::NetworkSendDriver::tick # CCN 21, NLOC 69 +src/core/Control.cpp:mm::writeControlValue # CCN 21, NLOC 58 +src/light/effects/RingsEffect.h:mm::RingsEffect::tick # CCN 21, NLOC 54 +src/light/drivers/PreviewDriver.h:mm::PreviewDriver::tick # CCN 21, NLOC 45 +src/core/moonlive/MoonLiveCompiler.cpp:mm::moonlive::Parser::parseCall # CCN 21, NLOC 38 +src/platform/esp32/platform_esp32.cpp:mm::platform::netSetStaticIPv4 # CCN 21, NLOC 36 +src/light/drivers/RmtLedDriver.h:mm::RmtLedDriver::tick # CCN 21, NLOC 35 +src/light/effects/GameOfLifeEffect.h:mm::GameOfLifeEffect::placePentomino # CCN 21, NLOC 32 +src/light/layouts/HumanSizedCubeLayout.h:mm::HumanSizedCubeLayout::forEachCoord # CCN 21, NLOC 26 +src/platform/esp32/platform_esp32_moon_i80.cpp:mm::platform::moonI80EofCb # CCN 20, NLOC 83 +src/light/effects/FireEffect.h:mm::FireEffect::tick # CCN 20, NLOC 46 +src/core/IpList.h:mm::parseIpList # CCN 20, NLOC 42 +src/light/drivers/Correction.h:mm::Correction::apply # CCN 20, NLOC 26 +src/core/HttpServerModule.cpp:mm::HttpServerModule::resolveEditableList # CCN 19, NLOC 48 +src/light/effects/BlurzEffect.h:mm::BlurzEffect::tick # CCN 19, NLOC 44 +src/light/drivers/ParallelSlots.h:mm::encodeWs2812ShiftData # CCN 19, NLOC 41 +src/core/MqttModule.cpp:mm::MqttModule::tick1s # CCN 19, NLOC 36 +src/light/effects/StarSkyEffect.h:mm::StarSkyEffect::tick # CCN 19, NLOC 36 +src/light/drivers/LightPresetsModule.h:mm::LightPresetsModule::restoreList # CCN 19, NLOC 34 +src/platform/esp32/platform_esp32.cpp:mm::platform::ethInitSpi # CCN 18, NLOC 74 +src/platform/esp32/platform_esp32_parlio.cpp:mm::platform::createState # CCN 18, NLOC 65 +src/platform/esp32/platform_esp32.cpp:mm::platform::ethInitEmac # CCN 18, NLOC 63 +src/light/layouts/RingLayout.h:mm::RingLayout::walk # CCN 18, NLOC 58 +src/platform/esp32/platform_esp32_rmt.cpp:mm::platform::rmtWs2812Loopback # CCN 18, NLOC 49 +src/platform/esp32/platform_esp32_moon_i80.cpp:mm::platform::createState # CCN 18, NLOC 47 +src/light/effects/StarFieldEffect.h:mm::StarFieldEffect::tick # CCN 18, NLOC 47 +src/platform/esp32/platform_esp32_improv.cpp:mm::platform::improvDispatchFrame # CCN 18, NLOC 44 +src/core/HttpServerModule.cpp:mm::HttpServerModule::applyWledState # CCN 18, NLOC 37 +src/light/layers/Layer.h:mm::Layer::applyLivePass # CCN 18, NLOC 31 +src/core/moonlive/MoonLiveCompiler.cpp:mm::moonlive::Parser::parseDecl # CCN 18, NLOC 27 +src/platform/desktop/moonlive_lower_host.cpp:mm::moonlive::lowerToBytes # CCN 17, NLOC 58 +src/platform/esp32/moonlive_lower_xtensa.cpp:mm::moonlive::lowerToBytes # CCN 17, NLOC 55 +src/platform/esp32/moonlive_lower_riscv.cpp:mm::moonlive::lowerToBytes # CCN 17, NLOC 54 +src/core/DevicesModule.h:mm::DevicesModule::upsertDevice # CCN 17, NLOC 34 +src/light/drivers/ParallelLedDriver.h:mm::ParallelLedDriver::snapshotSourceForRing # CCN 17, NLOC 33 +src/platform/esp32/platform_esp32_moon_i80.cpp:mm::platform::destroyState # CCN 17, NLOC 31 +src/light/drivers/Drivers.h:mm::Drivers::tick # CCN 17, NLOC 28 +src/core/HttpServerModule.cpp:mm::HttpServerModule::applyAddModule # CCN 17, NLOC 26 +src/light/drivers/LightPresetsModule.h:mm::LightPresetsModule::setListRowField # CCN 17, NLOC 26 +src/core/Control.cpp:mm::controlTypeName # CCN 17, NLOC 21 +src/light/E131Packet.h:mm::parseE131Packet # CCN 17, NLOC 17 +src/core/HttpServerModule.cpp:mm::HttpServerModule::serveFile # CCN 16, NLOC 57 +src/light/effects/FreqMatrixEffect.h:mm::FreqMatrixEffect::tick # CCN 16, NLOC 40 +src/core/MqttModule.cpp:mm::MqttModule::publishState # CCN 16, NLOC 39 +src/core/HttpServerModule.cpp:mm::HttpServerModule::drainPreviewSend # CCN 16, NLOC 37 +src/core/AudioBands.h:mm::magnitudesToBands # CCN 16, NLOC 36 +src/core/HttpServerModule.cpp:mm::HttpServerModule::pollWledStateFromWebSockets # CCN 16, NLOC 35 +src/core/AudioService.h:mm::AudioService::tick # CCN 16, NLOC 34 +src/light/effects/ParticlesEffect.h:mm::ParticlesEffect::tick # CCN 16, NLOC 33 +src/platform/esp32/platform_esp32_moon_i80.cpp:mm::platform::moonI80Ws2812InitRing # CCN 16, NLOC 30 +src/light/effects/RubiksCubeEffect.h:mm::RubiksCubeEffect::Cube::drawCube # CCN 16, NLOC 24 +src/core/Control.h:mm::sanitizeHostname # CCN 16, NLOC 20 +src/light/drivers/ParallelLedDriver.h:mm::ParallelLedDriver::tick # CCN 16, NLOC 13 +src/platform/esp32/platform_esp32_i80.cpp:mm::platform::i80Ws2812Loopback # CCN 15, NLOC 45 +src/platform/esp32/platform_esp32_ota.cpp:mm::platform::otaWriteStream # CCN 15, NLOC 45 +src/light/layouts/CarLightsLayout.h:mm::CarLightsLayout::emitRing # CCN 15, NLOC 44 +src/light/drivers/HueDriver.h:mm::HueDriver::parseLights # CCN 15, NLOC 34 +src/light/effects/FixedRectangleEffect.h:mm::FixedRectangleEffect::tick # CCN 15, NLOC 31 +src/core/PinList.h:mm::parsePinList # CCN 15, NLOC 27 +src/light/effects/RubiksCubeEffect.h:mm::RubiksCubeEffect::tick # CCN 15, NLOC 25 +src/light/drivers/RmtLedDriver.h:mm::RmtLedDriver::runLoopbackSelfTest # CCN 14, NLOC 57 +src/core/FilesystemModule.cpp:mm::FilesystemModule::applyNode # CCN 14, NLOC 51 +src/core/MqttModule.cpp:mm::MqttModule::handleInboundByte # CCN 14, NLOC 45 +src/core/MqttModule.cpp:mm::MqttModule::publishDiscovery # CCN 14, NLOC 40 +src/light/effects/NetworkReceiveEffect.h:mm::NetworkReceiveEffect::tick # CCN 14, NLOC 37 +src/core/AudioService.h:mm::AudioService::tick1s # CCN 14, NLOC 30 +src/core/AudioService.h:mm::AudioService::syncEnsureSocket # CCN 14, NLOC 29 +src/platform/esp32/platform_esp32_fs.cpp:mm::platform::fsWriteStream # CCN 14, NLOC 24 +src/light/drivers/HueDriver.h:mm::HueDriver::rgbToHsv # CCN 14, NLOC 14 +src/core/HttpServerModule.cpp:mm::HttpServerModule::handleReplaceModule # CCN 13, NLOC 54 +src/platform/esp32/platform_esp32.cpp:mm::platform::mdnsInit # CCN 13, NLOC 47 +src/core/Scheduler.cpp:mm::Scheduler::tick # CCN 13, NLOC 42 +src/core/MqttModule.cpp:mm::MqttModule::publishUpdateDiscovery # CCN 13, NLOC 35 +src/light/drivers/ParallelSlots.h:mm::encodeWs2812ShiftSlots # CCN 13, NLOC 35 +src/core/HttpServerModule.cpp:mm::HttpServerModule::writeControls # CCN 13, NLOC 32 +src/core/HttpServerModule.cpp:mm::HttpServerModule::pushStateToWebSockets # CCN 13, NLOC 31 +src/light/drivers/HueDriver.h:mm::HueDriver::parseGroups # CCN 13, NLOC 31 +src/core/JsonUtil.h:mm::json::detail::JsonParser::parseStringLiteral # CCN 13, NLOC 27 +src/core/IpList.h:mm::detail::parseOneIp # CCN 13, NLOC 26 +src/light/drivers/ParallelLedDriver.h:mm::ParallelLedDriver::onControlChanged # CCN 13, NLOC 25 +src/platform/desktop/platform_desktop.cpp:mm::platform::TcpConnection::connectStart # CCN 13, NLOC 21 +src/platform/esp32/platform_esp32_moon_i80.cpp:mm::platform::moonI80Ws2812Wait # CCN 13, NLOC 20 +src/platform/esp32/platform_esp32_parlio.cpp:mm::platform::parlioWs2812Loopback # CCN 12, NLOC 39 +src/platform/esp32/platform_esp32.cpp:mm::platform::wifiStaInit # CCN 12, NLOC 39 +src/light/effects/BouncingBallsEffect.h:mm::BouncingBallsEffect::tick # CCN 12, NLOC 39 +src/platform/esp32/platform_esp32_rmt.cpp:mm::platform::rmtWs2812RxCapture # CCN 12, NLOC 38 +src/platform/esp32/platform_esp32_improv.cpp:mm::platform::improvProvisioningInit # CCN 12, NLOC 37 +src/light/effects/RipplesEffect.h:mm::RipplesEffect::tick # CCN 12, NLOC 37 +src/platform/esp32/platform_esp32_improv.cpp:mm::platform::improvHandleProvision # CCN 12, NLOC 34 +src/platform/esp32/platform_esp32.cpp:mm::platform::wifiEventHandler # CCN 12, NLOC 34 +src/core/Scheduler.cpp:mm::Scheduler::setControl # CCN 12, NLOC 32 +src/light/layers/Layer.h:mm::Layer::allocateBuffer # CCN 12, NLOC 31 +src/core/JsonUtil.h:mm::json::detail::JsonParser::parseObject # CCN 12, NLOC 29 +src/light/drivers/LightPresetsModule.h:mm::LightPresetsModule::rebuildPool # CCN 12, NLOC 29 +src/light/effects/WaveEffect.h:mm::WaveEffect::tick # CCN 12, NLOC 29 +src/light/layers/MappingLUT.h:mm::MappingLUT::allocateDestinations # CCN 12, NLOC 28 +src/platform/desktop/platform_desktop.cpp:mm::platform::fsWriteStream # CCN 12, NLOC 27 +src/core/DevicesModule.h:mm::DevicesModule::upsertSelf # CCN 12, NLOC 25 +src/core/ModuleFactory.h:mm::ModuleFactory::displayNameFor # CCN 12, NLOC 24 +src/light/drivers/MoonLedDriver.h:mm::MoonI80Peripheral::busInitRing # CCN 12, NLOC 23 +src/core/DevicesModule.h:mm::DevicesModule::upsertHueBridge # CCN 12, NLOC 22 +src/core/PinsModule.h:mm::PinsModule::PinListSource::collect # CCN 12, NLOC 21 +src/core/NetworkModule.h:mm::NetworkModule::syncMdns # CCN 12, NLOC 19 +src/light/layouts/GridBlacksLayout.h:mm::GridBlacksLayout::forEachCoord # CCN 12, NLOC 17 +src/light/Palette.h:mm::Palettes::rgbToHueSat # CCN 12, NLOC 13 +src/light/draw.h:mm::draw::pixel # CCN 12, NLOC 11 +src/platform/esp32/platform_esp32.cpp:mm::platform::wifiApInit # CCN 11, NLOC 42 +src/light/effects/LavaLampEffect.h:mm::LavaLampEffect::tick # CCN 11, NLOC 42 +src/light/effects/PaintBrushEffect.h:mm::PaintBrushEffect::tick # CCN 11, NLOC 41 +src/light/drivers/ParallelLedDriver.h:mm::ParallelLedDriver::encodeRows # CCN 11, NLOC 40 +src/light/layers/Layer.h:mm::Layer::rebuildLUT # CCN 11, NLOC 37 +src/core/TasksModule.h:mm::TasksModule::TaskListSource::writeListRowDetail # CCN 11, NLOC 34 +src/core/MqttModule.cpp:mm::MqttModule::serviceConnected # CCN 11, NLOC 29 +src/platform/esp32/platform_esp32_fs.cpp:mm::platform::fsWriteAtomic # CCN 11, NLOC 26 +src/core/JsonSink.h:mm::JsonSink::writeJsonString # CCN 11, NLOC 24 +src/core/MoonModule.h:mm::MoonModule::mixSchema # CCN 11, NLOC 20 +src/light/drivers/HueDriver.h:mm::HueDriver::onControlChanged # CCN 11, NLOC 18 +src/core/JsonSink.h:mm::JsonSink::append # CCN 11, NLOC 17 +src/platform/esp32/platform_esp32_i80.cpp:mm::platform::i80Ws2812Init # CCN 11, NLOC 17 +src/core/moonlive/MoonLiveCompiler.cpp:mm::moonlive::Parser::parseProgram # CCN 11, NLOC 15 +src/platform/esp32/platform_esp32.cpp:mm::platform::resetReason # CCN 11, NLOC 15 +src/light/draw.h:mm::draw::blur # CCN 11, NLOC 14 +src/main.cpp:registerModuleTypes # CCN 4, NLOC 95 diff --git a/docs/moonmodules/core/system.md b/docs/moonmodules/core/system.md index b566d5cd..7e483bb3 100644 --- a/docs/moonmodules/core/system.md +++ b/docs/moonmodules/core/system.md @@ -71,14 +71,14 @@ Detail: [technical](moxygen/DevicesModule.md) ### MQTT -Bridges the light to an MQTT broker so a home-automation hub (Homebridge) can control it — a transport over the shared `Scheduler::setControl` apply-core, not new control logic. Our own dependency-free MQTT 3.1.1 client; disabled until a broker is set. Always-there network infra, a wired-by-code child of Network. Topics, colour-wheel mapping, and the Homebridge config: ⌄ details. +Bridges the light to an MQTT broker so a home-automation hub (Homebridge) can control it — a transport over the shared `Scheduler::setControl` apply-core, not new control logic. Our own dependency-free MQTT 3.1.1 client; disabled until a broker is set. Always-there network infra, a wired-by-code child of Network. Topics, color-wheel mapping, and the Homebridge config: ⌄ details. <img src="../../assets/core/MqttModule.png" width="300" alt="MQTT module controls"> - `broker` — the broker hostname (e.g. `homeassistant.lan`) or IP. A hostname is resolved via DNS. - `port` — broker port (default 1883). - `username` / `password` — broker credentials (optional; the password is stored obfuscated like the WiFi password). -- `haDiscovery` — announce a Home Assistant MQTT-discovery light (default off, opt-in). HA already auto-discovers the device over the WLED `/json` shim (colour + palette + sensors, no broker), so this stays off to avoid a duplicate entity; turn it on for broker-only / cross-subnet setups where mDNS doesn't reach. When on, HA auto-creates a wired entity; toggling it off removes it. See the [home-automation guide](../../usecases/home-automation.md). +- `haDiscovery` — announce a Home Assistant MQTT-discovery light (default off, opt-in). HA already auto-discovers the device over the WLED `/json` shim (color + palette + sensors, no broker), so this stays off to avoid a duplicate entity; turn it on for broker-only / cross-subnet setups where mDNS doesn't reach. When on, HA auto-creates a wired entity; toggling it off removes it. See the [home-automation guide](../../usecases/home-automation.md). - read-only — `mqtt_status` (`disabled` / `idle` / `connecting` / `connected` / `disconnected` / an error). Detail: [technical](moxygen/MqttModule.md) @@ -146,7 +146,7 @@ Detail: [technical](moxygen/TasksModule.md) A read-only diagnostic that shows **which module owns each GPIO, for what role, and whether that pin is safe for it** — the device's pin ownership map, keyed by physical GPIO the way an OS Device Manager, a Tasmota template, or a GPIOViewer diagram is. A fixed System module, wired-by-code, always present. It walks the live module tree and collects every claimed pin — each GPIO control (a mic's `sckPin`/`wsPin`/`sdPin`, an Ethernet PHY's `ethMdcGpio`, a driver's `loopbackTxPin`) and each LED-driver `pins` lane CSV — so it needs no state of its own: unlike a central pin manager, each module owns its pins and this one only observes. A GPIO claimed by two controls is flagged red at the summary and lists both owners in the row detail — the read-only way to surface a conflict (a mic pin colliding with an LED lane, two lanes on one pin) without wedging the device: the claim still lands, the map just makes it loud. A **disabled** module's pins drop out of the map (switching a module off frees its GPIOs, on re-claims them) — the intent side of releasing resources on disable. Refreshes once a second, so a live pin change shows without a reboot. -- read-only — `pins` (a row per claimed GPIO: `gpio`, `owner` = the owning module, `role` = derived from the control name — `sckPin`→BCLK, `wsPin`→WS, `pins`→LED lane N, `ethMdcGpio`→MDC, …). A row is flagged with a coloured edge when the claim is unsafe: **error** (red) for a claim on a reserved flash/PSRAM/USB pin or a double-claim, **warn** (yellow) for a driven role on a boot strap or input-only pin. The strap/reserved data comes from [gpio-usage.md](../../reference/gpio-usage.md) via the platform layer; PSRAM-conditional pins (classic-ESP32 16/17, S3 33-37) are flagged only when PSRAM is actually present at runtime, so a bare-WROOM board isn't falsely flagged. Each row also shows the pin's **live state** — `dir` (out/in/both/off, the pad's *actual* direction right now — shown as information, not auto-flagged, since a pin reading input/off is often legitimate: an idle I²C line, an unrun loopback pin, an external clock), `level` (HIGH/LOW, read straight off the pad — a driver's output must toggle when it renders, a mic clock must toggle when the mic runs), and `drive` (WEAK…STRONGEST). Expand a row to see every claim on that GPIO (`owner · role`) plus a `warning` line naming *why* it's flagged; a double-claim lists all co-owners. Unused pins (value −1) are skipped. +- read-only — `pins` (a row per claimed GPIO: `gpio`, `owner` = the owning module, `role` = derived from the control name — `sckPin`→BCLK, `wsPin`→WS, `pins`→LED lane N, `ethMdcGpio`→MDC, …). A row is flagged with a colored edge when the claim is unsafe: **error** (red) for a claim on a reserved flash/PSRAM/USB pin or a double-claim, **warn** (yellow) for a driven role on a boot strap or input-only pin. The strap/reserved data comes from [gpio-usage.md](../../reference/gpio-usage.md) via the platform layer; PSRAM-conditional pins (classic-ESP32 16/17, S3 33-37) are flagged only when PSRAM is actually present at runtime, so a bare-WROOM board isn't falsely flagged. Each row also shows the pin's **live state** — `dir` (out/in/both/off, the pad's *actual* direction right now — shown as information, not auto-flagged, since a pin reading input/off is often legitimate: an idle I²C line, an unrun loopback pin, an external clock), `level` (HIGH/LOW, read straight off the pad — a driver's output must toggle when it renders, a mic clock must toggle when the mic runs), and `drive` (WEAK…STRONGEST). Expand a row to see every claim on that GPIO (`owner · role`) plus a `warning` line naming *why* it's flagged; a double-claim lists all co-owners. Unused pins (value −1) are skipped. Detail: [technical](moxygen/PinsModule.md) @@ -168,7 +168,7 @@ The topic prefix is `projectMM/<mac>` — a **stable** identifier (the last 6 he | device → get | `projectMM/563cfe/update/state` | `{"installed_version":…,"latest_version":…,"release_url":…,"title":…}` (retained; HA update entity) | | set → device | `projectMM/563cfe/update/set` | target version string (empty = install latest); triggers OTA against the matching GitHub release asset | -The HomeKit colour wheel has no "palette" concept, so `hsv/set`'s hue+saturation pick the **nearest palette** (each built-in palette has a representative colour; the closest one is selected) and the value drives brightness — the colour wheel becomes a natural palette selector. +The HomeKit color wheel has no "palette" concept, so `hsv/set`'s hue+saturation pick the **nearest palette** (each built-in palette has a representative color; the closest one is selected) and the value drives brightness — the color wheel becomes a natural palette selector. **Homebridge** — install [`homebridge-mqttthing`](https://github.com/arachnetech/homebridge-mqttthing) and add a `lightbulb` accessory. Use the device's own MAC suffix (read it from the `mqtt_status`/topics, or `mosquitto_sub -t 'projectMM/#'`) in place of `563cfe`: @@ -194,7 +194,7 @@ The HomeKit colour wheel has no "palette" concept, so `hsv/set`'s hue+saturation ``` Home Assistant adopts the device two ways, both zero-config: -- **MQTT auto-discovery** — with `haDiscovery` on (opt-in; off by default) and a broker set, the device announces itself on `homeassistant/light/projectMM_<mac6>/config` and HA auto-creates a wired entity with **on/off + brightness** (the config declares `brightness` only; colour isn't in it, so the entity has no colour control). Retained across reboots. Colour/palette stays on the separate `hsv/set` topic above, not this entity. Off by default because the WLED `/json` shim already gives HA a richer light (colour + palette + sensors) over mDNS with no broker — leaving both on lists the device twice; enable this only for broker-only / cross-subnet setups. +- **MQTT auto-discovery** — with `haDiscovery` on (opt-in; off by default) and a broker set, the device announces itself on `homeassistant/light/projectMM_<mac6>/config` and HA auto-creates a wired entity with **on/off + brightness** (the config declares `brightness` only; color isn't in it, so the entity has no color control). Retained across reboots. Color/palette stays on the separate `hsv/set` topic above, not this entity. Off by default because the WLED `/json` shim already gives HA a richer light (color + palette + sensors) over mDNS with no broker — leaving both on lists the device twice; enable this only for broker-only / cross-subnet setups. - **WLED integration** — HA's built-in WLED integration discovers the device over the WLED `/json` API projectMM already serves; on/off + brightness work with no broker. Both can be on at once. Setup walkthrough (including exposing HA to Apple Home via HA's HomeKit Bridge, no Homebridge needed) in the [Home Assistant recipe](../../usecases/home-automation.md#adopt-in-home-assistant). diff --git a/docs/moonmodules/core/ui.md b/docs/moonmodules/core/ui.md index 02d2bf65..677d2bc8 100644 --- a/docs/moonmodules/core/ui.md +++ b/docs/moonmodules/core/ui.md @@ -184,7 +184,7 @@ One picker serves **add** (`+ add child`) and **replace** (the ✎ button), rend `font-variant-numeric: tabular-nums` so digits don't dance. - **Module nesting** by progressively lighter card backgrounds (depth 0 / 1 / 2). - **Responsive breakpoint** at 820 px. -- **Colour semantics** consistent across the app: green (`#22c55e`/`#6ee7b7`) = connected/ok/pass; red +- **Color semantics** consistent across the app: green (`#22c55e`/`#6ee7b7`) = connected/ok/pass; red (`#f87171`) = error/fail/crashed/delete; purple (`#a78bfa`/`#c4b5fd`) = accent/brand/active/value; gray (`#6b7280`/`#9ca3af`/`#4b5563`) = secondary/muted. diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index ee496a4f..c2da5cd6 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -14,7 +14,7 @@ The functions are **not built into the compiler** — `setRGB`, `fill`, `random1 ## Controls -- `source` — the script text (default: random pixels — `setRGB(random16(256), random16(256), random16(256), random16(256));`, one random light in a random colour each tick). Editing it recompiles live: a valid script swaps in on the next tick; a failed compile frees the old code, shows the diagnostic in the module status, and renders dark until fixed (the script-editor loop, robust + no reboot). +- `source` — the script text (default: random pixels — `setRGB(random16(256), random16(256), random16(256), random16(256));`, one random light in a random color each tick). Editing it recompiles live: a valid script swaps in on the next tick; a failed compile frees the old code, shows the diagnostic in the module status, and renders dark until fixed (the script-editor loop, robust + no reboot). - **Scripted controls** — a script declares a tunable variable with a range annotation, and the engine surfaces it as a real `uint8` MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick: ```c @@ -54,7 +54,7 @@ MoonLive's native-codegen approach — compile a small C-like language straight The grammar + bounds guard are verified live on the S3/Olimex (Xtensa) by editing the `source` control — the device compiles the expression on-chip and renders it. -[scenario_MoonLiveEffect_livescript](../../../test/scenarios/light/scenario_MoonLiveEffect_livescript.json) exercises the effect **as a wired MoonModule** — what the unit tests can't reach: add it, live-edit the `source` to recolour (recompile), push a broken script (`MoonLive::compile` fails, frees the previous code, `MoonLiveEffect` reports the parse error in the status and renders dark — no crash), recover, resize the grid to 1×1 and back while rendering (the every-grid-size hard rule), then remove and re-add (exec memory re-acquired clean). It runs in-process on the desktop backend each commit, and the same JSON runs live over REST against the device backends. The Xtensa/RISC-V backends are validated by the live S3/P4 runs (a `MoonLiveEffect` on a Layer lights the grid from its `source`), which the desktop tests can't reach. +[scenario_MoonLiveEffect_livescript](../../../test/scenarios/light/scenario_MoonLiveEffect_livescript.json) exercises the effect **as a wired MoonModule** — what the unit tests can't reach: add it, live-edit the `source` to recolor (recompile), push a broken script (`MoonLive::compile` fails, frees the previous code, `MoonLiveEffect` reports the parse error in the status and renders dark — no crash), recover, resize the grid to 1×1 and back while rendering (the every-grid-size hard rule), then remove and re-add (exec memory re-acquired clean). It runs in-process on the desktop backend each commit, and the same JSON runs live over REST against the device backends. The Xtensa/RISC-V backends are validated by the live S3/P4 runs (a `MoonLiveEffect` on a Layer lights the grid from its `source`), which the desktop tests can't reach. ## Source diff --git a/docs/moonmodules/light/effects.md b/docs/moonmodules/light/effects.md index 3c8b29d2..30e11863 100644 --- a/docs/moonmodules/light/effects.md +++ b/docs/moonmodules/light/effects.md @@ -1,10 +1,10 @@ # Effects -Every effect, one block each: its preview, what it does, and what each control means — together. An effect writes per-pixel colour into its [Layer](moxygen/Layer.md)'s buffer each tick; [modifiers](modifiers.md) reshape the result and a [driver](moxygen/PreviewDriver.md) sends it out. Effects that name an index colour read the global palette (the `palette` control on [Drivers](moxygen/Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio — see the [tag emoji legend](../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](moxygen/Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (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).) +Every effect, one block each: its preview, what it does, and what each control means — together. An effect writes per-pixel color into its [Layer](moxygen/Layer.md)'s buffer each tick; [modifiers](modifiers.md) reshape the result and a [driver](moxygen/PreviewDriver.md) sends it out. Effects that name an index color read the global palette (the `palette` control on [Drivers](moxygen/Drivers.md)) via `colorFromPalette`. Each block's emoji are its `tags()` (origin/creator/audio — see the [tag emoji legend](../../architecture.md#tag-emoji-legend)); **Dim** is its native axes ([Layer](moxygen/Layer.md) extrudes a lower-dim effect onto a bigger grid). Effects are grouped into sections by origin, and each block carries that effect's preview, behaviour, and control descriptions together. (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).) **Jump to:** [MoonLight](#moonlight-effects) · [MoonModules](#moonmodules-effects) · [WLED](#wled-effects) · [FastLED](#fastled-effects) · [projectMM-native](#projectmm-native-effects) -**Migrating an effect — behaviour is the spec.** A ported effect must reproduce the original's **exact** visual behaviour: end users have relied on these for years, so a port that looks different is a regression, not an improvement. Don't get creative with defaults, oscillator math, colour mapping, or geometry, and don't silently drop a parameter that *is* the mechanism (the PaintBrush straight-vs-curved-lines bug was a dropped partial-line `length`; Game of Life was wrong the first time by not porting the real algorithm). Study the source for the algorithm, defaults, and visual result; pin it with unit + scenario tests; then write our **own** implementation against `EffectBase`/our primitives — carry the behaviour forward, don't trace or copy the structure (see [*Industry standards, our own code*](../../../CLAUDE.md#principles)). Credit the origin as prior art in the block below. +**Migrating an effect — behaviour is the spec.** A ported effect must reproduce the original's **exact** visual behaviour: end users have relied on these for years, so a port that looks different is a regression, not an improvement. Don't get creative with defaults, oscillator math, color mapping, or geometry, and don't silently drop a parameter that *is* the mechanism (the PaintBrush straight-vs-curved-lines bug was a dropped partial-line `length`; Game of Life was wrong the first time by not porting the real algorithm). Study the source for the algorithm, defaults, and visual result; pin it with unit + scenario tests; then write our **own** implementation against `EffectBase`/our primitives — carry the behaviour forward, don't trace or copy the structure (see [*Industry standards, our own code*](../../../CLAUDE.md#principles)). Credit the origin as prior art in the block below. > Some WLED-origin effects show a preview gif from [WLED-Utils](https://github.com/scottrbailey/WLED-Utils) by scottrbailey (the canonical WLED effect gif set, cross-linked with credit); these show WLED's rendering. Effects with a local `../../assets/…` gif show our own output. @@ -14,7 +14,7 @@ Every effect, one block each: its preview, what it does, and what each control m ### DistortionWaves 💫 · 2D -Two interfering sine waves beat against each other into a moiré colour field. +Two interfering sine waves beat against each other into a moiré color field. - `freq_x` / `freq_y` — horizontal/vertical wave frequency (1–8). - `speed` — animation rate (0 = frozen). @@ -29,9 +29,9 @@ Detail: [technical](moxygen/DistortionWavesEffect.md) ### FixedRectangle 💫 · 3D -A solid colour filling a positioned box within the grid, with an optional alternating-white checker on the box's pixels. +A solid color filling a positioned box within the grid, with an optional alternating-white checker on the box's pixels. -- `red` / `green` / `blue` / `white` — the box colour. +- `red` / `green` / `blue` / `white` — the box color. - `X position` / `Y position` / `Z position` — the box's origin corner. - `Rectangle width` / `Rectangle height` / `Rectangle depth` — the box extent on each axis. - `alternateWhite` — alternate box pixels to white in a checker pattern. @@ -186,7 +186,7 @@ Detail: [technical](moxygen/RainbowEffect.md) ### Random 💫 · 3D -Lights one random light per frame in a random palette colour over a fading background — a sparse, palette-tinted sparkle. +Lights one random light per frame in a random palette color over a fading background — a sparse, palette-tinted sparkle. - `fade` — how fast prior sparkles fade to black. @@ -236,12 +236,12 @@ Detail: [technical](moxygen/RipplesEffect.md) ### RubiksCube 💫🧊 · 3D -A 3D Rubik's Cube projected onto the volume: it scrambles, then plays its solution back one turn at a time, the six faces in their standard colours. +A 3D Rubik's Cube projected onto the volume: it scrambles, then plays its solution back one turn at a time, the six faces in their standard colors. - `turnsPerSecond` — how fast the cube turns. - `cubeSize` — the cube order (2×2 up to 8×8). - `randomTurning` — turn endlessly at random instead of scramble-then-solve. -- `usePalette` — colour the six faces from the system-wide palette instead of the classic Rubik's colours. +- `usePalette` — color the six faces from the system-wide palette instead of the classic Rubik's colors. Origin: MoonLight · by WildCats08 / [@Brandon502](https://github.com/Brandon502) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -253,9 +253,9 @@ Detail: [technical](moxygen/RubiksCubeEffect.md) ### Solid 💫 · 3D -A flat fill with five colour modes: a plain RGB(W) colour, the active palette spread across the lights, an RMS-averaged single palette colour, or the palette banded along the grid's rows or columns. +A flat fill with five color modes: a plain RGB(W) color, the active palette spread across the lights, an RMS-averaged single palette color, or the palette banded along the grid's rows or columns. -- `red` / `green` / `blue` / `white` — the flat colour in `RGB(W)` mode (ignored in the palette modes). +- `red` / `green` / `blue` / `white` — the flat color in `RGB(W)` mode (ignored in the palette modes). - `brightness` — scales the flat and palette-spread output. - `colorMode` — `RGB(W)`, `Palette` (spread across the lights), `Palette avg` (RMS mean of the palette), `Palette rows`, `Palette cols` (palette banded along that axis). - `minRGB` — in the band modes, drops palette entries whose every channel is below this floor. @@ -271,7 +271,7 @@ Detail: [technical](moxygen/SolidEffect.md) ### SphereMove 💫🧊 · 3D -A hollow spherical shell that bounces through the 3D volume, its surface coloured from the palette, leaving no trail. +A hollow spherical shell that bounces through the 3D volume, its surface colored from the palette, leaving no trail. - `speed` — how fast the sphere moves through the volume. @@ -308,7 +308,7 @@ A perspective starfield: stars approach the viewer from a vanishing point, brigh - `speed` — how fast stars approach (frame throttle). - `numStars` — how many stars are active. - `blur` — motion-trail fade per frame. -- `usePalette` — colour the stars from the palette instead of white. +- `usePalette` — color the stars from the palette instead of white. Origin: MoonLight · by [@Brandon502](https://github.com/Brandon502), inspired by Daniel Shiffman / [Coding Train](https://www.youtube.com/watch?v=17WoOqgXsRM) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -326,7 +326,7 @@ Twinkling stars at random light positions, each fading in and out independently - `speed` — fade rate per frame (how fast each star brightens/dims). - `star_fill_ratio` — how many stars (as a fraction of the light count). -- `usePalette` — colour the stars from the active palette instead of white. +- `usePalette` — color the stars from the active palette instead of white. Origin: MoonLight · by [limpkin](https://github.com/limpkin) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -338,13 +338,13 @@ Detail: [technical](moxygen/StarSkyEffect.md) ### Text 💫 · 2D -Renders a multi-line string in a bitmap font. Static by default (laid out top-left, each newline dropping one font-height, clipped where it runs off the grid); turn on `scroll` to march the whole block leftwards as a wrapping marquee. Text colour comes from the active palette. +Renders a multi-line string in a bitmap font. Static by default (laid out top-left, each newline dropping one font-height, clipped where it runs off the grid); turn on `scroll` to march the whole block leftwards as a wrapping marquee. Text color comes from the active palette. - `text` — the string to show; a **multi-line text area** (each line renders on its own row). - `scroll` — off (default) = static; on = horizontal marquee. - `font` — glyph size (`4x6` compact, `6x8` larger). - `speed` — marquee speed (only used when `scroll` is on). -- `hue` — palette index for the text colour. +- `hue` — palette index for the text color. Origin: projectMM original, on MoonLight's Scrolling Text · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h) @@ -358,19 +358,19 @@ Detail: [technical](moxygen/TextEffect.md) ### GameOfLife 💫🌙 · 2D/3D -Conway's cellular automaton generalised to 2D/3D: selectable rulesets (+ custom `B#/S#`), cells that inherit a neighbour's palette colour on birth, optional green→red age colouring, a dead-cell blur fading toward the background colour, toroidal `wrap`, a 1.5 s settle pause, and 3-CRC stasis self-respawn (R-pentomino/glider) when the board goes static. +Conway's cellular automaton generalised to 2D/3D: selectable rulesets (+ custom `B#/S#`), cells that inherit a neighbour's palette color on birth, optional green→red age coloring, a dead-cell blur fading toward the background color, toroidal `wrap`, a 1.5 s settle pause, and 3-CRC stasis self-respawn (R-pentomino/glider) when the board goes static. -- `backgroundColorR` / `backgroundColorG` / `backgroundColorB` — the colour dead cells fade toward (0–255 each). +- `backgroundColorR` / `backgroundColorG` / `backgroundColorB` — the color dead cells fade toward (0–255 each). - `ruleset` — the birth/survive rule (Conway, HighLife, InverseLife, Maze, Mazecentric, DrighLife, or Custom). - `customRuleString` — a custom `B#/S#` rule, read only when `ruleset` = Custom. - `GameSpeed (FPS)` — generation rate (0–100, 100 = uncapped). - `startingLifeDensity` — % of cells alive at start (10–90). -- `mutationChance` — % chance a newborn gets a random colour (0–100). +- `mutationChance` — % chance a newborn gets a random color (0–100). - `wrap` — toroidal edges (cells wrap around). - `disablePause` — skip the 1.5 s settle pause between boards. -- `colorByAge` — green→red aging instead of inheriting a neighbour's palette colour. +- `colorByAge` — green→red aging instead of inheriting a neighbour's palette color. - `infinite` — respawn on stasis (R-pentomino/glider) instead of resetting. -- `blur` — dead-cell fade strength toward the background colour. +- `blur` — dead-cell fade strength toward the background color. Origin: MoonModules · by Ewoud Wijma (2022), mods by Brandon Butler / [@Brandon502](https://github.com/Brandon502) · [natureofcode](https://natureofcode.com/book/chapter-7-cellular-automata/) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonModules.h) @@ -384,11 +384,11 @@ Detail: [technical](moxygen/GameOfLifeEffect.md) <img src="https://raw.githubusercontent.com/scottrbailey/WLED-Utils/master/gifs/FX_139.gif" width="300" alt="GEQ effect preview" title="WLED effect preview — WLED-Utils by scottrbailey"> <!-- preview: WLED-Utils (scottrbailey), WLED FX 139; replace with our own capture once bench-verified --> -A flat graphic equaliser: the 16 audio bands rise as vertical bars from the bottom, with optional smoothing between bars, per-bar palette colouring, and falling peak markers. +A flat graphic equaliser: the 16 audio bands rise as vertical bars from the bottom, with optional smoothing between bars, per-bar palette coloring, and falling peak markers. - `fadeOut` — how fast bars fade each frame. - `ripple` — falling-peak marker decay. -- `colorBars` — colour each bar from the palette by band instead of by row. +- `colorBars` — color each bar from the palette by band instead of by row. - `smoothBars` — blend neighbouring bands for smoother bar heights. Origin: WLED (audio) · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -458,7 +458,7 @@ Falling Tetris-style blocks: each column drops a brick that lands on the growing - `speed` — fall speed (0 = randomised per brick). - `width` — brick height (0 = randomised). -- `oneColor` — one advancing palette colour for all bricks instead of random per-brick colours. +- `oneColor` — one advancing palette color for all bricks instead of random per-brick colors. Origin: WLED · by Andrew Tuline (WLED-SR) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -493,7 +493,7 @@ Detail: [technical](moxygen/BlurzEffect.md) <img src="https://raw.githubusercontent.com/scottrbailey/WLED-Utils/master/gifs/FX_091.gif" width="300" alt="BouncingBalls effect preview" title="WLED effect preview — WLED-Utils by scottrbailey"> <!-- preview: WLED-Utils (scottrbailey), WLED FX 91; replace with our own capture once bench-verified --> -A row of balls per column bounce under gravity, each losing energy on impact and relaunching when it stops, palette-coloured by ball index over a fading background. +A row of balls per column bounce under gravity, each losing energy on impact and relaunching when it stops, palette-colored by ball index over a fading background. - `grav` — gravity strength (higher = faster fall, snappier bounce). - `numBalls` — balls per column (1–16). @@ -530,7 +530,7 @@ Detail: [technical](moxygen/FreqMatrixEffect.md) <img src="https://raw.githubusercontent.com/scottrbailey/WLED-Utils/master/gifs/FX_176.gif" width="300" alt="Lissajous effect preview" title="WLED effect preview — WLED-Utils by scottrbailey"> <!-- preview: WLED-Utils (scottrbailey), WLED FX 176; replace with our own capture once bench-verified --> -A Lissajous curve traced across the grid from two phase-shifted `sin8`/`cos8` sweeps, palette-coloured along its length, with a fading trail. +A Lissajous curve traced across the grid from two phase-shifted `sin8`/`cos8` sweeps, palette-colored along its length, with a fading trail. - `xFrequency` — the x-axis sweep frequency (sets the curve's lobe count). - `fadeRate` — trail fade per frame. @@ -548,7 +548,7 @@ Detail: [technical](moxygen/LissajousEffect.md) <img src="https://raw.githubusercontent.com/scottrbailey/WLED-Utils/master/gifs/FX_136.gif" width="300" alt="NoiseMeter effect preview" title="WLED effect preview — WLED-Utils by scottrbailey"> <!-- preview: WLED-Utils (scottrbailey), WLED FX 136; replace with our own capture once bench-verified --> -An audio VU meter rendered as a noise bar: the volume sets how many rows light from the bottom, each row coloured by drifting Perlin noise, filling the full width and depth. +An audio VU meter rendered as a noise bar: the volume sets how many rows light from the bottom, each row colored by drifting Perlin noise, filling the full width and depth. - `fadeRate` — trail decay per frame (200–254). - `width` — how strongly the volume drives the bar height. @@ -588,7 +588,7 @@ Fire2012-style heat field — sparks at the base rise and cool through the activ - `cooling` — how fast heat dissipates as it rises (higher = shorter flames). - `sparking` — chance of a new spark at the base each frame (higher = livelier fire). -The flame colour comes from the **active palette**. For the classic fire look pick the **Lava** palette (black→red→orange→yellow→white — the recommended default); any palette works, so an Ocean or Forest palette turns the flame blue or green. +The flame color comes from the **active palette**. For the classic fire look pick the **Lava** palette (black→red→orange→yellow→white — the recommended default); any palette works, so an Ocean or Forest palette turns the flame blue or green. Origin: FastLED / MoonLight · Mark Kriegsman's Fire2012; MoonLight adapts [MatrixFireFast](https://github.com/toggledbits/MatrixFireFast) (toggledbits) @@ -621,7 +621,7 @@ Detail: [technical](moxygen/NoiseEffect.md) The 16 mic frequency bands spread across X, each column lit bottom-up by its magnitude. -- `colorMode` — bar colouring: `height` (green base → red top, the VU look) or `per-band` (each column its own hue, the rainbow analyser look). +- `colorMode` — bar coloring: `height` (green base → red top, the VU look) or `per-band` (each column its own hue, the rainbow analyser look). Origin: projectMM original, on the WLED-SR GEQ / spectrum concept (Andrew Tuline) · via [MoonLight](https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h) @@ -633,7 +633,7 @@ Detail: [technical](moxygen/AudioSpectrumEffect.md) ### AudioVolume 🔊 -A whole-grid VU meter: every light pulses with the mic level, colour indexing the palette by loudness. +A whole-grid VU meter: every light pulses with the mic level, color indexing the palette by loudness. - `brightness` — overall brightness ceiling for the VU pulse (1–255). @@ -681,7 +681,7 @@ Detail: [technical](moxygen/NetworkReceiveEffect.md) ### Sine 🌀 · 3D -R/G/B each follow a sine along one axis at 120° phase offset — a glowing, scrolling colour box. +R/G/B each follow a sine along one axis at 120° phase offset — a glowing, scrolling color box. - `frequency` — spatial frequency, waves across the box (1–20). - `amplitude` — peak brightness (0–255, 255 = full). diff --git a/docs/principles-and-process.md b/docs/principles-and-process.md new file mode 100644 index 00000000..b0bebbdd --- /dev/null +++ b/docs/principles-and-process.md @@ -0,0 +1,17 @@ +--- +title: Principles & process +--- + +<!-- The rules live in CLAUDE.md at the repo root, where every Claude Code session + auto-loads them and where GitHub renders them. This page embeds that file rather + than restating it, so the site and the working rulebook are the same text (one + home, per the no-duplication rule); pymdownx.snippets resolves the path from the + repo root via base_path. + + The `title:` above names the page in the nav and the browser tab. The embedded + text opens with its own `# CLAUDE.md` heading, which is the right name at the repo + root but not in a docs menu — front matter overrides it without editing the source + file. The doc links inside the embed are rebased at build time (docs/x.md → x.html); + see _rebase_repo_root_doc_links in moondeck/docs/mkdocs_hooks.py. --> + +--8<-- "CLAUDE.md" diff --git a/docs/usecases/build-your-own-moonmodules.md b/docs/usecases/build-your-own-moonmodules.md index 81b89577..733343ca 100644 --- a/docs/usecases/build-your-own-moonmodules.md +++ b/docs/usecases/build-your-own-moonmodules.md @@ -25,12 +25,12 @@ A projectMM light show is a small tree of MoonModules: Layouts → where the LEDs are in space (a Grid, a sphere, a strip) Layers → a stack of images being drawn Layer → one image, built by… - Effect → draws colour into the image (the fun part) + Effect → draws color into the image (the fun part) Modifier → bends/masks/repeats the image Drivers → send the finished image to the physical LEDs ``` -An **effect** is the most creative and the easiest to write, so we start there. An effect's whole job is: **every frame, write colours into a buffer.** The buffer is just an array of bytes — 3 per LED (red, green, blue) — and the framework hands it to you already sized to the grid. You loop over the pixels and set colours. That's it. +An **effect** is the most creative and the easiest to write, so we start there. An effect's whole job is: **every frame, write colors into a buffer.** The buffer is just an array of bytes — 3 per LED (red, green, blue) — and the framework hands it to you already sized to the grid. You loop over the pixels and set colors. That's it. Everything else — allocating that buffer, figuring out where each pixel is, pushing the finished frame to real LEDs, stopping cleanly when the user turns your effect off — is done *for* you by the core. @@ -44,7 +44,7 @@ Your module is a class with a handful of **hook functions**. You override the on |---|---|---| | `defineControls()` | At startup, and whenever the control set changes | Declare your sliders/toggles (e.g. a `speed` control) | | `prepare()` | At boot, and whenever the grid size / config changes | *(only if you need memory)* size a `ScratchBuffer` to the grid — one line | -| `tick()` | **Every render tick** | Draw your colours. This is the heart of an effect. | +| `tick()` | **Every render tick** | Draw your colors. This is the heart of an effect. | | `release()` | When your module is removed or **switched off** | *(usually nothing)* the core frees your `ScratchBuffer`s for you; override only to release a non-buffer resource like a GPIO | **The core calls these — you only fill them in.** It decides when to build, when to run, and when to release. A single traffic-cop method — `applyState()` — visits every module in the tree and either **builds it** (calls `prepare()`) when it's enabled or **releases it** (calls `release()`) when it's disabled, then runs `tick()` on the enabled ones each render tick. @@ -123,7 +123,7 @@ We build the moving `phase` from `elapsed()` (milliseconds since the show starte ### Writing pixels by coordinate -`draw::pixel(buf, dims, {x, y, z}, colour)` sets one pixel at a coordinate. It works out the byte offset into the buffer for you and safely ignores a coordinate that's off the grid — so you think in `(x, y, z)`, not array indices. There are matching helpers for common jobs: `draw::fill` (whole buffer one colour), `draw::line`, `draw::fade` (dim the buffer for trails), `draw::blur`. Reach for these before hand-rolling buffer arithmetic; they're in `light/draw.h`. +`draw::pixel(buf, dims, {x, y, z}, color)` sets one pixel at a coordinate. It works out the byte offset into the buffer for you and safely ignores a coordinate that's off the grid — so you think in `(x, y, z)`, not array indices. There are matching helpers for common jobs: `draw::fill` (whole buffer one color), `draw::line`, `draw::fade` (dim the buffer for trails), `draw::blur`. Reach for these before hand-rolling buffer arithmetic; they're in `light/draw.h`. ### Controls are just member variables @@ -236,7 +236,7 @@ For **more than one** buffer — a game-of-life keeps three planes, a starfield ```cpp ScratchBuffer<uint8_t> cells_{*this}; // current generation ScratchBuffer<uint8_t> future_{*this}; // next generation -ScratchBuffer<uint8_t> colors_{*this}; // one colour per cell +ScratchBuffer<uint8_t> colors_{*this}; // one color per cell ``` The element type can be anything — a `uint8_t` heat value, a packed cell plane, or a small struct (`ScratchBuffer<Ball>`); the buffer multiplies by `sizeof(T)` for you. @@ -293,7 +293,7 @@ The framework uses your coordinates to build the mapping that turns an effect's ### Modifiers — bend the picture -A **modifier** sits between the effect and the LEDs and reshapes space: mirror it, mask a region, tile it, rotate it. Instead of drawing colours, a modifier transforms *coordinates*. Its main hook, `modifyLogical`, takes a light's position and folds it into a new one (or rejects it). Several modifiers can stack, and the framework collapses the whole chain into a single fast lookup so the per-frame cost stays tiny. +A **modifier** sits between the effect and the LEDs and reshapes space: mirror it, mask a region, tile it, rotate it. Instead of drawing colors, a modifier transforms *coordinates*. Its main hook, `modifyLogical`, takes a light's position and folds it into a new one (or rejects it). Several modifiers can stack, and the framework collapses the whole chain into a single fast lookup so the per-frame cost stays tiny. Modifiers are the most abstract of the four, so treat them as a step up from effects — but the shape is the same class with the same lifecycle: declare controls, implement your transform hook, let the core run it. @@ -308,8 +308,8 @@ You get all of that "release the pin on disable" behaviour by implementing the s ## A suggested classroom path -1. **Copy the rainbow.** Change the maths in `tick()` — make it pulse, or swap the palette lookup for a fixed colour. Rebuild and add it in the UI. See it move. -2. **Add a control.** Give it a `brightness` slider (`addUint8`) and multiply your colours by it. Watch the UI wire itself up. +1. **Copy the rainbow.** Change the maths in `tick()` — make it pulse, or swap the palette lookup for a fixed color. Rebuild and add it in the UI. See it move. +2. **Add a control.** Give it a `brightness` slider (`addUint8`) and multiply your colors by it. Watch the UI wire itself up. 3. **Add memory.** Write an effect that keeps a per-pixel value between frames (a fading trail, a bouncing dot). Now you need `prepare()` + `release()` — practice matching them. 4. **Make it robust.** Resize the grid live to 1×1, then 0×0. Your effect must not crash. (Reading `width()`/`height()` each frame is what saves you.) 5. **Write a test.** Add `test/unit/light/unit_MyEffect.cpp` with one case: build a small grid, run a frame, `CHECK` the buffer got painted (and doesn't crash on 0×0). Run `ctest`. That's the habit — a module and its test travel together. diff --git a/docs/usecases/home-automation.md b/docs/usecases/home-automation.md index 1c889c71..26301d02 100644 --- a/docs/usecases/home-automation.md +++ b/docs/usecases/home-automation.md @@ -1,10 +1,10 @@ # Home automation -Bring a projectMM device into your smart home — controlled alongside your lights, scenes, and automations — using the [MQTT module](../moonmodules/core/system.md#mqtt) (or, for some hubs, the built-in WLED compatibility). The device exposes on/off, brightness, and colour; a home-automation platform adopts it and drives those from its own app, voice assistant, and automations. +Bring a projectMM device into your smart home — controlled alongside your lights, scenes, and automations — using the [MQTT module](../moonmodules/core/system.md#mqtt) (or, for some hubs, the built-in WLED compatibility). The device exposes on/off, brightness, and color; a home-automation platform adopts it and drives those from its own app, voice assistant, and automations. Integration goes **both directions**, and this page covers both: -- **Your smart home controlling the device** — a hub (Home Assistant, Homebridge, …) adopts the projectMM device and drives its on/off, brightness, and colour from its own app, voice assistant, and automations. +- **Your smart home controlling the device** — a hub (Home Assistant, Homebridge, …) adopts the projectMM device and drives its on/off, brightness, and color from its own app, voice assistant, and automations. - **The device controlling smart-home lights** — projectMM drives **Philips Hue** bulbs as effect pixels, so your existing smart bulbs become part of a show. The device-side recipes below assume the hub and (where relevant) an MQTT broker are already running. If any of that infrastructure isn't there yet, jump to [Set up the infrastructure](#set-up-the-infrastructure) at the end for HA-with-Mosquitto and standalone-Homebridge walkthroughs, then come back. @@ -29,7 +29,7 @@ The control surface these integrations drive — the MQTT controls, topics, and <img src="../assets/core/ha-integration.png" width="600" alt="projectMM devices as 💫-marked lights in a Home Assistant dashboard"> -HA discovers a projectMM device automatically over the WLED path — no broker, nothing to configure — and lists it as a light with colour, palette, and brightness. The 💫 in each name marks it as projectMM among any plain WLED devices. An optional step brings that entity into Apple Home via HA's own HomeKit Bridge — no Homebridge needed. Turn the `haDiscovery` control on only for the MQTT path (broker-only or cross-subnet setups); see [Let HA auto-create the entity](#2-let-ha-auto-create-the-entity) below. +HA discovers a projectMM device automatically over the WLED path — no broker, nothing to configure — and lists it as a light with color, palette, and brightness. The 💫 in each name marks it as projectMM among any plain WLED devices. An optional step brings that entity into Apple Home via HA's own HomeKit Bridge — no Homebridge needed. Turn the `haDiscovery` control on only for the MQTT path (broker-only or cross-subnet setups); see [Let HA auto-create the entity](#2-let-ha-auto-create-the-entity) below. The chain: @@ -53,10 +53,10 @@ The default WLED path needs none of this — skip to step 2. Do step 1 **only if Nothing to configure — HA discovers the device automatically over the WLED path. Two paths exist; the WLED one is the default and the richer of the two, so a device appears in HA **once** out of the box: -- **WLED integration** (the default, no MQTT at all): HA's built-in WLED integration discovers the device over zeroconf and reads the WLED-compatible `/json` API projectMM already serves — **colour, palette, brightness, and diagnostic sensors**, no broker. This is the recommended path. -- **MQTT auto-discovery** (opt-in, uses the Mosquitto add-on): turn `haDiscovery` on for a broker-only or cross-subnet setup where zeroconf can't reach HA. The device then also announces itself on the retained `homeassistant/light/…/config` topic, and HA **auto-creates a light entity** named after the device with **on/off + brightness** (the discovery config declares `brightness` only, so this entity has no colour control); it greys out when the device drops offline. Colour/palette control stays on the separate `hsv/set` topic — not the auto-created entity. +- **WLED integration** (the default, no MQTT at all): HA's built-in WLED integration discovers the device over zeroconf and reads the WLED-compatible `/json` API projectMM already serves — **color, palette, brightness, and diagnostic sensors**, no broker. This is the recommended path. +- **MQTT auto-discovery** (opt-in, uses the Mosquitto add-on): turn `haDiscovery` on for a broker-only or cross-subnet setup where zeroconf can't reach HA. The device then also announces itself on the retained `homeassistant/light/…/config` topic, and HA **auto-creates a light entity** named after the device with **on/off + brightness** (the discovery config declares `brightness` only, so this entity has no color control); it greys out when the device drops offline. Color/palette control stays on the separate `hsv/set` topic — not the auto-created entity. -**Why is MQTT discovery off by default?** Because the WLED path alone already gives HA a richer light — colour, palette, sensors — with zero infrastructure, and running both lists the device **twice** (one WLED entity, one MQTT entity). So WLED is the default and MQTT discovery is opt-in, reserved for what WLED's zeroconf can't do: reach across VLANs/guest nets or a broker-only network, sit next to other MQTT devices (Tasmota, ESPHome, Zigbee2MQTT) under one convention, or push state with retained-across-offline latency. On a flat LAN with only projectMM devices, WLED alone is the whole story; a segmented network or an existing MQTT estate is where turning `haDiscovery` on earns its slot. The full rationale is [ADR-0012](../adr/0012-ha-discovery-wled-default-mqtt-opt-in.md). +**Why is MQTT discovery off by default?** Because the WLED path alone already gives HA a richer light — color, palette, sensors — with zero infrastructure, and running both lists the device **twice** (one WLED entity, one MQTT entity). So WLED is the default and MQTT discovery is opt-in, reserved for what WLED's zeroconf can't do: reach across VLANs/guest nets or a broker-only network, sit next to other MQTT devices (Tasmota, ESPHome, Zigbee2MQTT) under one convention, or push state with retained-across-offline latency. On a flat LAN with only projectMM devices, WLED alone is the whole story; a segmented network or an existing MQTT estate is where turning `haDiscovery` on earns its slot. The full rationale is [ADR-0012](../adr/0012-ha-discovery-wled-default-mqtt-opt-in.md). The MQTT control surface (topics, HSV → palette mapping, retained state) is in [Core › System › MQTT](../moonmodules/core/system.md#mqtt); nothing here restates it. @@ -96,13 +96,13 @@ Restart Homebridge. ### 3. Pair it in Apple Home -Once the broker, device, and Homebridge are all talking (the `mosquitto_sub` window shows the device, and Homebridge lists the accessory), add it to HomeKit: in the **Home** app, **Add Accessory → More options →** the Homebridge bridge, using the PIN Homebridge prints on start (or shown in the Config UI). The device shows up as a light — on/off, brightness, and the colour wheel that steps through palettes. +Once the broker, device, and Homebridge are all talking (the `mosquitto_sub` window shows the device, and Homebridge lists the accessory), add it to HomeKit: in the **Home** app, **Add Accessory → More options →** the Homebridge bridge, using the PIN Homebridge prints on start (or shown in the Config UI). The device shows up as a light — on/off, brightness, and the color wheel that steps through palettes. ## Drive Hue lights -The other direction: instead of a hub controlling the device, the **device controls your Philips Hue bulbs**, treating each colour bulb as a pixel of an effect. Your existing smart lights join the show — an effect's colours glide across them alongside (or instead of) an LED strip. This is a projectMM **output driver**, not a hub integration, so there's no broker and no Homebridge — the device talks straight to the Hue bridge over its LAN HTTP API. +The other direction: instead of a hub controlling the device, the **device controls your Philips Hue bulbs**, treating each color bulb as a pixel of an effect. Your existing smart lights join the show — an effect's colors glide across them alongside (or instead of) an LED strip. This is a projectMM **output driver**, not a hub integration, so there's no broker and no Homebridge — the device talks straight to the Hue bridge over its LAN HTTP API. -Because Hue is a rate-limited HTTP hub (~10 commands/s), this is **smooth ambient colour**, not fast strobing — the driver paces itself to the bridge and lets it fade between colours. Full behaviour, controls, and the wire contract are in the driver reference: [Drivers › Hue](../moonmodules/light/drivers.md#hue). +Because Hue is a rate-limited HTTP hub (~10 commands/s), this is **smooth ambient color**, not fast strobing — the driver paces itself to the bridge and lets it fade between colors. Full behaviour, controls, and the wire contract are in the driver reference: [Drivers › Hue](../moonmodules/light/drivers.md#hue). **Recommended layout:** set up a **one-dimensional grid — width 1, height = the number of Hue lights** you want to control. Each pixel of that column maps to one bulb (the driver assigns window pixels to bulbs in order), so a 1×N grid gives you exactly N discrete lights with no wasted pixels, and 1D effects (rainbow, chase, …) read naturally across the bulbs. Sizing the grid to your bulb count keeps the effect and the driver in step. @@ -110,10 +110,10 @@ To set it up: 1. **Add a Hue driver.** In the device's web UI pipeline (**Layers → a Layer → its Drivers**), add a **Hue** driver. Enter your bridge's IP in `bridgeIp` (find it in the Hue app, or at [discovery.meethue.com](https://discovery.meethue.com)). 2. **Pair with the bridge.** Press the physical **link button** on the Hue bridge, then click the driver's **`pair`** button within ~30 seconds. The device claims an app key (stored on the driver as `appKey`) — a one-time step; the status line reports `paired, N lights`. -3. **Pick what it drives.** The driver lists the bridge's colour-capable, reachable bulbs and its rooms; use the `room` / `light` controls to aim the effect at all bulbs, one room, or a single light. Each selected bulb becomes one pixel of the driver's window. -4. **Run an effect.** Any effect on the layer now drives the bulbs — the global brightness slider and colour-order correction apply to them just like a physical strip (brightness 0 turns a bulb off). +3. **Pick what it drives.** The driver lists the bridge's color-capable, reachable bulbs and its rooms; use the `room` / `light` controls to aim the effect at all bulbs, one room, or a single light. Each selected bulb becomes one pixel of the driver's window. +4. **Run an effect.** Any effect on the layer now drives the bulbs — the global brightness slider and color-order correction apply to them just like a physical strip (brightness 0 turns a bulb off). -*Note:* a bulb is only driven if it's an "Extended color light" and currently reachable — a white-only bulb, a plug, or a powered-off light is skipped. For true real-time (fast) Hue, the [Hue Entertainment API](https://developers.meethue.com/develop/hue-entertainment/) (DTLS streaming) is a separate future path; today's driver targets the standard API's ambient-colour sweet spot. +*Note:* a bulb is only driven if it's an "Extended color light" and currently reachable — a white-only bulb, a plug, or a powered-off light is skipped. For true real-time (fast) Hue, the [Hue Entertainment API](https://developers.meethue.com/develop/hue-entertainment/) (DTLS streaming) is a separate future path; today's driver targets the standard API's ambient-color sweet spot. ## Other platforms @@ -263,4 +263,4 @@ Now jump back to [Adopt in Homebridge](#adopt-in-homebridge). A **404 on `/json`** means an old firmware that predates the WLED-compatibility shim → reflash. A **404 on `/presets.json`** with `/json` working means the presets route is missing → HA's coordinator retry-storms trying to fetch presets and the entity ends up stuck on "unavailable" even though the light responds; also a firmware reflash. A `/json` response that parses but is missing `info.fs`, `state.nl`, `state.udpn`, or `state.lor` fails python-wled's dataclass parse and HA reports HTTP-500 on `light.turn_on` — again a firmware version older than the current WLED shim. - **Homebridge shows "No Response"** — the accessory's topics don't match the device's MAC suffix, or the `url` points at the wrong broker. Confirm the suffix with `mosquitto_sub -t 'projectMM/#'` and that the same broker appears in both the device's `broker` control and the accessory `url`. -- **HomeKit colour wheel doesn't match a specific colour** — expected: HomeKit sends a full-precision hue, and the device snaps it to the *nearest* built-in palette (there's no arbitrary-colour mode). Same behaviour whether the bridge is HA's HomeKit Bridge or standalone Homebridge. See the palette note in the [MQTT reference](../moonmodules/core/system.md#mqtt). +- **HomeKit color wheel doesn't match a specific color** — expected: HomeKit sends a full-precision hue, and the device snaps it to the *nearest* built-in palette (there's no arbitrary-color mode). Same behaviour whether the bridge is HA's HomeKit Bridge or standalone Homebridge. See the palette note in the [MQTT reference](../moonmodules/core/system.md#mqtt). diff --git a/docs/usecases/led-signal-integrity.md b/docs/usecases/led-signal-integrity.md index 0c9db88e..a4e7dce0 100644 --- a/docs/usecases/led-signal-integrity.md +++ b/docs/usecases/led-signal-integrity.md @@ -1,10 +1,10 @@ # LED signal integrity — flicker on LEDs that should be off -Random wrong colours on LEDs the effect leaves black — most often a few stray pixels flickering — is, on a 3.3 V ESP32 driving WS2812 **directly**, almost always a **data-line signal-integrity** problem, not a firmware bug. WS2812 wants a logic-high near 0.7 × VDD (≈ 3.5 V on a 5 V strip), but the ESP32 drives only 3.3 V, so individual bits sit at the margin and noise tips them. +Random wrong colors on LEDs the effect leaves black — most often a few stray pixels flickering — is, on a 3.3 V ESP32 driving WS2812 **directly**, almost always a **data-line signal-integrity** problem, not a firmware bug. WS2812 wants a logic-high near 0.7 × VDD (≈ 3.5 V on a 5 V strip), but the ESP32 drives only 3.3 V, so individual bits sit at the margin and noise tips them. Confirm the firmware is innocent **before** reaching for the soldering iron. These checks are the bench diagnosis path (recorded in [lessons.md](../history/lessons.md)): -1. **Is the data clean?** The preview/source buffer is the logical RGB the effect produced — if it shows no stray colour, the effect is innocent (the corruption is downstream of the buffer). +1. **Is the data clean?** The preview/source buffer is the logical RGB the effect produced — if it shows no stray color, the effect is innocent (the corruption is downstream of the buffer). 2. **Is the firmware/peripheral clean?** Run the [`loopbackFrame` self-test](../moonmodules/light/drivers.md#led-drivers) through a short jumper on the data pin. A `PASS` means the RMT encode + transmit emit bit-perfect WS2812 — the GPIO is fine. 3. **Is it WiFi RF?** Lower `Network.txPowerSetting` from 20 dBm down toward 2 and watch. If the flicker shrinks with TX power, it's radio coupling into the data wire (mitigate with the level shifter below). If it's **unchanged across the whole sweep, it is not the radio** — it's the physical data path. diff --git a/esp32/sdkconfig.defaults b/esp32/sdkconfig.defaults index cb2fabe4..8e987fc2 100644 --- a/esp32/sdkconfig.defaults +++ b/esp32/sdkconfig.defaults @@ -7,8 +7,10 @@ # KB free RAM, so the extra 4 KB is cheap, and the S3/P4 have even more. CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288 -# CPU at the chip's rated maximum (IDF defaults to 160 MHz) — the render loop and the ring ISR's -# encode deadline are compute-bound, and the P4 ignores this symbol (its own 360 MHz default applies). +# CPU at 240 MHz (IDF defaults to 160) — the render loop and the ring ISR's encode deadline are +# compute-bound. This is the rated maximum for the classic/S3 chips; a target whose silicon goes +# higher overrides it in its own fragment (the S31 at 320), and the P4 ignores this symbol +# entirely (its own 360 MHz default applies). CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240=y # GDMA control functions in IRAM: the streaming ring's EOF ISR is registered cache-safe (it keeps diff --git a/esp32/sdkconfig.defaults.esp32s31 b/esp32/sdkconfig.defaults.esp32s31 index f79573a9..9f253300 100644 --- a/esp32/sdkconfig.defaults.esp32s31 +++ b/esp32/sdkconfig.defaults.esp32s31 @@ -18,6 +18,14 @@ CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions/ota_16mb.csv" # Flash size override (the CoreBoard ships 16 MB). CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y +# CPU at the S31's rated maximum, 320 MHz. The shared sdkconfig.defaults sets 240 (the +# rated max for the chips that came before), so this target has to override it or it +# silently runs a third slower than the silicon allows — the render loop and the ring +# ISR's encode deadline are compute-bound, so that is a third of the headroom gone. +# Same reasoning as the base file's 240 over IDF's 160 default; the P4 needs no entry +# because it ignores the symbol (its own 360 MHz default applies). +CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_320=y + # PSRAM — the S31 has a DMA-capable PSRAM with a dedicated LDO (SOC_SPIRAM_SUPPORTED, # SOC_PSRAM_HAS_DEDICATED_LDO). Enabled for the large buffers later rounds allocate; # not relied on at boot. diff --git a/mkdocs.yml b/mkdocs.yml index 1ae6a787..fcc1fd4f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -142,11 +142,18 @@ nav: - Unit tests: tests/unit-tests.md - Scenario tests: tests/scenario-tests.md - Developer reference: + # The repo's rulebook (CLAUDE.md at the root, embedded — see the page's comment). + # First in this section because it is the "how we work here" a new contributor + # reads before the technical references below it. + - Principles & process: principles-and-process.md - Architecture: architecture.md - Coding standards: coding-standards.md - Building: building.md - Testing strategy: testing.md - Performance: performance.md + # Generated by moondeck/check/repo_health.py on every KPI-gate run — the size/LOC/ + # docs ratchet. Next to Performance because both are measured state, not prose. + - Repo health: metrics/repo-health.md - Migrating (breaking changes): MIGRATING.md - Hardware reference: - GPIO usage per MCU: reference/gpio-usage.md diff --git a/moondeck/MoonDeck.md b/moondeck/MoonDeck.md index 645d05f5..513b7213 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). - **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). - **Destructive-action confirm** — scripts flagged `destructive: true` (e.g. Erase Flash) pop a native confirm dialog before running. - **Tab persistence** — selected tab survives page refresh. @@ -103,6 +104,46 @@ uv run moondeck/check/check_platform_boundary.py Scans all source files outside `src/platform/` for forbidden includes and platform `#ifdef`s. +### check_hotpath + +Flag allocation or blocking written directly in a render-path method. + +```bash +uv run moondeck/check/check_hotpath.py # report findings +uv run moondeck/check/check_hotpath.py --list # list the methods it scans +``` + +Reads the body of every `tick()` / `tick20ms()` / `tick1s()` under `src/` and reports the banned constructs it can see: `new` / `malloc` / `push_back` / `std::string` / `make_unique` / `make_shared` (allocation) and `delay` / `sleep` / `mutex.lock()` (blocking). A **lint, not a proof** — it cannot see what a callee allocates, so a clean run means "nothing visible in the render path's own source". A justified exception is marked `// hot-path-ok: <reason>` at the line, so the reason lives at the site. + +### check_esp32_built + +Check that a firmware binary exists and is newer than every source that feeds it. + +```bash +uv run moondeck/check/check_esp32_built.py --firmware esp32s3-n16r8 +``` + +The cheap stand-in for a full `idf.py build` in the commit and merge gates. Freshness is measured against the **sources**, not the clock: a wall-clock rule ("built in the last hour") passes a binary that predates an edit made twenty minutes ago, which is the stale-artifact trap that sends debugging at the wrong image. On failure it names the newer file and prints the rebuild command. `--max-age-hours N` adds an optional age rule on top; the default (0) disables it. + +### event_precommit / event_premerge / event_prerelease + +Run the gate list for one lifecycle event ([CLAUDE.md § The Process](../CLAUDE.md#the-process)). + +```bash +uv run moondeck/event/precommit.py # commit event +uv run moondeck/event/precommit.py --build-esp32 # …compiling the firmware for real +uv run moondeck/event/precommit.py --firmware esp32 # pick the ESP32 variant +uv run moondeck/event/premerge.py # merge event (branch diff vs main) +uv run moondeck/event/prerelease.py # release event (diff vs previous tag) +``` + +Each gate carries an objective trigger read from the changed-file set, so a docs-only change runs the spec check and skips the rest, while a `src/` change runs the full list. Every gate reports **PASS** (ran, succeeded), **FAIL** (ran, failed), **SKIP** (trigger did not match) or **MANUAL** (a human decision — hardware, review, release criteria — listed, never auto-failed). Gates do not stop at the first failure: one pass gives the whole picture, and the run ends with a `DONE` line so a long run's finish is unambiguous. The scripts are **product-owner initiated** and never commit, merge, or tag. + +**The commit list is built to stay under ~10 seconds**, because a gate list nobody runs protects nothing. Two steps that would otherwise dominate it are deliberately cheap: + +- **ESP32 is a freshness check, not a compile** — [check_esp32_built](#check_esp32_built) instead of a cold `idf.py build`. `--build-esp32` compiles for real; `prerelease.py` always does, since that is the event where the binary ships; CI builds every variant on every PR regardless. +- **KPI skips the live serial capture** — the gate passes `--no-live-capture` (see [collect_kpi](#collect_kpi)), so it needs no bench board and costs seconds. + ### check_devices Validate the installer device-model catalog (`web-installer/deviceModels.json`). @@ -128,10 +169,197 @@ Regenerates the firmware list from `build_esp32.py`'s `FIRMWARES` dict and fails Collect the per-target KPI line (tick/FPS, memory, sizes) for the commit message. ```bash -uv run moondeck/check/collect_kpi.py +uv run moondeck/check/collect_kpi.py # full interactive report +uv run moondeck/check/collect_kpi.py --commit # the commit-message form +uv run moondeck/check/collect_kpi.py --commit --no-live-capture # skip the serial read +``` + +Captures a live tick from a connected ESP32 (and the desktop scenario ticks) plus source/test line counts, emitting the `tick:Xus(FPS:Y)` one-liner the commit message records. + +The ESP32 half reads `esp32/monitor.log`, and refreshes it by opening the serial port for 15 s when that log is older than 5 minutes — accurate, but ~80 s and only possible with a bench board attached. `--no-live-capture` skips that refresh and uses whatever log exists (a few seconds, no board needed); the ESP32 tick line is then absent rather than stale when no recent log is around. The gate lists pass the flag so their cost stays predictable; omit it when composing a commit message, where the fresh reading is the point. + +In `--commit` mode it also writes the repo-health snapshot (below), reusing the tick/FPS it just measured. + +### repo_health + +Measure the repo's current state into `repo-health.json` — flash per firmware variant, tick/FPS per target, lines of code by area, comment density, test counts, docs inventory. + +```bash +uv run moondeck/check/repo_health.py # measure + print the delta, write nothing +uv run moondeck/check/repo_health.py --write # rewrite repo-health.json +``` + +**One small file, current state only — the trend is its git history** (`git log -p repo-health.json`), so the file never grows. The KPI gate rewrites it on every `--commit` run and prints the delta first, so growth is visible while you work and again in the commit's diff. A **soft ratchet**: nothing here fails a build. The numbers count things; they cannot tell a valuable comment from a restating one, so the judgment stays human. + +Two properties worth knowing. Measurements read **tracked files only** (`git ls-files`), so a stray build artifact or scratch file can't move a number. And anything this run could not measure — a firmware variant that wasn't built, a tick with no board attached — **carries its previous value forward** rather than disappearing, so a docs-only commit doesn't blank the flash sizes and make the next diff unreadable. + +### Tools group + +Static-analysis tools, run **manually**: they take minutes rather than seconds, so they are not +in the commit/merge gate lists yet. + +### check_module + +Every static-analysis tool, on ONE module — the repo-wide reports turned around. + +```bash +uv run moondeck/check/check_module.py --module Control +uv run moondeck/check/check_module.py --module Layer --skip clang-tidy +``` + +The other tool cards sweep the whole repo, which is the wrong shape when you are working on one +file and want to know what the tools say about *it*. This runs clang-tidy, clang-query and +lizard against one module and prints them under one heading. It adds no analysis of its own — +it invokes the same scripts with `--module`, so this and the repo-wide reports can never +disagree about a finding. Each tool also accepts `--module` on its own if you want just one. + +**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 +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 +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. + +### check_clang_tidy + +Run clang-tidy over the whole tree and write a Markdown report. + +```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 ``` -Captures a live tick from a connected ESP32 (and the desktop scenario ticks) plus source/test line counts, emitting the `tick:Xus(FPS:Y)` one-liner the commit gate records. +Configured by [`.clang-tidy`](../.clang-tidy) at the repo root — the same file clangd reads, so +this report and your editor agree. Takes ~2-3 minutes; the baseline is **zero findings**, so +anything it prints is new. + +The findings print straight to the log — a per-check summary, the worst files, then every +finding grouped by file. No report file to open: a run this slow should answer on the spot, +and the old `build/clang-tidy-report.md` was gitignored anyway, so it existed only to be read +once. + +**Verify a zero before believing it.** A tool that analysed nothing also reports zero, and this +one has four documented ways to fail silently (the reasons are in the script). The control is a +check you know must fire: `--check readability-magic-numbers` returns thousands. If that returns +zero too, the run is broken, not the tree. The script also refuses to report when more than ten +files fail to compile, because "most files errored" is a broken run and not a result. + +### check_clang_query + +Our own AST rules — the checks we invent, that no off-the-shelf tool reports. + +```bash +uv run moondeck/check/check_clang_query.py # every rule +uv run moondeck/check/check_clang_query.py --rule=arrays # one rule +uv run moondeck/check/check_clang_query.py --min-bytes=256 +``` + +One script holding a **growing list** of rules, not one script per rule: a new rule is a matcher +plus a few lines of Python, so it costs a list entry rather than another card and another help +page. clang-query rather than a compiled clang-tidy plugin — matchers are plain text, there is no +plugin to build and no LLVM ABI to track, and clangd cannot load a compiled plugin anyway. + +**Rule `arrays` — fixed arrays that cost RAM.** A fixed array is a fixed size, and the +architecture sizes buffers at runtime from available memory. Reported worst-first, split by where +the RAM lives, because the fix differs: + +| Where | Cost | Why it matters | +|---|---|---| +| `local` | Stack | A 2 KB local on a 4 KB task stack is an overflow waiting for the wrong call depth. | +| `member` | Per instance | Multiplied by how many instances exist — 200 bytes × 90 modules is 18 KB. | + +`constexpr` and static-storage arrays are **excluded**: they live in flash and cost no RAM. +Including them roughly triples the list with entries nobody should act on. `MoonModule::name_[16]` is the cautionary case: its +comment records that it was *shrunk* from `char[24]` to save 8 bytes per module, so reporting it +would flag a past win as a problem. + +Element sizes come from a table of the types we actually use; anything else (a struct, a class) +falls back to 4 bytes. The number is an order-of-magnitude guide, not an ABI-exact figure. + +**No size threshold.** Every RAM-costing array is reported, however small — a cutoff hides +things for the wrong reason (`bool birthNumbers_[9]` is 9 bytes and was invisible under the old +10-byte default), and it bought little anyway: 362 findings with no threshold against 290 at +>10 bytes. Volume is capped by `--max-rows` (default 60, worst-first) instead, which truncates +the longest lists rather than silently dropping the smallest entries — and the cut is always +announced, because a table that quietly stops reads as "that is all there is". `--max-rows 0` +prints everything, and `--min-bytes N` restores a size cutoff — both CLI-only, since the card is +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. + +**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 — +`GameOfLifeEffect` has three (`cells_`, `future_`, `colors_`) and the `heap` rule reports zero +for it, because the real `platform::alloc` lives once inside `ScratchBuffer.cpp`. This rule +closes that blind spot: 18 members across 13 files. No size column — a ScratchBuffer is sized at +runtime from the light count, which is the entire point of it. + +**Rule `heap` — every allocation site in `src/`.** `new` / `delete` / the `malloc` family +(including `heap_caps_*` and `ps_malloc`), in **two tables — ALLOCATE and FREE**. They answer +different questions: the acquire list is where RAM comes from and what the hot path must not do; +the release list is what pairs with it. Reading them side by side is how an unpaired allocation +shows up — and the split is what reveals, for example, that `HttpServerModule` frees five times +and allocates nothing, or that only **8 places in the whole codebase acquire memory**. + +Each row carries what the site is, what it touches, and **the enclosing function** — the column +that turns a location into a lead ("this file allocates" is weak, "`handleConnection` allocates" +says where to look). `realloc` counts as acquiring, since it can move and grow. Not violations: +the driver layer allocates deliberately, but the hot path must not. + +Member methods named `free()` are excluded. We have three (`MoonLive`, `Buffer`, `MappingLUT`), +and matching on the name alone counted every call to them as heap deallocation — 15 false +positives pointing at the wrong lines. + +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. + +### check_lizard + +Complexity gate: fail on **new** over-complex functions, not the ones already there. + +```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 +uv run moondeck/check/check_lizard.py --baseline # rewrite whitelizard.txt from today +``` + +Results print as one table, sorted worst-first, so the top row is the next thing worth +simplifying. The five numbers are the same ones lizard's own summary reports: + +| Column | Meaning | Gated | +|---|---|---| +| **CCN** | Cyclomatic complexity — independent paths through the function: 1, plus one for every branch point (`if`, `for`, `while`, `case`, and each short-circuit `&&` / logical-or operator). The count of things that must hold at once to reason about it, and the number of tests needed to cover it. | **Yes**, > 10 | +| **NLOC** | Non-comment lines of code — the body's real size, blank lines and comments excluded. | **Yes**, > 60 | +| **TOKEN** | Total tokens (identifiers, operators, literals). Density rather than length: a high TOKEN against a modest NLOC means long, packed expressions. | No | +| **PARAM** | Parameter count. A long list usually means the function does several jobs, or wants a struct. | No | +| **LINES** | Raw line span, first to last — **includes** comments and blanks, so `LINES` minus `NLOC` is roughly how much of the function is documentation. | No | + +A `*` next to CCN or NLOC marks which threshold tripped. It matters because the two point at +different fixes: `HttpServerModule::handleConnection` is `93* 178*` (both — split it), while +`json::parseString` is `40* 47` — branchy but short, so it wants a lookup table rather than a +split. TOKEN, PARAM and LINES are context for *why* a function is heavy; nothing gates on them. + +A raw run reports 162 functions over threshold (CCN > 10 or NLOC > 60), and a metric that can +never reach zero is a poor gate — people stop reading it. So [`docs/metrics/whitelizard.txt`](../docs/metrics/whitelizard.txt) +freezes today's set and the check fails only on something new. The baseline is lizard's own +`--whitelist` format, matched on **file + function name** rather than line numbers, so it +survives edits above a function. + +**The list only shrinks.** Simplify a function, delete its line; the check reports baselined +entries that no longer violate so they don't linger. Adding a line means admitting a new +violation, which is the thing this exists to prevent. + +Lizard owns the complexity number (`collect_kpi.py` reports the same 162 for the repo-health +trend); clang-tidy's `readability-function-*` checks stay off so one rule has one owner. ### scenario_pipeline @@ -241,7 +469,7 @@ For a full description of each scenario, see the [scenario inventory](/api/docs/ ### run_network_live -End-to-end lights-over-UDP matrix test across every online board in moondeck.json's active network — the live proof for [NetworkReceiveEffect](../docs/moonmodules/light/effects.md#networkreceive) and [NetworkSendDriver](../docs/moonmodules/light/drivers.md#networksend). Each round one device is the sender and every other device listens: the desktop seeds the sender **three times — once per protocol (ArtNet, E1.31, DDP), each with its own colour** — asserting the sender's `/ws` preview stream shows each one, then points the sender's own NetworkSendDriver at each listener with the protocol control cycled round-robin and asserts the listener's preview shows the sender's corrected colour (brightness + channel order replicated host-side). With one device online only the desktop→device sweep runs. +End-to-end lights-over-UDP matrix test across every online board in moondeck.json's active network — the live proof for [NetworkReceiveEffect](../docs/moonmodules/light/effects.md#networkreceive) and [NetworkSendDriver](../docs/moonmodules/light/drivers.md#networksend). Each round one device is the sender and every other device listens: the desktop seeds the sender **three times — once per protocol (ArtNet, E1.31, DDP), each with its own color** — asserting the sender's `/ws` preview stream shows each one, then points the sender's own NetworkSendDriver at each listener with the protocol control cycled round-robin and asserts the listener's preview shows the sender's corrected color (brightness + channel order replicated host-side). With one device online only the desktop→device sweep runs. ```bash uv run moondeck/scenario/run_network_live.py # full matrix over all online devices @@ -253,7 +481,7 @@ Everything it mutates (grid size → 16×16 for the run, NetworkSend `ip`/`proto ### run_network_roundtrip -Minimal **desktop→device→desktop latency probe** across **all three protocols**: per device, the desktop sends one solid-colour frame over ArtNet, then E1.31, then DDP, each time timing how long until that colour appears in the device's `/ws` preview stream (desktop → NetworkReceiveEffect → PreviewDriver → desktop). The receiver autodetects each protocol on its own port, so there's no device reconfig between them. Reports min / median / max over N repeats per protocol and a per-device median-per-protocol comparison line — the spread is the signal for the latency / hiccup symptom, the protocol comparison shows which transport is fastest on a given board, and running across boards makes the per-chip difference visible (a classic ESP32 measures slower than an S3). Runs against **every device checked in the Live tab** (the same `selected` set the matrix test uses); unreachable checked devices are warned and skipped. The measured time includes the PreviewDriver's own fps quantisation (≈42 ms at the 24 fps default), so it's "state visible within" latency, not wire latency; raise the device's Preview fps to tighten it. Deliberately minimal — per-frame sequence matching, the device→device chain, and jitter/drop histograms are left as later extensions. +Minimal **desktop→device→desktop latency probe** across **all three protocols**: per device, the desktop sends one solid-color frame over ArtNet, then E1.31, then DDP, each time timing how long until that color appears in the device's `/ws` preview stream (desktop → NetworkReceiveEffect → PreviewDriver → desktop). The receiver autodetects each protocol on its own port, so there's no device reconfig between them. Reports min / median / max over N repeats per protocol and a per-device median-per-protocol comparison line — the spread is the signal for the latency / hiccup symptom, the protocol comparison shows which transport is fastest on a given board, and running across boards makes the per-chip difference visible (a classic ESP32 measures slower than an S3). Runs against **every device checked in the Live tab** (the same `selected` set the matrix test uses); unreachable checked devices are warned and skipped. The measured time includes the PreviewDriver's own fps quantisation (≈42 ms at the 24 fps default), so it's "state visible within" latency, not wire latency; raise the device's Preview fps to tighten it. Deliberately minimal — per-frame sequence matching, the device→device chain, and jitter/drop histograms are left as later extensions. ```bash uv run moondeck/scenario/run_network_roundtrip.py # every checked device, 10 probes each @@ -264,7 +492,7 @@ Captures and restores each device's grid and removes the temporary NetworkReceiv ### preview_health -Browser-faithful **3D-preview stream health probe** — measures the device's `/ws` preview the way a real browser tab experiences it, so the numbers match what a person watching the [PreviewDriver](../docs/moonmodules/light/drivers.md#preview) preview sees. A plain one-shot WebSocket reader gives up the moment the device closes the socket, so it reports stalls a browser never shows (the browser reconnects) and misses the brief blips a browser does show; this probe replicates the real client in [app.js](../src/ui/app.js)'s `connectWs` — reads the binary frames, sends a `"ping"` text frame every 25 s, and **auto-reconnects on close with 500 ms→5 s backoff** — so a momentary device-side close registers as a short blip, not a frozen preview. Pure WebSocket client: **no device-side changes**, it observes the unmodified stream the device already broadcasts. Reports, per device: colour frames + sustained fps, reconnects (each a visible blip), `maxgap` (the longest stretch with no colour frame — the real "did it freeze?" number), and a `SMOOTH` / `CHOPPY` / `DEAD` verdict. Diagnostic, not a gate — it always exits `0`; read the verdict. Runs against **every device checked in the Live tab** (or an explicit `--host`); with no host it sweeps every online device on the active network. +Browser-faithful **3D-preview stream health probe** — measures the device's `/ws` preview the way a real browser tab experiences it, so the numbers match what a person watching the [PreviewDriver](../docs/moonmodules/light/drivers.md#preview) preview sees. A plain one-shot WebSocket reader gives up the moment the device closes the socket, so it reports stalls a browser never shows (the browser reconnects) and misses the brief blips a browser does show; this probe replicates the real client in [app.js](../src/ui/app.js)'s `connectWs` — reads the binary frames, sends a `"ping"` text frame every 25 s, and **auto-reconnects on close with 500 ms→5 s backoff** — so a momentary device-side close registers as a short blip, not a frozen preview. Pure WebSocket client: **no device-side changes**, it observes the unmodified stream the device already broadcasts. Reports, per device: color frames + sustained fps, reconnects (each a visible blip), `maxgap` (the longest stretch with no color frame — the real "did it freeze?" number), and a `SMOOTH` / `CHOPPY` / `DEAD` verdict. Diagnostic, not a gate — it always exits `0`; read the verdict. Runs against **every device checked in the Live tab** (or an explicit `--host`); with no host it sweeps every online device on the active network. ```bash uv run moondeck/diag/preview_health.py # every online device, 30s each diff --git a/moondeck/build/generate_effect_sweep.py b/moondeck/build/generate_effect_sweep.py new file mode 100644 index 00000000..be48902f --- /dev/null +++ b/moondeck/build/generate_effect_sweep.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Generate the effect list the grid-size sweep test drives. + +The sweep (`test/unit/light/unit_Effects_gridsweep.cpp`) must cover EVERY effect, +including ones that do not exist yet — a hand-written list would silently miss the next +effect someone adds, which is exactly the gap the sweep exists to close. The test binary +does not link `main.cpp` (where the factory registrations live), so it cannot ask the +factory either. + +So the list is derived from the filesystem: every `src/light/effects/*Effect.h` becomes an +include + a registration line in a generated header the test includes. Add an effect file, +and the sweep covers it on the next build with no test edit. + +Usage: + uv run moondeck/build/generate_effect_sweep.py <output-header> +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +EFFECTS = ROOT / "src" / "light" / "effects" + +# Headers that are not a concrete, default-constructible effect the sweep can instantiate. +# EffectBase the abstract base itself +# MoonLiveEffect runs a user-supplied script; with no script loaded it is a no-op, +# and its JIT path is covered by its own tests +_SKIP = {"EffectBase", "MoonLiveEffect"} + + +def effect_stems(): + """Every concrete effect header stem, sorted for a stable generated file.""" + return sorted(p.stem for p in EFFECTS.glob("*Effect.h") if p.stem not in _SKIP) + + +def render(stems): + includes = "\n".join(f'#include "light/effects/{s}.h"' for s in stems) + cases = "\n".join( + f' fn("{s}", [] {{ return static_cast<MoonModule*>(new {s}()); }});' + for s in stems) + return f"""// GENERATED by moondeck/build/generate_effect_sweep.py — do not edit. +// +// Every concrete effect header under src/light/effects/, as a name + constructor pair. +// Regenerated on every build, so an effect added to the tree is swept without touching +// the test. +// +// Deliberately NOT ModuleFactory registrations. The factory is a process-wide registry +// shared by every test in this binary, and `registerType` APPENDS unconditionally — the +// same name registered by several test files takes several slots (`Layer` and +// `RainbowEffect` are each registered many times over). Adding one entry per effect on +// top of that pushes `count_` (a uint8_t) toward its 255 ceiling, where `grow()` returns +// false and registration silently fails: the bool nobody checks. Constructing directly +// keeps the sweep independent of whatever else the binary has already registered, and of +// the order the tests happen to run in. +#pragma once + +#include "core/MoonModule.h" +{includes} + +namespace mm {{ + +// Call `fn(name, make)` once per effect. `make` returns a heap effect the caller owns. +template <typename F> +inline void forEachEffect(F fn) {{ +{cases} +}} + +}} // namespace mm +""" + + +def main(): + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + + stems = effect_stems() + if not stems: + print(f"No effect headers found under {EFFECTS} — refusing to generate an empty " + f"sweep (it would pass without testing anything).", file=sys.stderr) + sys.exit(1) + + out = Path(sys.argv[1]) + out.parent.mkdir(parents=True, exist_ok=True) + text = render(stems) + # Only rewrite on change, so an unchanged effect set does not re-trigger the build. + if not out.exists() or out.read_text(encoding="utf-8") != text: + out.write_text(text, encoding="utf-8") + print(f"Effect sweep list: {len(stems)} effects -> {out}") + + +if __name__ == "__main__": + main() diff --git a/moondeck/check/check_clang_query.py b/moondeck/check/check_clang_query.py new file mode 100644 index 00000000..2bc3f715 --- /dev/null +++ b/moondeck/check/check_clang_query.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +"""Bespoke AST rules — the checks we invent, that no off-the-shelf tool reports. + +The rest of the stack enforces rules someone else wrote; this is where OUR rules live. Each +rule is an AST matcher plus a Python predicate, and this file holds a LIST of them: rule two +costs a list entry, not a new script, a new MoonDeck card and a new help page. That framing is +the point — the first rule alone would not earn a script. + +clang-query rather than a compiled clang-tidy plugin: matchers are plain text, minutes to +write, no plugin to build, no LLVM ABI to track — and clangd will not load a compiled plugin, +so a custom check written that way could never appear in the editor anyway. + +## Rules + +1. **RAM-costing arrays.** A fixed array is a fixed size, and the architecture says sizes are + determined at runtime from available memory. This reports the arrays that actually cost RAM, + split by where that RAM lives, because the fix differs per category: + locals — on the stack; a big one risks overflow on a 4 KB task stack + members — per INSTANCE, so the cost multiplies by how many exist + `constexpr` and static-storage arrays are excluded: they live in flash and cost no RAM, so + reporting them would flag lookup tables as memory bloat. (Measured: including them roughly + triples the list with entries nobody should act on — `MoonModule::name_[16]` was itself + *shrunk* from [24] to save 8 bytes per module, and would have been reported as a problem.) + +2. **Heap allocation sites.** Every `new` / `delete` / `malloc`-family call in our own code. + Not a violation — the driver layer allocates deliberately — but the hot path must not, and + the count is the thing worth trending. + +Usage: + uv run moondeck/check/check_clang_query.py # every rule + uv run moondeck/check/check_clang_query.py --rule arrays + uv run moondeck/check/check_clang_query.py --min-bytes 64 +""" + +import argparse +import collections +import json +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import check_clang_tidy # noqa: E402 — reuse its build-dir and toolchain logic, one owner + +# Bytes per element for the types we actually declare arrays of. Anything unlisted (a struct, +# an enum, a class) falls back to 4: this report is about ORDER OF MAGNITUDE, and guessing a +# struct's exact layout would need the full ABI for no gain in what the reader does next. +ELEM_BYTES = { + "char": 1, "signed char": 1, "unsigned char": 1, "bool": 1, "int8_t": 1, "uint8_t": 1, + "short": 2, "unsigned short": 2, "int16_t": 2, "uint16_t": 2, + "int": 4, "unsigned int": 4, "int32_t": 4, "uint32_t": 4, "float": 4, + "long": 8, "unsigned long": 8, "size_t": 8, "double": 8, "int64_t": 8, "uint64_t": 8, +} +DEFAULT_ELEM_BYTES = 4 + +# Default array threshold: ZERO — report every RAM-costing array, however small. A size cutoff +# hides things for the wrong reason (`bool birthNumbers_[9]` is 9 bytes and was invisible at the +# old default of 10), and the interesting question is "what does this module allocate", not "is +# any single one big". Measured: no threshold is 362 findings vs 290 at >10 bytes, so the cutoff +# was buying almost nothing. Volume is controlled by MAX_ROWS instead, which truncates the +# LONGEST lists rather than silently dropping the smallest entries. +DEFAULT_MIN_BYTES = 0 + +# How many rows a table prints before truncating, worst-first. Deep enough that the RAM-costing +# array table reaches the small entries that matter on a 180 KB heap, short enough that a +# repo-wide sweep (362 arrays) stays readable. Truncation is always announced — a silently +# short list would read as a clean result. +DEFAULT_MAX_ROWS = 60 + +# `VarDecl`/`FieldDecl <path:line:col...> ... name 'type[N]'`. clang-query's `dump` output is +# an AST dump, so this reads its stable prefix rather than trying to parse the whole tree. +_DECL = re.compile( + r"(?P<kind>VarDecl|FieldDecl) 0x\w+ <(?P<path>[^>]*?):(?P<line>\d+):\d+[^>]*>" + r"[^\n]*?(?P<name>\w+) '(?P<type>[\w:\s]+?)\[(?P<count>\d+)\]'") + +# An allocation node in the AST dump: `CXXNewExpr 0x... <path:line:col, ...>`. +_HEAP_NODE = re.compile( + r"(?P<kind>CXXNewExpr|CXXDeleteExpr|CallExpr) 0x\w+ <(?P<path>[^>]*?):(?P<line>\d+):" + r"(?P<col>\d+)") + +# The callee inside a CallExpr's child DeclRefExpr: `... Function 0x... 'free' '...'`. +_CALLEE = re.compile(r"Function 0x\w+ '(?P<name>\w+)'") + +# What is being allocated or freed, from the operand leaf under the node. Two forms: +# MemberExpr ... lvalue ->buf a member (`free(buf)` inside a method) +# DeclRefExpr ... lvalue Var 0x... 'ptr' a local +# `free(buf)` and `free(nodes)` on the SAME LINE are otherwise indistinguishable in the report, +# which is what makes this worth digging two levels through the implicit casts for. +_OPERAND_MEMBER = re.compile(r"MemberExpr 0x\w+ [^\n]*lvalue (?:->|\.)(?P<name>\w+)") +# `Var` for a local, `ParmVar` for a function parameter — `platform::free(ownedBody)` where +# ownedBody is a parameter matched nothing until ParmVar was included (6 of 63 sites). +_OPERAND_VAR = re.compile( + r"DeclRefExpr 0x\w+ [^\n]*lvalue (?:Var|ParmVar) 0x\w+ '(?P<name>\w+)'") + +# The enclosing function, from the head of the "fn" binding block. Its qualified name is not +# in the dump, so the class is recovered from the `'mm::Foo *' implicit this` line when present. +# `[^<]*` before the location: an OUT-OF-LINE definition prints extra fields first +# (`CXXMethodDecl 0x... parent 0x... prev 0x... <path...>`), and requiring `<` immediately +# after the address silently missed every method defined in a .cpp — 4 of 5 sites blank. +_FN_DECL = re.compile( + r"^(?:CXX(?:Method|Constructor|Destructor|Conversion)Decl|FunctionDecl) 0x\w+[^<]*<[^>]*>" + r"[^\n]*?\b(?P<name>~?(?:operator\s*\S+|\w+)) '") +_FN_CLASS = re.compile(r"CXXThisExpr 0x\w+ <[^>]*> '(?:const )?(?:\w+::)*(?P<cls>\w+) \*'") + +# A ScratchBuffer member: `FieldDecl 0x... <path:line:col...> ... name 'ScratchBuffer<uint8_t>':...` +_SCRATCH = re.compile( + r"FieldDecl 0x\w+[^<]*<(?P<path>[^>]*?):(?P<line>\d+):\d+[^>]*>" + r"[^\n]*?(?P<name>\w+) 'ScratchBuffer<(?P<elem>[^>]+)>'") + +# The type a `new` produces: `CXXNewExpr 0x... <...> 'Foo *' array ...`. +_NEW_TYPE = re.compile(r"CXXNewExpr 0x\w+ <[^>]*> '(?P<type>[^']+?) ?\*'") + +RULES = { + "arrays": { + "title": "RAM-costing fixed arrays", + "output": "dump", + "matcher": ("namedDecl(anyOf(" + "varDecl(hasType(constantArrayType()), " + "unless(anyOf(isConstexpr(), hasStaticStorageDuration()))), " + "fieldDecl(hasType(constantArrayType()))))"), + }, + "scratch": { + "title": "ScratchBuffer members (managed heap)", + "output": "dump", + "matcher": ('fieldDecl(hasType(cxxRecordDecl(hasName("ScratchBuffer"))))'), + }, + "heap": { + "title": "Heap allocation sites", + # `dump`, not `diag`: diag gives a location with no expression and print gives an + # expression with no location. Only dump carries both, which is what makes a per-site + # listing possible rather than just a per-file count. + "output": "dump", + # `stmt(...)` is REQUIRED: a bare top-level anyOf() of Stmt matchers is ambiguous + # ("unresolved overloaded type") and clang-query then matches NOTHING while still + # exiting 0 — a silent zero, the same trap as the clang-tidy flag forms. + # `unless(cxxMethodDecl())` matters: hasAnyName("free") also matches our OWN member + # methods called free() (MoonLive, Buffer, MappingLUT all have one), which are not heap + # deallocation at all. Measured on main.cpp: 62 matches with them, 47 without — 15 false + # positives pointing at the wrong lines. + # `hasAncestor(functionDecl().bind("fn"))` names the ENCLOSING function, so the report + # 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(" + 'hasAnyName("malloc","calloc","realloc","free","strdup",' + '"heap_caps_malloc","heap_caps_calloc","heap_caps_realloc","heap_caps_free",' + '"ps_malloc","ps_calloc"), unless(cxxMethodDecl()))))), ' + 'hasAncestor(functionDecl().bind("fn")))'), + }, +} + + +def module_files(module): + """The source files that ARE a module: src/**/<Module>.h and .cpp. + + The MoonDeck dropdown offers module names (`AudioService`, `Control`), which map 1:1 onto + filenames in this codebase — one module, one header, optionally one .cpp. Returns [] for an + unknown name so the caller can say so rather than silently reporting on everything. + """ + hits = [] + for suffix in (".h", ".cpp"): + hits += [p for p in (ROOT / "src").rglob(f"{module}{suffix}")] + return sorted(str(p.relative_to(ROOT)) for p in hits) + + +def including_tus(files, build_dir): + """The translation units that reach `files` — the TUs worth parsing to analyse 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 + resolves the .cpp files whose include graph contains the header, transitively. + + Resolution is textual (`#include "<name>"`, followed through intermediate headers) rather + than from the compiler's depfiles, because those only exist after a build with -MD and would + make the report depend on how the tree was last built. A missed edge costs coverage, so the + caller falls back to every TU when this finds nothing. + """ + tus = _source_tus(build_dir) + wanted = {Path(f).name for f in files} + if not wanted: + return tus + + # header name -> the files that include it, so we can walk the graph upward. + src = list((ROOT / "src").rglob("*.h")) + list((ROOT / "src").rglob("*.cpp")) + includes = {} + for f in src: + try: + body = f.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + includes[f] = {m.group(1).rsplit("/", 1)[-1] + for m in re.finditer(r'#\s*include\s+"([^"]+)"', body)} + + reached, frontier = set(wanted), set(wanted) + while frontier: + nxt = set() + for f, inc in includes.items(): + if inc & frontier and f.name not in reached: + reached.add(f.name) + nxt.add(f.name) + 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 + + +def _source_tus(build_dir): + """The src/ translation units to analyse. + + 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). + """ + 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("\\", "/")}) + + +def _run_rule(rule, tus, build_dir, tool): + """Run one matcher over every TU, in parallel. + + clang-query has no parallel runner of its own (unlike run-clang-tidy) and costs ~44s per + TU here, so serial would be ~11 minutes for a job that takes ~50s across cores. + """ + query = f"set output {rule['output']}\nm {rule['matcher']}\n" + qfile = build_dir / "clang-query-rule.txt" + qfile.write_text(query, encoding="utf-8") + + cmd = [tool, "-p", str(build_dir), "-f", str(qfile)] + cmd += [f"--extra-arg={a}" for a in check_clang_tidy._toolchain_args()] + + 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)) + + +def _rel(path): + """Repo-relative path, or None for anything outside src/ (SDK and vendored headers).""" + p = path.replace("\\", "/") + if "projectMM/src/" in p: + return p.split("projectMM/")[-1] + return p if p.startswith("src/") else None + + +def _truncate(rows, max_rows): + """The rows to print, plus a note when some were dropped. + + Always announces the cut: a table that silently stops at 20 reads as "that is all there is", + which is the same class of lie as a silent zero. Rows are already worst-first, so the ones + kept are the ones worth acting on. + """ + if not max_rows or len(rows) <= max_rows: + return rows, [] + hidden = len(rows) - max_rows + return rows[:max_rows], [ + "", + f" … {hidden} more not shown (--max-rows {max_rows}). Use --max-rows 0 for all, " + f"or --module <name> to scope."] + + +def collect_arrays(out, min_bytes): + """Array declarations over the byte threshold, deduplicated by declaration site. + + A header included by N translation units yields N identical AST nodes, so the key is + (file, line, name) — the declaration itself, not the times it was parsed. + """ + rows = {} + for m in _DECL.finditer(out): + rel = _rel(m["path"]) + if not rel: + continue + elem = m["type"].strip() + count = int(m["count"]) + size = ELEM_BYTES.get(elem, DEFAULT_ELEM_BYTES) * count + if size <= min_bytes: + continue + rows[(rel, int(m["line"]), m["name"])] = { + "file": rel, "line": int(m["line"]), "name": m["name"], + "elem": elem, "count": count, "bytes": size, + "where": "member" if m["kind"] == "FieldDecl" else "local", + } + return sorted(rows.values(), key=lambda r: -r["bytes"]) + + +def collect_heap(out): + """Allocation sites with what they are, what they touch, and the function they sit in. + + Parsed from the AST dump rather than `set output diag`: diag gives a location with no + expression and `print` gives an expression with no location — only dump has both, plus the + bound ancestor. clang-query emits one block per binding, so a match looks like + + Match #7: + Binding for "fn": <the enclosing function, with its whole body below it> + Binding for "root": <the allocation node, with its operand below it> + + which is why this splits on `Match #` and reads the two blocks separately rather than + scanning line by line — the "fn" block contains allocation nodes of its own (every other + site in the same function), and a line-wise scan would attribute them to the wrong match. + """ + rows = {} + for block in re.split(r"^Match #\d+:", out, flags=re.M)[1:]: + parts = re.split(r'^Binding for "(\w+)":$', block, flags=re.M) + # parts: [pre, name1, body1, name2, body2, ...] + bind = {parts[i]: parts[i + 1] for i in range(1, len(parts) - 1, 2)} + root, fn_block = bind.get("root", ""), bind.get("fn", "") + if not root: + continue + + lines = root.strip().splitlines() + if not lines: + continue + head = lines[0] + m = _HEAP_NODE.search(head) + if not m: + continue + rel = _rel(m["path"]) + if not rel: + continue + + sub = "\n".join(lines[1:]) + kind = m["kind"] + if kind == "CXXNewExpr": + what = "new[]" if " array " in head else "new" + ty = _NEW_TYPE.search(head) + target = ty["type"] if ty else "" + elif kind == "CXXDeleteExpr": + what = "delete[]" if " array " in head else "delete" + target = "" + else: + callee = _CALLEE.search(sub) + what = callee["name"] + "()" if callee else "call" + target = "" + + # What is allocated/freed. `free(buf)` and `free(nodes)` can share a line, so without + # this the two are indistinguishable in the report. + if not target: + op = _OPERAND_MEMBER.search(sub) or _OPERAND_VAR.search(sub) + target = op["name"] if op else "" + + # The enclosing function, qualified with its class where the dump reveals one. + fn = "" + fn_lines = fn_block.strip().splitlines() + if fn_lines: + fm = _FN_DECL.match(fn_lines[0]) + if fm: + fn = fm["name"] + cm = _FN_CLASS.search(fn_block) + if cm and cm["cls"] not in fn: + fn = f"{cm['cls']}::{fn}" + + rows[(rel, int(m["line"]), int(m["col"]))] = { + "file": rel, "line": int(m["line"]), "what": what, + "target": target, "fn": fn, + } + return sorted(rows.values(), key=lambda r: (r["file"], r["line"])) + + +def collect_scratch(out): + """ScratchBuffer members, deduplicated by declaration site. + + These are heap allocations the `heap` rule cannot see: the actual platform::alloc lives once + inside ScratchBuffer.cpp, so a module declaring three of them shows zero allocation sites in + its own source. Size is deliberately absent — a ScratchBuffer is sized at RUNTIME from the + light count (that is the point of it), so there is no static number to report. + """ + rows = {} + for m in _SCRATCH.finditer(out): + rel = _rel(m["path"]) + if not rel: + continue + rows[(rel, int(m["line"]), m["name"])] = { + "file": rel, "line": int(m["line"]), "name": m["name"], "elem": m["elem"], + } + return sorted(rows.values(), key=lambda r: (r["file"], r["line"])) + + +def render_scratch(rows, max_rows=0): + nfiles = len(set(r["file"] for r in rows)) + L = [f"{len(rows)} found in {nfiles} file{'s' if nfiles != 1 else ''}."] + if not rows: + return L + + name_w = min(max(len(r["name"]) for r in rows), 24) + elem_w = min(max(len(r["elem"]) for r in rows), 20) + L += ["", f" {'MEMBER':<{name_w}} {'ELEMENT':<{elem_w}} FILE:LINE", + f" {'-' * name_w} {'-' * elem_w} {'-' * 30}"] + shown, note = _truncate(rows, max_rows) + for r in shown: + L.append(f" {r['name'][:name_w]:<{name_w}} {r['elem'][:elem_w]:<{elem_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}."] + if not rows: + return L + + by_where = collections.Counter(r["where"] for r in rows) + L += [f"{by_where['local']} local, {by_where['member']} member."] + + name_w = min(max(len(r["name"]) for r in rows), 30) + decl_w = min(max(len(f"{r['elem']}[{r['count']}]") for r in rows), 22) + L += ["", f" {'BYTES':>7} {'WHERE':<6} {'DECLARATION':<{decl_w}} {'NAME':<{name_w}} FILE:LINE", + f" {'-' * 7} {'-' * 6} {'-' * decl_w} {'-' * name_w} {'-' * 30}"] + shown, note = _truncate(rows, max_rows) + for r in shown: + decl = f"{r['elem']}[{r['count']}]" + L.append(f" {r['bytes']:>7} {r['where']:<6} {decl[:decl_w]:<{decl_w}} " + f"{r['name'][:name_w]:<{name_w}} {r['file']}:{r['line']}") + return L + note + + +# 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()") + + +def render_heap(rows, max_rows=0): + """Two tables — what acquires memory, and what releases it. + + Split because the two answer different questions: the acquire list is where RAM comes from + (and what the hot path must not do), the release list is what pairs with it. Reading them + side by side is how an unpaired allocation shows up. + """ + L = [f"{len(rows)} found."] + if not rows: + return L + + alloc = [r for r in rows if r["what"] not in _RELEASING] + free = [r for r in rows if r["what"] in _RELEASING] + + def table(title, subset): + if not subset: + return [f"", 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 + fn_w = min(max((len(r["fn"]) for r in subset), default=0), 44) or 1 + + def clip(s, w): + return s if len(s) <= w else s[: w - 1] + "…" + + out = ["", f"{title} — {len(subset)}: " + + " ".join(f"{w} ×{n}" for w, n in kinds.most_common()), + "", f" {'WHAT':<{what_w}} {'TARGET':<{tgt_w}} {'FUNCTION':<{fn_w}} FILE:LINE", + f" {'-' * what_w} {'-' * tgt_w} {'-' * fn_w} {'-' * 30}"] + shown, note = _truncate(subset, max_rows) + for r in shown: + out.append(f" {r['what']:<{what_w}} {clip(r['target'], tgt_w):<{tgt_w}} " + f"{clip(r['fn'], fn_w):<{fn_w}} {r['file']}:{r['line']}") + return out + note + + return L + table("ALLOCATE", alloc) + table("FREE", free) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--rule", choices=sorted(RULES), help="Run one rule only.") + ap.add_argument("--min-bytes", type=int, default=DEFAULT_MIN_BYTES, + help=f"Array size threshold in bytes (default {DEFAULT_MIN_BYTES}).") + ap.add_argument("--module", help="Report only findings in this module's source files.") + ap.add_argument("--max-rows", type=int, default=DEFAULT_MAX_ROWS, + help=f"Longest table to print, worst first (default {DEFAULT_MAX_ROWS}; " + f"0 = no limit).") + args = ap.parse_args() + + tool = check_clang_tidy._find_tool("clang-query") + if not tool: + print("clang-query not found. Install LLVM (brew install llvm) or add it to PATH.", + file=sys.stderr) + return 2 + + build_dir = check_clang_tidy._host_build_dir() + if not (build_dir / "compile_commands.json").exists(): + print(f"No compile_commands.json in {build_dir.relative_to(ROOT)} — " + 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 + # 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 + if args.module: + only = module_files(args.module) + if not only: + print(f"No source files for module '{args.module}' " + f"(looked for src/**/{args.module}.h and .cpp).", file=sys.stderr) + return 2 + print(f"Filtered to {args.module}: {', '.join(only)}") + + tus = _source_tus(build_dir) + if only: + scoped = including_tus(only, build_dir) + if scoped and len(scoped) < len(tus): + print(f"Parsing {len(scoped)} of {len(tus)} TUs: " + f"{', '.join(Path(f).name for f in scoped)}") + tus = scoped + print() + if not tus: + print("No src/ translation units in the compilation database.", file=sys.stderr) + return 2 + + for name in ([args.rule] if args.rule else sorted(RULES)): + rule = RULES[name] + out = _run_rule(rule, tus, build_dir, tool) + + # A matcher clang-query rejects prints a parse error and zero matches — which is + # indistinguishable from a clean tree unless we say so. Same silent-zero trap that + # cost us twice on clang-tidy. + # A matcher clang-query cannot resolve yields zero matches and exit 0. Its complaints + # 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] + 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 + + print(f"=== {rule['title']} ===") + if name == "arrays": + rows = collect_arrays(out, args.min_bytes) + elif name == "scratch": + rows = collect_scratch(out) + 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_heap(rows, args.max_rows)) + print("\n".join(body)) + print() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/check_clang_tidy.py b/moondeck/check/check_clang_tidy.py new file mode 100644 index 00000000..dd60599d --- /dev/null +++ b/moondeck/check/check_clang_tidy.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Run clang-tidy over the desktop build and print a readable findings report. + +The config is [.clang-tidy](../../.clang-tidy) at the repo root — the same file clangd reads, +so the editor and this report never disagree. This script only adds what a raw `run-clang-tidy` +dump lacks: deduplication (a header included by N translation units is reported N times), a +per-check and per-file summary, and a filter down to our own files. Output goes to stdout — +MoonDeck shows it in the log pane, so a run answers on the spot instead of writing a file to +go open. + +Not a gate. `.clang-tidy` sets `WarningsAsErrors: ''` while the initial findings are triaged, +so this reports and exits 0 unless `--fail-on-findings` says otherwise. + +Usage: + uv run moondeck/check/check_clang_tidy.py # full run, report to stdout + uv run moondeck/check/check_clang_tidy.py --fail-on-findings + uv run moondeck/check/check_clang_tidy.py --check bugprone-use-after-move +""" + +import argparse +import collections +import os +import re +import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +# A diagnostic line: path:line:col: severity: message [check-name] +# +# The trailing bracket can hold MORE than the check name: with WarningsAsErrors set, clang-tidy +# emits `[clang-diagnostic-unused-variable,-warnings-as-errors]`. A pattern that allowed only +# [a-z.-] rejected the comma and silently dropped EVERY finding — turning the ratchet itself +# into a green-looking zero the moment it was switched on. Capture the whole bracket, then keep +# the first comma-separated name. +_DIAG = re.compile(r"^(?P<file>\S+?):(?P<line>\d+):(?P<col>\d+): " + r"(?P<sev>warning|error): (?P<msg>.*?) \[(?P<check>[^\]]+)\]$") + + +def _host_build_dir(): + """The build dir holding the compile_commands.json to analyse against. + + Two can exist: build/ (a plain `cmake --build build`) and build/<host>/ (what the build + script writes). Pick whichever database is NEWEST rather than assuming, because a database + from a configure that never actually built leaves generated headers missing — clang-tidy + then reports `file not found` and SKIPS that file entirely. A skipped file looks identical + to a clean one in the report, so the stale-database case must not win by default. + """ + host = {"darwin": "macos", "linux": "linux", "win32": "windows"}.get(sys.platform, "macos") + candidates = [d for d in (ROOT / "build" / host, ROOT / "build") + if (d / "compile_commands.json").exists()] + if not candidates: + return ROOT / "build" / host # caller reports the missing-database error + return max(candidates, key=lambda d: (d / "compile_commands.json").stat().st_mtime) + + +def _find_tool(name): + """clang-tidy is often not on PATH on macOS (Xcode does not ship it). Prefer whatever is + on PATH, then the usual Homebrew LLVM location, so this works without setup.""" + return shutil.which(name) or next( + (p for p in (f"/opt/homebrew/opt/llvm/bin/{name}", f"/usr/local/opt/llvm/bin/{name}") + if Path(p).exists()), None) + + +def _toolchain_args(): + """Extra flags so clang-tidy can find the standard library the DATABASE's compiler uses. + + The trap this exists for: CMake records whichever compiler it picked (on macOS that is + Apple Clang at /usr/bin/c++), but clang-tidy is usually a *different* clang — Homebrew + LLVM, or a distro package. A different clang does not know Apple Clang's SDK paths, so + every file fails with `'cstdint' file not found`, clang-tidy abandons it, and the run + reports almost nothing. **It looks like a clean codebase.** Measured here: 129 of 129 + files errored that way, hiding a real integer-division bug in BouncingBallsEffect. + + macOS needs `-isysroot` pointing at the active SDK. Linux and Windows do not: the system + clang and gcc share include paths there, so no argument is needed and this returns empty. + Deliberately NOT "require Homebrew clang" — that would make the check macOS-only. + """ + if sys.platform != "darwin": + return [] + sdk = subprocess.run(["xcrun", "--show-sdk-path"], + capture_output=True, text=True).stdout.strip() + return [f"-isysroot{sdk}"] if sdk else [] + + +def run(build_dir, check_filter=None, tu_regex=None): + """Run clang-tidy across the compilation database; return parsed, deduplicated findings.""" + runner = _find_tool("run-clang-tidy") + if not runner: + print("run-clang-tidy not found. Install LLVM (brew install llvm) or add it to PATH.", + file=sys.stderr) + return None + + cmd = [runner, "-p", str(build_dir), "-quiet", "-j", str(os.cpu_count() or 4)] + # `-extra-arg=VALUE`, never `-extra-arg VALUE`: run-clang-tidy silently ignores the + # space-separated form, which produced an empty report that read as a clean tree. + cmd += [f"-extra-arg={extra}" for extra in _toolchain_args()] + if check_filter: + # `=` form, for the same reason as -extra-arg above: run-clang-tidy silently ignores the + # space-separated form, so `--check X` filtered nothing and reported 0 like a clean tree. + cmd += [f"-checks=-*,{check_filter}"] + + # run-clang-tidy shells out to `clang-tidy` BY NAME, so it must be on PATH even though we + # resolved the runner by absolute path. Without this it exits 0 having analysed nothing — + # a silent empty report that reads exactly like a clean tree. Put the runner's own + # directory first so the pair always comes from one toolchain (a clang-tidy from a + # different LLVM than the runner is the other way this goes quietly wrong). + # A trailing positional restricts which TUs are PARSED, not just which findings are kept — + # measured 3s vs 227s for one module. Only .cpp files can be named: a header is not a TU, so + # a header-only module still needs the full parse and the report filter below. + if tu_regex: + cmd.append(tu_regex) + + env = dict(os.environ) + env["PATH"] = f"{Path(runner).parent}{os.pathsep}{env.get('PATH', '')}" + + # clang-tidy exits non-zero when it finds anything; that is data, not failure. + proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, env=env) + out = proc.stdout + + # A run that analysed nothing prints no per-file progress and yields no findings, which + # is indistinguishable from a clean tree in the report. Treat it as an error instead: + # a silent "0 findings" is the worst possible output for a check like this. + if "clang-tidy" in proc.stderr and "not found" in proc.stderr: + print(f"run-clang-tidy could not invoke clang-tidy:\n{proc.stderr.strip()}", + file=sys.stderr) + return None + + seen, rows = set(), [] + for line in out.splitlines(): + m = _DIAG.match(line.replace(str(ROOT) + "/", "")) + if not m: + continue + d = m.groupdict() + d["check"] = d["check"].split(",")[0] # drop the `,-warnings-as-errors` suffix + # Our code only: a TU drags in SDK and vendored headers we neither own nor fix. + if not d["file"].startswith(("src/", "test/")): + continue + # A header analysed via N translation units yields N identical diagnostics. + key = (d["file"], d["line"], d["col"], d["check"]) + if key in seen: + continue + seen.add(key) + rows.append(d) + + # A file that fails to compile is never analysed, so widespread errors mean the report is + # measuring nothing — the failure that hid a real bug here until it was chased down. Treat + # "most files errored" as a broken run rather than a result, because the alternative is a + # short, clean-looking report that is entirely fictional. + errors = sum(1 for r in rows if r["check"] == "clang-diagnostic-error") + if errors > 10: + print(f"{errors} files failed to compile — clang-tidy analysed almost nothing.\n" + f"Usually a toolchain mismatch: the compilation database was written by a\n" + f"different compiler than clang-tidy is using. Rebuild the desktop target, or\n" + f"check _toolchain_args() covers this platform.", file=sys.stderr) + return None + + return sorted(rows, key=lambda r: (r["file"], int(r["line"]))) + + +def render(rows, commit): + """The findings as plain text, printed straight to the log. + + Deliberately NOT a file: a 2-3 minute run that answers with "wrote a file, go look" makes + you open a second thing to learn what happened, and the file was gitignored anyway — it + existed only to be read once. Space-aligned columns rather than Markdown tables, since the + output pane is a plain-text log (same reasoning as check_lizard.py's table). + """ + by_check = collections.Counter(r["check"] for r in rows) + by_file = collections.Counter(r["file"] for r in rows) + + L = [f"clang-tidy · {datetime.now():%Y-%m-%d %H:%M} · commit {commit} · " + f"{len(rows)} findings"] + + if not rows: + # Kept deliberately: this is not an explanation but the check on the result — a zero + # from a run that analysed nothing looks identical to a clean tree. + L += ["", "No findings. ✓", + "Verify with a check that must fire: " + "--check readability-magic-numbers"] + return "\n".join(L) + + check_w = max(len(c) for c in by_check) + L += ["", "BY CHECK", f" {'COUNT':>5} CHECK", f" {'-' * 5} {'-' * check_w}"] + L += [f" {n:>5} {c}" for c, n in by_check.most_common()] + + file_w = min(max(len(f) for f in by_file), 60) + L += ["", "WORST FILES", f" {'COUNT':>5} FILE", f" {'-' * 5} {'-' * file_w}"] + L += [f" {n:>5} {f}" for f, n in by_file.most_common(15)] + + L += ["", "ALL FINDINGS"] + current = None + for r in rows: + if r["file"] != current: + current = r["file"] + L += ["", f" {current}"] + L.append(f" {r['line']:>6} {r['msg']} [{r['check']}]") + return "\n".join(L) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--fail-on-findings", action="store_true", + help="Exit 1 if anything is found (for when this becomes a gate).") + ap.add_argument("--check", help="Run one check only, e.g. bugprone-use-after-move.") + ap.add_argument("--module", help="Report only findings in this module's source files.") + args = ap.parse_args() + + build_dir = _host_build_dir() + if not (build_dir / "compile_commands.json").exists(): + print(f"No compile_commands.json in {build_dir.relative_to(ROOT)} — " + f"run `uv run moondeck/build/build_desktop.py` first.", file=sys.stderr) + return 2 + + only, tu_regex = None, None + if args.module: + # Resolver lives in check_clang_query (one owner) so every tool scopes a module the same. + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import check_clang_query + 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)}") + # Parse only the TUs that actually reach this module's files. A header-only module has + # no TU of its own, but it IS included by one or two — analysing those beats parsing all + # 15 (measured: 18s vs 263s for a header included only by main.cpp). + tus = check_clang_query.including_tus(only, build_dir) + all_tus = check_clang_query._source_tus(build_dir) + if tus and len(tus) < len(all_tus): + tu_regex = "|".join(re.escape(Path(f).name) for f in tus) + print(f"Parsing {len(tus)} of {len(all_tus)} TUs: " + f"{', '.join(Path(f).name for f in tus)}") + print() + + rows = run(build_dir, args.check, tu_regex) + if rows is None: + return 2 + if only: + rows = [r for r in rows if r["file"] in only] + + commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, + capture_output=True, text=True).stdout.strip() + print(render(rows, commit)) + + return 1 if (args.fail_on_findings and rows) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/check_esp32_built.py b/moondeck/check/check_esp32_built.py new file mode 100644 index 00000000..7d287911 --- /dev/null +++ b/moondeck/check/check_esp32_built.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Check that a firmware binary exists and is newer than the sources that go into it. + +The full `idf.py build` is the strongest check, but on a cold build directory it costs +minutes — enough that the gate list stops being run, which is worse than a weaker check +run often. This is the cheap alternative: it does not compile, it asks whether the binary +on disk was built *after* every source file that feeds it. + +Freshness is measured against the SOURCES, not the clock. A wall-clock rule ("built within +the last hour") passes a binary that predates an edit made twenty minutes ago — precisely +the stale-artifact trap that costs a debugging session chasing a fix that was never in the +running image. Comparing timestamps answers the question the gate actually means: does this +binary include what is on disk right now? + +Usage: + uv run moondeck/check/check_esp32_built.py --firmware esp32s3-n16r8 + uv run moondeck/check/check_esp32_built.py --firmware esp32 --max-age-hours 24 + +Exit codes: 0 fresh · 1 missing or stale (the message says which, and what to run). +""" + +import argparse +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +# What feeds an ESP32 image. Kept in step with the gate's own trigger in +# moondeck/event/precommit.py — both answer "could this change alter the firmware?". +SOURCE_DIRS = ("src", "esp32") +SOURCE_FILES = ("CMakeLists.txt", "library.json") +SOURCE_SUFFIXES = {".c", ".cpp", ".h", ".hpp", ".cmake", ".json", ".txt", ".py", ".js", + ".html", ".css", ".defaults"} + +# Build outputs and caches live under the source dirs; they are products, not inputs, and +# including them would compare the binary against itself. +SKIP_PARTS = {"build", "managed_components", "__pycache__", ".git"} + +# Generated headers that sit in the source tree but are build PRODUCTS (both gitignored). +# A desktop build regenerates them after an ESP32 build, so treating them as inputs makes +# the firmware look stale forever, one minute after it was built. What actually feeds them +# — src/ui/*.js, library.json, the git hash — is already covered by the scan. +SKIP_FILES = {"src/ui/ui_embedded.h", "src/core/build_info.h"} + +# The desktop-only platform never compiles into an ESP32 image, so an edit there cannot +# stale the firmware. Kept in step with the ESP32 gate's own trigger in precommit.py, +# which excludes the same path. +SKIP_PREFIXES = ("src/platform/desktop/",) + + +def newest_source(): + """The most recently modified source file that feeds a firmware image.""" + newest_path, newest_mtime = None, 0.0 + + for rel in SOURCE_DIRS: + base = ROOT / rel + if not base.exists(): + continue + for path in base.rglob("*"): + if not path.is_file(): + continue + rel_posix = path.relative_to(ROOT).as_posix() + if SKIP_PARTS.intersection(path.relative_to(ROOT).parts): + continue + if rel_posix in SKIP_FILES or rel_posix.startswith(SKIP_PREFIXES): + continue + if path.suffix not in SOURCE_SUFFIXES: + continue + mtime = path.stat().st_mtime + if mtime > newest_mtime: + newest_path, newest_mtime = path, mtime + + for rel in SOURCE_FILES: + path = ROOT / rel + if path.exists() and path.stat().st_mtime > newest_mtime: + newest_path, newest_mtime = path, path.stat().st_mtime + + return newest_path, newest_mtime + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--firmware", required=True, + help="Firmware variant, e.g. esp32s3-n16r8 (its build dir is " + "build/esp32-<firmware>/).") + parser.add_argument("--max-age-hours", type=float, default=0, + help="Also fail if the binary is older than this many hours, even " + "when no source is newer. 0 (default) disables the age rule — " + "source freshness is the check that matters.") + args = parser.parse_args() + + binary = ROOT / "build" / f"esp32-{args.firmware}" / "projectMM.bin" + build_cmd = f"uv run moondeck/build/build_esp32.py --firmware {args.firmware}" + + if not binary.exists(): + print(f"No firmware binary for {args.firmware}.") + print(f" expected: {binary.relative_to(ROOT)}") + print(f" build it: {build_cmd}") + return 1 + + built = binary.stat().st_mtime + newest_path, newest_mtime = newest_source() + age_h = (time.time() - built) / 3600 + + if newest_path is not None and newest_mtime > built: + stale_by = (newest_mtime - built) / 60 + print(f"Firmware for {args.firmware} is STALE: a source file is newer than the binary.") + print(f" binary : {binary.relative_to(ROOT)} (built {age_h:.1f}h ago)") + print(f" newer : {newest_path.relative_to(ROOT)} ({stale_by:.0f} min after the build)") + print(f" rebuild: {build_cmd}") + return 1 + + if args.max_age_hours and age_h > args.max_age_hours: + print(f"Firmware for {args.firmware} is older than {args.max_age_hours}h " + f"({age_h:.1f}h) — no source is newer, but the age rule asks for a rebuild.") + print(f" rebuild: {build_cmd}") + return 1 + + print(f"Firmware for {args.firmware} is up to date " + f"(built {age_h:.1f}h ago, newer than every source).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/check_hotpath.py b/moondeck/check/check_hotpath.py new file mode 100644 index 00000000..da33809b --- /dev/null +++ b/moondeck/check/check_hotpath.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Flag hot-path discipline violations: allocation and blocking in the render path. + +The rule (CLAUDE.md § Principles, Minimalism; architecture.md § Hot path discipline): in +the render loop and everything it calls there is no heap allocation and no blocking. A +violation does not fail the build — it produces a frame hitch or, on a long-running ESP32, +fragmentation that degrades over hours. That makes it exactly the kind of defect a review +misses and a user reports as "it stutters after a day". + +This is a LINT, not a proof. It reads the text of the functions that make up the render +path and reports the banned constructs it can see: + + allocation new / malloc / push_back / std::string / make_unique / make_shared + blocking delay / sleep / mutex.lock (try_lock is the sanctioned form) + +What it cannot see: a call into a helper that allocates, a container that grows behind an +innocent-looking method, allocation inside a template instantiated elsewhere. So a clean +run means "no violation is visible in the render path's own source", not "the hot path is +allocation-free". Treat a finding as a question to answer, not an automatic bug. + +Usage: + uv run moondeck/check/check_hotpath.py # report findings, exit 1 if any + uv run moondeck/check/check_hotpath.py --list # list the scanned functions and exit +""" + +import argparse +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +SRC = ROOT / "src" + +# The methods that ARE the render path. `tick()` is the render loop itself; the periodic +# ticks share the same thread between two frames, so a heavy or allocating one steals from +# the render budget just as directly (architecture.md § Hot path discipline, sub-hot path). +HOT_METHODS = ("tick", "tick20ms", "tick1s") + +# One pattern per banned construct, with the reason the reader needs at the point of the +# finding — a bare "push_back found" teaches nothing. +BANNED = [ + (re.compile(r'\bnew\s+[A-Za-z_]'), "heap allocation (`new`)", + "allocate in setup()/prepare() and reuse the buffer"), + (re.compile(r'\bmalloc\s*\('), "heap allocation (`malloc`)", + "allocate in setup()/prepare() and reuse the buffer"), + (re.compile(r'\.push_back\s*\('), "heap allocation (`push_back` may grow)", + "pre-size the container in prepare(), or use a fixed array"), + (re.compile(r'\bstd::string\b'), "heap allocation (`std::string`)", + "use a fixed char buffer; std::string allocates on construction and on append"), + (re.compile(r'\bmake_unique\s*<|\bmake_shared\s*<'), "heap allocation (smart-pointer factory)", + "allocate in setup()/prepare()"), + (re.compile(r'\bdelay\s*\(|\bdelayMs\s*\('), "blocking (`delay`)", + "gate on millis() instead; blocking the render task shows as a visible glitch"), + (re.compile(r'\bsleep\s*\(|sleep_for\s*\('), "blocking (`sleep`)", + "gate on millis() instead"), + (re.compile(r'\.lock\s*\(\s*\)'), "blocking (`mutex.lock`)", + "use try_lock and skip the work this tick"), +] + +# A line carrying this marker is a deliberate, explained exception. The rule is the same +# one the codebase uses for a -Wno- suppression: the escape hatch exists, but it has to +# state its reason at the site, so a reviewer sees the justification rather than silence. +ALLOW_MARKER = "hot-path-ok:" + +# The desktop platform's own run loop is not the device's render path: it is host-side +# glue, free to allocate and block. (Only `src/` is scanned, so the test tree needs no +# entry here.) +SKIP_PARTS = {"platform/desktop"} + + +def hot_path_bodies(path, text): + """Yield (method_name, start_line, body_text) for each hot method defined in `text`. + + Brace-matched from the method's opening `{`, so a nested block or a lambda inside the + body stays part of it. Deliberately simple: this is a lint over well-formed project + source, not a C++ parser. + """ + for method in HOT_METHODS: + # `void tick() override {` / `void tick1s() {` — the definition, not a call. + for m in re.finditer(rf'\b(?:void|bool|int)\s+{method}\s*\([^)]*\)[^;{{]*\{{', text): + start = m.end() - 1 + depth, i = 0, start + while i < len(text): + if text[i] == '{': + depth += 1 + elif text[i] == '}': + depth -= 1 + if depth == 0: + break + i += 1 + body = text[start:i] + yield method, text[:start].count('\n') + 1, body + + +def scan_file(path): + findings = [] + rel = path.relative_to(ROOT).as_posix() + if any(part in rel for part in SKIP_PARTS): + return findings + + text = path.read_text(encoding="utf-8", errors="replace") + for method, method_line, body in hot_path_bodies(path, text): + for offset, line in enumerate(body.splitlines()): + stripped = line.strip() + # Skip comments and deliberate, explained exceptions. + if stripped.startswith("//") or stripped.startswith("*"): + continue + if ALLOW_MARKER in line: + continue + # Match against CODE only. A trailing comment ("// fade dead on new game") + # otherwise reads as an allocation — prose is full of the banned words, and a + # lint that cries wolf on comments gets muted, which costs the real findings. + code = line.split("//", 1)[0] + if not code.strip(): + continue + for pattern, what, fix in BANNED: + if pattern.search(code): + findings.append({ + "file": rel, + "line": method_line + offset, + "method": method, + "what": what, + "fix": fix, + "code": stripped[:100], + }) + break # one finding per line is enough to send the reader there + return findings + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--list", action="store_true", + help="List the hot-path methods that would be scanned, then exit.") + args = parser.parse_args() + + files = sorted(SRC.rglob("*.h")) + sorted(SRC.rglob("*.cpp")) + + if args.list: + for path in files: + rel = path.relative_to(ROOT).as_posix() + if any(part in rel for part in SKIP_PARTS): + continue + text = path.read_text(encoding="utf-8", errors="replace") + for method, line, _ in hot_path_bodies(path, text): + print(f"{rel}:{line} {method}()") + return 0 + + findings = [] + for path in files: + findings.extend(scan_file(path)) + + if not findings: + print("Hot-path check passed: no allocation or blocking visible in the render path.") + return 0 + + print(f"Hot-path findings ({len(findings)}):\n") + for f in findings: + print(f" {f['file']}:{f['line']} in {f['method']}()") + print(f" {f['what']}") + print(f" {f['code']}") + print(f" -> {f['fix']}\n") + + print(f"A finding is a question, not a verdict: this lint reads only the method's own " + f"source.\nIf a line is a justified exception, mark it `// {ALLOW_MARKER} <reason>` " + f"so the reason lives at the site.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/check_lizard.py b/moondeck/check/check_lizard.py new file mode 100644 index 00000000..f9f57225 --- /dev/null +++ b/moondeck/check/check_lizard.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Complexity gate: fail on NEW over-complex functions, not on the ones we already live with. + +lizard counts cyclomatic complexity (CCN) and length (NLOC) per function. Its job in this +stack is the NUMBER PER COMMIT that repo-health trends — clang-tidy can tell you a function +is complex today, only a trend tells you the codebase is getting worse (plan: "one rule, one +owner"; lizard owns complexity, clang-tidy's `readability-function-*` checks stay off). + +A raw run reports 162 warnings, and a metric that can never reach zero is a poor gate: it +reads as permanently-failing, so people stop looking. Hence the baseline. `whitelizard.txt` +freezes today's set; this check fails only on a function that is NEW or newly over the +threshold. Bringing a baselined function under the limit and deleting its line is how the +number goes down — the same ratchet shape as repo-health.json. + +The baseline matches on FILE + FUNCTION NAME, never line numbers, so it survives edits above +a function. That is lizard's own `-W/--whitelist` format (`file:name`, `#` comments), used +rather than reimplemented — the filtering is upstream's, we only generate and report. + +Thresholds match collect_kpi.py so both tools report the same set: CCN > 10, NLOC > 60. + +Usage: + 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 + uv run moondeck/check/check_lizard.py --baseline # rewrite whitelizard.txt from today +""" + +import argparse +import csv +import io +import os +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +# Not the repo root: lizard picks up `./whitelizard.txt` BY DEFAULT, and a baseline that +# applies itself silently is what zeroed the KPI's complexity count once this landed. +# Sitting in docs/metrics/ next to repo-health.* it is only ever applied on purpose, and +# it lives with the other measured-state files. +BASELINE = ROOT / "docs" / "metrics" / "whitelizard.txt" + +# Same thresholds as collect_kpi.py — one definition of "too complex" across the tooling. +MAX_CCN = 10 +MAX_NLOC = 60 + +# `src/ui/` is JavaScript served to the browser, not firmware C++. +LIZARD_ARGS = ["src/", "-l", "cpp", "-x", "src/ui/*"] + + +def measure(extra=None): + """Every function lizard measured, as dicts. Public: collect_kpi.py calls this so the KPI + number and this gate can never disagree about what lizard said. + + `--csv` so we parse a real format rather than a column-aligned report whose spacing shifts + with the longest name. `-W /dev/null` disables lizard's own whitelist entirely: the raw + measurement must never be filtered, or the KPI trend flatlines at 0 the moment a baseline + exists and hides all future growth. Baselining is this script's job, applied explicitly + below. (Belt and braces — the baseline no longer sits at lizard's default path either.) + """ + cmd = ["uv", "run", "--with", "lizard", "python3", "-m", "lizard", + *LIZARD_ARGS, "--csv", "-W", os.devnull, *(extra or [])] + proc = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=300) + # lizard exits non-zero when warnings exist; that is data here, not a failure. A real + # failure produces no parseable rows, which the caller detects. + rows = list(csv.reader(io.StringIO(proc.stdout))) + out = [] + for r in rows: + # nloc,ccn,token,param,length,location,file,name,long_name,start,end + if len(r) < 11: + continue + try: + out.append({"nloc": int(r[0]), "ccn": int(r[1]), "token": int(r[2]), + "param": int(r[3]), "length": int(r[4]), + "file": r[6], "name": r[7], "start": int(r[9])}) + except ValueError: + continue # header or a malformed line + if not out: + print("lizard produced no parseable output — is it installed? (uv run --with lizard)", + file=sys.stderr) + print(proc.stderr.strip()[:500], file=sys.stderr) + return None + return out + + +def violations(funcs): + """The functions over either threshold, worst-first so the report leads with the target.""" + bad = [f for f in funcs if f["ccn"] > MAX_CCN or f["nloc"] > MAX_NLOC] + return sorted(bad, key=lambda f: (-f["ccn"], -f["nloc"])) + + +def _read_baseline(): + """The baselined (file, name) pairs. Absent file = no baseline, everything is new.""" + if not BASELINE.exists(): + return set() + out = set() + for line in BASELINE.read_text(encoding="utf-8").splitlines(): + line = line.split("#")[0].strip() + if not line: + continue + # lizard's format is `file:name`; `::` in a C++ name is not the separator. + pieces = line.replace("::", "##").split(":") + if len(pieces) > 1: + out.add((pieces[0], pieces[1].replace("##", "::"))) + return out + + +def _write_baseline(viol): + lines = [ + "# lizard complexity baseline — the over-threshold functions as of this commit.", + "#", + "# Generated by `uv run moondeck/check/check_lizard.py --baseline`. Format is lizard's", + "# own whitelist (`file:function`, matched by NAME so it survives edits above the", + "# function). A line here means 'known, not yet fixed' — NOT 'acceptable'.", + "#", + "# This list only shrinks: simplify a function, delete its line. Adding a line means", + "# admitting a new violation, which is what the check exists to prevent.", + "#", + f"# Thresholds: CCN > {MAX_CCN} or NLOC > {MAX_NLOC}.", + "", + ] + for v in viol: + lines.append(f"{v['file']}:{v['name']} # CCN {v['ccn']}, NLOC {v['nloc']}") + BASELINE.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _table(viol, indent=" "): + """The violations as one aligned table, worst first. + + Flat and severity-ordered rather than grouped by file: the point of the list is "what do I + fix next", and the worst function is the answer regardless of which file it lives in. + (Grouping by file tripled the line count for 162 findings and buried the CCN 56 outlier + under an alphabetically-earlier file.) + + Columns are space-padded rather than drawn with box characters — the output pane is a + plain-text log, and space alignment survives copy-paste into an issue or commit message. + """ + if not viol: + return [] + + # `mm::` is on essentially every symbol here, so it distinguishes nothing and costs width. + # The directory likewise: the class name in the symbol already says where you are. + def short(name): + return name.removeprefix("mm::") + + def base(path): + return path.rsplit("/", 1)[-1] + + name_w = min(max(*(len(short(v["name"])) for v in viol), len("FUNCTION")), 44) + file_w = min(max(*(len(base(v["file"])) for v in viol), len("FILE")), 28) + + def clip(s, w): + return s if len(s) <= w else s[: w - 1] + "…" + + # A `*` next to a number marks the threshold that actually tripped — a CCN 93 function and + # a 178-line one need different fixes, and bare numbers do not say which rule fired. + def cell(value, limit=None): + return f"{value}{'*' if limit is not None and value > limit else ' '}" + + # The same five metrics lizard's own summary reports, so this table and the KPI line read + # as one tool. Only CCN and NLOC are gated; TOKEN/PARAM/LINES are context that tells you + # WHY a function is heavy (many branches vs a long argument list vs sheer size). + out = [ + "", + f"{indent}{'CCN':>5} {'NLOC':>5} {'TOKEN':>6} {'PARAM':>5} {'LINES':>5} " + f"{'FUNCTION':<{name_w}} FILE", + f"{indent}{'-' * 5} {'-' * 5} {'-' * 6} {'-' * 5} {'-' * 5} " + f"{'-' * name_w} {'-' * file_w}", + ] + for v in sorted(viol, key=lambda v: (-v["ccn"], -v["nloc"])): + out.append(f"{indent}{cell(v['ccn'], MAX_CCN):>5} {cell(v['nloc'], MAX_NLOC):>5} " + f"{v['token']:>6} {v['param']:>5} {v['length']:>5} " + f"{clip(short(v['name']), name_w):<{name_w}} {clip(base(v['file']), file_w)}") + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--all", action="store_true", help="report every violation, ignore the baseline") + ap.add_argument("--baseline", action="store_true", help="rewrite whitelizard.txt from today's run") + ap.add_argument("--module", help="Report only this module's source files.") + args = ap.parse_args() + + funcs = measure() + if funcs is None: + return 2 + + # lizard measures per FILE (no translation units involved), so scoping is a plain filter. + if args.module: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import check_clang_query + 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") + funcs = [f for f in funcs if f["file"] in only] + + viol = violations(funcs) + + if args.baseline: + _write_baseline(viol) + print(f"Baseline written: {len(viol)} functions frozen in " + f"{BASELINE.relative_to(ROOT)}") + return 0 + + if args.all: + print(f"All violations: {len(viol)} (CCN > {MAX_CCN} or NLOC > {MAX_NLOC}) " + f"of {len(funcs)} functions") + print("\n".join(_table(viol))) + return 0 + + baseline = _read_baseline() + # Scope the baseline to the same files, or the "no longer violates" report below compares a + # module-sized violation set against the WHOLE baseline and calls every other module's entry + # fixed — 159 phantom rows on one module, and acting on that advice wipes the baseline. + if args.module: + baseline = {b for b in baseline if b[0] in only} + new = [v for v in viol if (v["file"], v["name"]) not in baseline] + # A baselined function that is no longer a violation: the list should shrink to match. + current = {(v["file"], v["name"]) for v in viol} + fixed = [b for b in baseline if b not in current] + + print(f"lizard: {len(funcs)} functions, {len(viol)} over threshold " + f"(CCN > {MAX_CCN} or NLOC > {MAX_NLOC}), {len(baseline)} baselined") + + if fixed: + print(f"\n{len(fixed)} baselined function(s) no longer violate — " + f"drop them from {BASELINE.name} (run --baseline):") + for f, n in sorted(fixed)[:10]: + print(f" {n} ({f})") + + if new: + print(f"\nFAIL — {len(new)} NEW violation(s):") + print("\n".join(_table(new))) + print("\nSimplify it, or add it to whitelizard.txt with a reason if it is genuinely " + "irreducible.") + return 1 + + print("\nNo new violations. ✓") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/check_module.py b/moondeck/check/check_module.py new file mode 100644 index 00000000..8c42e19c --- /dev/null +++ b/moondeck/check/check_module.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""Every static-analysis tool, on ONE module — the repo-wide reports turned around. + +The other check scripts each run one tool over the whole repo: good for a sweep, wrong shape +when you are working on a single file and want to know what the tools say about *it*. This runs +the whole set against one module and prints them under one heading, so "is my module clean" is +one click rather than three runs and three filters. + +Adds no analysis of its own — it invokes the same scripts with `--module`, so this report and +the repo-wide ones can never disagree about a finding. + +Tools, in order (sequential by design: each already parallelises across cores internally, so +running them concurrently would just make them contend): + + clang-tidy bug patterns, performance, portability + clang-query our own AST rules — RAM-costing arrays, heap allocation sites + lizard complexity (CCN / NLOC), baseline-filtered + +A module is resolved to `src/**/<Module>.h` and `.cpp` (see check_clang_query.module_files) — +the same names the MoonDeck dropdown offers. + +Usage: + uv run moondeck/check/check_module.py --module Control + uv run moondeck/check/check_module.py --module Layer --skip clang-tidy +""" + +import argparse +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +HERE = Path(__file__).resolve().parent + +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). +TOOLS = [ + ("clang-tidy", ["check_clang_tidy.py"]), + ("clang-query", ["check_clang_query.py", "--max-rows=0"]), + ("lizard", ["check_lizard.py", "--all"]), +] + + +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.") + args = ap.parse_args() + + files = check_clang_query.module_files(args.module) + if not files: + print(f"No source files for module '{args.module}' — looked for " + f"src/**/{args.module}.h and src/**/{args.module}.cpp.", file=sys.stderr) + return 2 + + print(f"All tools on module: {args.module}") + print(f"Files: {', '.join(files)}") + print() + + failed = [] + for name, argv in TOOLS: + if name in args.skip: + print(f"--- {name}: skipped ---\n") + continue + print(f"{'=' * 70}\n=== {name}\n{'=' * 70}") + started = time.time() + proc = subprocess.run( + ["uv", "run", str(HERE / argv[0]), *argv[1:], "--module", args.module], + cwd=ROOT, capture_output=True, text=True) + # Each tool already prints a readable report; pass it through rather than re-format, + # so this stays an orchestrator and the formatting has one owner per tool. + 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) + print(f"[{name}: {time.time() - started:.0f}s]\n") + + if failed: + print(f"Tools that could not run: {', '.join(failed)}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/check/collect_kpi.py b/moondeck/check/collect_kpi.py index 2dfd2898..65757503 100644 --- a/moondeck/check/collect_kpi.py +++ b/moondeck/check/collect_kpi.py @@ -24,6 +24,12 @@ sys.path.insert(0, str(ROOT / "moondeck")) from _moondeck_config import active_device_ips, raised_log_level, LOG_INFO # noqa: E402 +# Same directory; imported by path so this needs no PYTHONPATH tweak (the pattern the +# other cross-script imports here use). +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import repo_health # noqa: E402 +import check_lizard # noqa: E402 + # Per-host desktop build dir (matches build_desktop.py / package_desktop.py). # We pick the directory belonging to the OS this script runs on so KPI # numbers reflect the binary the developer actually has on disk. @@ -267,8 +273,19 @@ def _extract_esp32_tick(log, kpi): return "tick_us" in kpi return False +# Whether a stale monitor.log may be refreshed by opening the serial port. On by default — +# a live reading is the honest one for a commit message. The gate lists turn it off +# (--no-live-capture): a capture costs ~80s and needs a bench board plugged in, which makes +# the gate's cost unpredictable, and a gate people avoid running protects nothing. +_LIVE_CAPTURE_ENABLED = True + + def _live_capture(log, seconds=15): """Capture ESP32 serial output to monitor.log for ~seconds. Returns True on success.""" + if not _LIVE_CAPTURE_ENABLED: + print(" ESP32 KPI: live capture disabled (--no-live-capture); " + "using monitor.log if present") + return False import json cfg = ROOT / "moondeck" / "moondeck.json" if not cfg.exists(): @@ -332,27 +349,16 @@ def collect_code(): kpi["specs"] = len(list((ROOT / "docs" / "moonmodules").rglob("*.md"))) kpi["scenarios"] = len(list((ROOT / "test" / "scenarios").rglob("*.json"))) - # Lizard - out, _ = run( - ["uv", "run", "--with", "lizard", "python3", "-m", "lizard", - "src/", "-l", "cpp", "-T", "nloc=60", "-T", "cyclomatic_complexity=10", - "-x", "src/ui/*"], - cwd=ROOT, timeout=30 - ) - warnings = [] - in_warnings = False - for line in out.splitlines(): - if "Warning" in line and "!!!!" in line: - in_warnings = True - continue - if in_warnings and "---" in line: - continue - if in_warnings and line.strip() and "NLOC" not in line and "Total" not in line and "====" not in line: - warnings.append(line.strip()) - if in_warnings and "Total" in line: - in_warnings = False + # Lizard — the RAW count, deliberately ignoring whitelizard.txt. The gate + # (check_lizard.py) subtracts the baseline so it fails only on new violations; the KPI must + # not, or the trend flatlines at 0 and hides every future regression. Two different jobs on + # the same measurement: the gate asks "did we get worse since the baseline", the KPI asks + # "how much complexity is there". Shared parsing so the two can never disagree on the count. + funcs = check_lizard.measure() + warnings = check_lizard.violations(funcs) if funcs else [] kpi["lizard_warnings"] = len(warnings) - kpi["lizard_details"] = warnings + kpi["lizard_details"] = [f"{v['ccn']} CCN, {v['nloc']} NLOC: {v['name']} ({v['file']})" + for v in warnings] return kpi @@ -434,7 +440,17 @@ def main(): parser = argparse.ArgumentParser() parser.add_argument("--commit", action="store_true", help="Output in commit message format (one-liner + details)") + parser.add_argument("--no-live-capture", action="store_true", + help="Never open the serial port for a fresh ESP32 tick reading; " + "use esp32/monitor.log only, however old it is. Turns an " + "~80s step into a few seconds, at the cost of the ESP32 " + "tick/FPS numbers when no recent log exists. For the gate " + "lists, where predictable cost matters more than a live " + "reading; omit it when composing a commit message.") args = parser.parse_args() + if args.no_live_capture: + global _LIVE_CAPTURE_ENABLED + _LIVE_CAPTURE_ENABLED = False desktop = collect_desktop() esp32 = collect_esp32() @@ -467,6 +483,20 @@ def main(): # Event 1 gate 7 — KPI collection). # Only --commit mode aborts on a breach; a plain interactive report just # warns, so viewing KPIs on an unlucky slow sample does not exit non-zero. + # The repo-health snapshot rides along with the KPI run: it needs the tick/FPS this + # collector just measured, and running at the same moment keeps the two views of + # "how is the project doing" consistent. Written only in --commit mode, so an + # interactive report never rewrites a committed file behind the reader's back. + if args.commit: + print() + perf = {} + if desktop.get("tick_us"): + perf["desktop"] = {"tick_us": desktop["tick_us"][0], + "fps": desktop.get("fps", [None])[0]} + if esp32.get("tick_us"): + perf["esp32"] = {"tick_us": esp32["tick_us"], "fps": esp32.get("fps")} + repo_health.write(perf) + esp32_tick = esp32.get("tick_us") lights = desktop.get("lights") if esp32_tick is not None and lights: diff --git a/moondeck/check/repo_health.py b/moondeck/check/repo_health.py new file mode 100644 index 00000000..96f70197 --- /dev/null +++ b/moondeck/check/repo_health.py @@ -0,0 +1,382 @@ +#!/usr/bin/env python3 +"""Measure the repo's current state into `docs/metrics/` — the lean-o-meter. + +The v4 goal is a system that stays small as it gains features, and the honest way to know +whether that is happening is to measure it every commit rather than to assert it. This +writes ONE small file holding only the CURRENT numbers: flash per firmware variant, tick +and FPS per target, lines of code by area, comment density, test counts, docs inventory. + +**The file never grows, because the history is git's.** `git log -p docs/metrics/repo-health.json` +is the trend; the file itself is a snapshot. That is the whole design: no accumulating +log, no rolling window, no second source of truth to prune later. + +**Soft ratchet, by intent.** Nothing here fails a build or blocks a commit. The gate +prints the delta against the committed file before overwriting it, so growth is visible +while you work, and the file's diff shows it again in review. The judgment stays human: +these numbers count things, they cannot tell a valuable comment from a restating one, or +a load-bearing test from a trivial one. + +Every measurement is deterministic and dependency-free (git ls-files, file reads, stat), +so two machines agree and a number never moves for a reason nobody can explain. + +Usage: + uv run moondeck/check/repo_health.py # print the snapshot + delta, write nothing + uv run moondeck/check/repo_health.py --write # write docs/metrics/repo-health.* (KPI gate does this) +""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent +HEALTH_FILE = ROOT / "docs" / "metrics" / "repo-health.json" +# The same snapshot as a table a human reads: units applied, ratios as percentages, areas +# grouped. The JSON stays the source the delta is computed from; this is the view. Both +# are generated from one measurement, so they cannot disagree. +HEALTH_MD = ROOT / "docs" / "metrics" / "repo-health.md" + +# Source areas measured separately: the core/light split is the one the architecture cares +# about (core is meant to grow slower than the domain), and the rest are the other places +# code accumulates. +AREAS = { + "core": "src/core", + "light": "src/light", + "platform": "src/platform", + "ui": "src/ui", + "test": "test", + "moondeck": "moondeck", +} + +CODE_SUFFIXES = {".h", ".cpp", ".c", ".js", ".py"} + + +def _git_files(prefix): + """Tracked files under `prefix`. Tracked-only on purpose: a build artifact or a local + scratch file is not repo state, and counting it would make the number depend on + whatever happens to sit in the working tree.""" + out = subprocess.run(["git", "ls-files", prefix], cwd=ROOT, + capture_output=True, text=True).stdout + return [ROOT / line for line in out.splitlines() if line.strip()] + + +def _read(path): + return path.read_text(encoding="utf-8", errors="replace") + + +def measure_loc(): + """Lines of code per area, counting only source files.""" + loc = {} + for area, prefix in AREAS.items(): + total = 0 + for f in _git_files(prefix): + if f.suffix in CODE_SUFFIXES and f.is_file(): + total += _read(f).count("\n") + loc[area] = total + return loc + + +def measure_comments(): + """Comment lines and their share of each area's source. + + Crude by design — it counts `//`, `///` and `#` line comments, not block comments or + trailing ones. The absolute number is not the point; the *direction* is, and this + catches the drift the comment-diet rule exists to notice. + """ + out = {} + for area, prefix in AREAS.items(): + comment_lines = code_lines = 0 + for f in _git_files(prefix): + if f.suffix not in CODE_SUFFIXES or not f.is_file(): + continue + for line in _read(f).splitlines(): + s = line.strip() + if not s: + continue + code_lines += 1 + if s.startswith("//") or (f.suffix == ".py" and s.startswith("#")): + comment_lines += 1 + out[area] = { + "lines": comment_lines, + "ratio": round(comment_lines / code_lines, 3) if code_lines else 0.0, + } + return out + + +def measure_flash(): + """Built firmware size per variant, in bytes. + + Only variants present in build/ are reported. A variant that was not built this run + carries its previous number forward (see merge_carry_forward) rather than vanishing: + a docs-only commit genuinely did not change any firmware size. + """ + flash = {} + build = ROOT / "build" + if not build.exists(): + return flash + for d in sorted(build.glob("esp32-*")): + binary = d / "projectMM.bin" + if binary.exists(): + flash[d.name.replace("esp32-", "", 1)] = binary.stat().st_size + desktop = ROOT / "build" / "projectMM" + if desktop.exists(): + flash["desktop"] = desktop.stat().st_size + return flash + + +def measure_docs(): + """Documentation inventory — the counts the docs-bloat conversation actually turns on.""" + md = [f for f in _git_files("docs") if f.suffix == ".md"] + plans = [f for f in md if "history/plans" in f.as_posix()] + lessons = ROOT / "docs" / "history" / "lessons.md" + claude = ROOT / "CLAUDE.md" + backlog = [f for f in md if "backlog" in f.as_posix()] + return { + "md_files": len(md), + "md_lines": sum(_read(f).count("\n") for f in md if f.is_file()), + "plans_files": len(plans), + "backlog_lines": sum(_read(f).count("\n") for f in backlog if f.is_file()), + "lessons_lines": _read(lessons).count("\n") if lessons.exists() else 0, + "claude_md_lines": _read(claude).count("\n") if claude.exists() else 0, + } + + +def measure_tests(): + """Test counts. `cases` comes from the test sources rather than a run, so this needs + no build and cannot report a stale binary's numbers.""" + cases = 0 + for f in _git_files("test"): + if f.suffix == ".cpp" and f.is_file(): + cases += _read(f).count("TEST_CASE(") + scenarios = [f for f in _git_files("test/scenarios") if f.suffix == ".json"] + return {"cases": cases, "scenarios": len(scenarios)} + + +def measure_complexity(): + """Complexity, the number lizard owns (plan § one rule, one owner). + + Deliberately the RAW count, not the baselined one: the gate (check_lizard.py) subtracts + whitelizard.txt so it fails only on new violations, but the TREND has to see the whole + number or it flatlines at 0 the moment a baseline lands and hides all future growth. + + Imported lazily and tolerantly — repo_health must stay runnable when lizard is not + installed, and a missing tool should carry the previous value forward rather than write a + misleading 0. (Returning {} lets merge_carry_forward do exactly that.) + """ + try: + sys.path.insert(0, str(Path(__file__).resolve().parent)) + import check_lizard + funcs = check_lizard.measure() + if not funcs: + return {} + viol = check_lizard.violations(funcs) + return { + "functions": len(funcs), + "over_threshold": len(viol), + "worst_ccn": max((f["ccn"] for f in funcs), default=0), + } + except Exception: + return {} + + +def _head(): + """The short SHA the measurement describes.""" + return subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, + capture_output=True, text=True).stdout.strip() + + +def snapshot(perf=None): + """The full current-state measurement. `perf` is the tick/FPS block the KPI collector + already gathered — passed in rather than re-measured, since it needs a running device.""" + head = _head() + return { + "commit": head, + "flash": measure_flash(), + "perf": perf or {}, + "loc": measure_loc(), + "comments": measure_comments(), + "tests": measure_tests(), + "docs": measure_docs(), + "complexity": measure_complexity(), + } + + +def load_previous(): + if not HEALTH_FILE.exists(): + return {} + try: + return json.loads(_read(HEALTH_FILE)) + except json.JSONDecodeError: + return {} + + +def merge_carry_forward(new, old): + """Keep the previous value for anything this run could not measure. + + A commit that did not build the P4 firmware, or ran without a bench board, should not + silently drop those numbers — the alternative is a file whose contents depend on which + targets happened to be built, which makes every diff unreadable. + """ + for key in ("flash", "perf", "complexity"): + merged = dict(old.get(key, {})) + merged.update(new.get(key, {})) + new[key] = merged + return new + + +def _flatten(d, prefix=""): + """Nested dict → {dotted.key: number}, so the delta is one flat comparison.""" + flat = {} + for k, v in d.items(): + key = f"{prefix}{k}" + if isinstance(v, dict): + flat.update(_flatten(v, f"{key}.")) + elif isinstance(v, (int, float)): + flat[key] = v + return flat + + +def format_delta(new, old): + """The lines that make growth visible at commit time. Only what moved is printed.""" + if not old: + return ["repo-health: first snapshot, no previous numbers to compare"] + new_flat, old_flat = _flatten(new), _flatten(old) + changes = [] + for key, value in sorted(new_flat.items()): + before = old_flat.get(key) + if before is None or before == value: + continue + diff = value - before + sign = "+" if diff > 0 else "" + if isinstance(value, float): + changes.append(f" {key}: {before} → {value} ({sign}{round(diff, 3)})") + else: + changes.append(f" {key}: {before} → {value} ({sign}{diff})") + if not changes: + return ["repo-health: unchanged"] + return [f"repo-health: {len(changes)} metric(s) moved"] + changes + + +def _kb(n): + return f"{round(n / 1024):,} KB" if n else "—" + + +def _pct(r): + return f"{round(r * 100, 1)} %" + + +def _arrow(new, old, key, fmt=str, lower_is_better=True): + """`value (±delta)` when the number moved, plain value when it didn't. + + The delta is the point of the table: an absolute number answers "how big", but only + the change answers "which way is this going", which is the question the ratchet asks. + """ + if old is None or key not in old or old[key] == new: + return fmt(new) + diff = new - old[key] + sign = "+" if diff > 0 else "−" + mark = "" if lower_is_better is None else (" ⚠" if (diff > 0) == lower_is_better else " ✓") + return f"{fmt(new)} ({sign}{fmt(abs(diff))}){mark}" + + +def render_markdown(new, old): + """The snapshot as a table, with units and per-metric deltas against the last commit.""" + o = old or {} + # The page lives in docs/, so the link to its generator climbs one level. Same `../` + # count GitHub's raw view uses, so the link resolves in both places. + L = [f"# Repo health", "", + f"Measured at `{new.get('commit', '?')}`. Generated by " + f"[`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every " + f"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.", ""] + + if new.get("flash"): + L += ["## Firmware size", "", "| Target | Flash |", "|---|---:|"] + for k, v in sorted(new["flash"].items()): + L.append(f"| {k} | {_arrow(v, o.get('flash'), k, _kb)} |") + L.append("") + + if new.get("perf"): + L += ["## Render performance", "", "| Target | Tick | FPS |", "|---|---:|---:|"] + for k, v in sorted(new["perf"].items()): + prev = (o.get("perf") or {}).get(k, {}) + tick = _arrow(v.get("tick_us", 0), prev, "tick_us", lambda n: f"{n:,} µs") + fps = _arrow(v.get("fps") or 0, prev, "fps", lambda n: f"{n:,}", lower_is_better=False) + L.append(f"| {k} | {tick} | {fps} |") + L.append("") + + L += ["## Code", "", "| Area | Lines | Comments | Comment share |", "|---|---:|---:|---:|"] + for area in new.get("loc", {}): + c = new.get("comments", {}).get(area, {}) + oc = (o.get("comments") or {}).get(area) + L.append( + f"| {area} " + f"| {_arrow(new['loc'][area], o.get('loc'), area, lambda n: f'{n:,}')} " + f"| {c.get('lines', 0):,} " + f"| {_arrow(c.get('ratio', 0), oc, 'ratio', _pct)} |") + L.append("") + + t, ot = new.get("tests", {}), o.get("tests") + L += ["## Tests", "", "| Kind | Count |", "|---|---:|", + f"| unit cases | {_arrow(t.get('cases', 0), ot, 'cases', lambda n: f'{n:,}', lower_is_better=False)} |", + f"| scenarios | {_arrow(t.get('scenarios', 0), ot, 'scenarios', str, lower_is_better=False)} |", + ""] + + cx, ocx = new.get("complexity", {}), o.get("complexity") + if cx: + # `functions` rising is neutral-to-good (the codebase grows); the two that matter are + # how many are over threshold and how bad the worst one is. + L += ["## Complexity", "", "| Metric | Value |", "|---|---:|", + f"| functions | {_arrow(cx.get('functions', 0), ocx, 'functions', lambda n: f'{n:,}', lower_is_better=False)} |", + f"| over threshold | {_arrow(cx.get('over_threshold', 0), ocx, 'over_threshold', str)} |", + f"| worst CCN | {_arrow(cx.get('worst_ccn', 0), ocx, 'worst_ccn', str)} |", + ""] + + d, od = new.get("docs", {}), o.get("docs") + L += ["## Documentation", "", "| Metric | Value |", "|---|---:|", + f"| markdown files | {_arrow(d.get('md_files', 0), od, 'md_files', str)} |", + f"| markdown lines | {_arrow(d.get('md_lines', 0), od, 'md_lines', lambda n: f'{n:,}')} |", + f"| plan files | {_arrow(d.get('plans_files', 0), od, 'plans_files', str)} |", + f"| backlog lines | {_arrow(d.get('backlog_lines', 0), od, 'backlog_lines', lambda n: f'{n:,}')} |", + f"| lessons lines | {_arrow(d.get('lessons_lines', 0), od, 'lessons_lines', str)} |", + f"| CLAUDE.md lines | {_arrow(d.get('claude_md_lines', 0), od, 'claude_md_lines', str)} |", + ""] + return "\n".join(L) + + +def write(perf=None, quiet=False): + """Measure, print the delta, and rewrite both views. Called by the KPI gate.""" + old = load_previous() + new = merge_carry_forward(snapshot(perf), old) + if not quiet: + for line in format_delta(new, old): + print(line) + HEALTH_FILE.write_text(json.dumps(new, indent=2) + "\n", encoding="utf-8") + HEALTH_MD.write_text(render_markdown(new, old) + "\n", encoding="utf-8") + return new + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--write", action="store_true", + help="Rewrite repo-health.json. Without it, measure and print only.") + args = parser.parse_args() + + old = load_previous() + new = merge_carry_forward(snapshot(), old) + for line in format_delta(new, old): + print(line) + if args.write: + HEALTH_FILE.write_text(json.dumps(new, indent=2) + "\n", encoding="utf-8") + HEALTH_MD.write_text(render_markdown(new, old) + "\n", encoding="utf-8") + print(f"\nwrote {HEALTH_FILE.name} + {HEALTH_MD.name}") + else: + print("\n" + render_markdown(new, old)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/moondeck/diag/preview_health.py b/moondeck/diag/preview_health.py index b67c85e1..839a49f6 100644 --- a/moondeck/diag/preview_health.py +++ b/moondeck/diag/preview_health.py @@ -9,20 +9,20 @@ this tool. What it replicates from the real client (src/ui/app.js connectWs): - - reads binary frames (0x03 coord table, 0x02 RGB colour) — see src/light/drivers/PreviewDriver.h; + - reads binary frames (0x03 coord table, 0x02 RGB color) — see src/light/drivers/PreviewDriver.h; - sends a "ping" TEXT frame every 25 s (app.js heartbeat — Safari reaps idle sockets otherwise); - AUTO-RECONNECTS on close with 500ms→5s backoff (app.js ws.onclose → connectWs). The browser never gives up, so a momentary device-side close shows as a brief visual blip, not a frozen preview. A probe that quits on close measures something no user experiences. What it reports (the honest "what does the user see" metrics): - - colour frames + sustained fps over the window; + - color frames + sustained fps over the window; - reconnects: each one is a blip the user might notice (a ~0.5 s gap as the browser re-handshakes); - - maxgap: the longest stretch with NO colour frame — the real "did it freeze?" number. A browser + - maxgap: the longest stretch with NO color frame — the real "did it freeze?" number. A browser that reconnects in 500 ms has a ~0.5 s gap (looks continuous); a multi-second maxgap is a visible freeze the reconnect could not hide. - verdict: SMOOTH (no long gaps, frames flowing) / CHOPPY (frames but long gaps or reconnects) / - DEAD (no colour frames at all). + DEAD (no color frames at all). Stdlib-only (socket/base64/os/struct/time/json/urllib), like the scenario helpers — runs anywhere uv runs, no third-party deps. Devices come from an explicit ip[:port] argument, or (no arg) every online @@ -142,9 +142,9 @@ def measure(host, seconds, grid): def _measure_loop(host, seconds, grid): t0 = time.monotonic() deadline = t0 + seconds - colour = coord = reconnects = 0 + color = coord = reconnects = 0 pts = None - last_colour = 0.0 + last_color = 0.0 maxgap = 0.0 backoff = 0.5 @@ -170,10 +170,10 @@ def _measure_loop(host, seconds, grid): continue # text (state JSON) / ping / pong — skip t = time.monotonic() if payload[0] == 0x02: - if last_colour and t - last_colour > maxgap: - maxgap = t - last_colour - last_colour = t - colour += 1 + if last_color and t - last_color > maxgap: + maxgap = t - last_color + last_color = t + color += 1 elif payload[0] == 0x03: coord += 1 if len(payload) >= 5: @@ -189,12 +189,12 @@ def _measure_loop(host, seconds, grid): elapsed = time.monotonic() - t0 reconnects -= 1 # the first connect is not a "re"-connect - if last_colour: # a trailing no-colour stretch is a freeze at the end - tail = time.monotonic() - last_colour + if last_color: # a trailing no-color stretch is a freeze at the end + tail = time.monotonic() - last_color maxgap = max(maxgap, tail) - fps = colour / elapsed if elapsed else 0 - verdict = "SMOOTH" if (maxgap < 1.5 and colour > elapsed) else ("CHOPPY" if colour else "DEAD") - print(f" [{host} grid={grid or '?'}] {colour}f over {elapsed:.0f}s ({fps:.1f}fps), " + fps = color / elapsed if elapsed else 0 + verdict = "SMOOTH" if (maxgap < 1.5 and color > elapsed) else ("CHOPPY" if color else "DEAD") + print(f" [{host} grid={grid or '?'}] {color}f over {elapsed:.0f}s ({fps:.1f}fps), " f"{coord} coord (pts={pts}), {reconnects} reconnect(s), maxgap={maxgap:.1f}s -> {verdict}") return verdict diff --git a/moondeck/docs/gen_api.py b/moondeck/docs/gen_api.py index 7d11a85c..50a09994 100644 --- a/moondeck/docs/gen_api.py +++ b/moondeck/docs/gen_api.py @@ -334,7 +334,7 @@ def repl(m: re.Match) -> str: def _highlight_signature_names(md: str) -> str: """Wrap the declared member NAME in each generated signature so the theme can highlight it while the type/args stay muted (CSS: `.mm-sig-name`). moxygen emits - a flat `<code>` string with no internal markup, so 'colour only the name' can't + a flat `<code>` string with no internal markup, so 'color only the name' can't be done in CSS alone — we split the code span here into `<code>…<span class="mm-sig-name">name</span>…</code>` (raw HTML the markdown passes through). The signature reads as one code chip; only the identifier pops.""" diff --git a/moondeck/docs/mkdocs_hooks.py b/moondeck/docs/mkdocs_hooks.py index e2d53914..e668dea6 100644 --- a/moondeck/docs/mkdocs_hooks.py +++ b/moondeck/docs/mkdocs_hooks.py @@ -234,7 +234,7 @@ def _emit_row(b: dict, details_names: set) -> str: if name in details_names: links.append(f"[⌄ details](#{_slug(name + ' — details')})") # Wrap so the plain attribution text greys (.mm-links); the actual links keep - # their accent link colour (Material's `a` styling wins over the grey). + # their accent link color (Material's `a` styling wins over the grey). col4 = f'<span class="mm-links">{_cell("<br>".join(links))}</span>' if links else "—" return f"| {col1} | {col2} | {col3} | {col4} |" @@ -357,6 +357,62 @@ def _add(src_uri: str, content: str): return files +# Pages that embed a file living at the REPO ROOT via a pymdownx snippet. Their +# borrowed text spells doc links `docs/architecture.md` — correct from the root (that +# is where CLAUDE.md is read by agents and on GitHub), but one level too deep once the +# text is rendered from inside docs/, where it resolves to docs/docs/… and 404s. +_EMBEDS_REPO_ROOT_FILE = {"principles-and-process.md"} + +# `href="docs/<rest>"` → `href="<rest>"`, and the same for the `.md` → `.html` form MkDocs +# has already applied by this stage. Only the `docs/` prefix is dropped; anchor and path +# ride along untouched. +_DOCS_PREFIXED_HREF_RE = re.compile(r'href="docs/([^"#]+?)(\.md)?(#[^"]*)?"') + +# 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) + + +def _rebase_repo_root_doc_links(html): + """Drop the leading `docs/` from links in content embedded from the repo root. + + Runs on the rendered HTML, not the source markdown: pymdownx expands a `--8<--` + snippet during markdown *conversion*, so at on_page_markdown time this page is still + just the one-line directive and there is nothing to rewrite yet. + + The alternative — rewriting CLAUDE.md's own links to be docs-relative — would break + them everywhere they are actually used (an agent reading the file, GitHub's own + rendering) to satisfy one embedded copy. Rebasing at build time keeps the source + correct at the root and correct on the site. + + `.md` becomes `.html` here too. MkDocs normally does that itself, but only for links + whose target it resolved during validation — and these did not resolve, precisely + because of the `docs/` prefix this function strips. So they arrive still pointing at + the source file and would 404 on the built site. + """ + 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) + + +def on_page_content(html, page, config, files): + """Post-conversion fixups for a page that embeds a repo-root file: rebase its doc + links, and replace the embedded `# <filename>` heading with the page's own title. + + The embedded file opens with a heading that names it as a file (`# CLAUDE.md`) — + right at the repo root and on GitHub, wrong in a docs menu, where the reader wants + the subject. Front matter `title:` fixes the nav entry and the browser tab but not + the rendered H1, so it is swapped here rather than by editing the source file.""" + if page.file.src_uri not in _EMBEDS_REPO_ROOT_FILE: + return html + html = _rebase_repo_root_doc_links(html) + if page.title: + html = _FIRST_H1_RE.sub( + lambda m: f"{m.group(1)}{page.title}{m.group(3)}", html, count=1) + return html + + def on_page_markdown(markdown, page, config, files): """Repoint out-of-docs source links to GitHub blob URLs, then (on the catalog pages) render the prose ### blocks as a MoonLight-style 4-column table. Source diff --git a/moondeck/event/_gates.py b/moondeck/event/_gates.py new file mode 100644 index 00000000..b5144daa --- /dev/null +++ b/moondeck/event/_gates.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Shared gate runner for the lifecycle event scripts (precommit / premerge / prerelease). + +A **gate** is one check with an objective trigger: it runs only when the change makes it +applicable, and reports PASS / FAIL / SKIP / MANUAL. The event scripts declare *which* +gates and *what triggers them*; everything about running them, deciding applicability from +the changed-file set, and printing the report lives here (one mechanism, three callers). + +Outcome vocabulary, printed and returned: + PASS the check ran and succeeded + FAIL the check ran and failed (the event script exits non-zero) + SKIP the trigger did not match this change, so the check does not apply + MANUAL the check cannot be automated (hardware, a human decision); listed for the + product owner to confirm, never auto-failed + +The changed-file set comes from git and is the single input every trigger reads, so a +trigger is a pure function of the diff rather than a guess. +""" + +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent.parent + +PASS = "PASS" +FAIL = "FAIL" +SKIP = "SKIP" +MANUAL = "MANUAL" + +# ANSI colors, disabled when the output is not a terminal (CI logs, pipes). +_TTY = sys.stdout.isatty() +_C = { + PASS: "\033[32m" if _TTY else "", + FAIL: "\033[31m" if _TTY else "", + SKIP: "\033[90m" if _TTY else "", + MANUAL: "\033[33m" if _TTY else "", +} +_RESET = "\033[0m" if _TTY else "" + + +def changed_files(base=None): + """The paths this event covers, as repo-relative POSIX strings. + + Without `base`: the working tree + index against HEAD, i.e. "what would this commit + contain" — the right question for the commit event. With `base` (e.g. "main"): every + file the branch touches, via the merge-base, which is what the merge and release + events ask about. + """ + if base: + cmd = ["git", "diff", "--name-only", f"{base}...HEAD"] + else: + # Staged + unstaged + untracked: the pre-commit gates run before `git add`, so + # an unstaged edit must still trigger its gate. + cmd = ["git", "status", "--porcelain"] + + out = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True).stdout + if base: + return [line.strip() for line in out.splitlines() if line.strip()] + # Porcelain lines are "XY path" (or "XY old -> new" for a rename; take the new path). + paths = [] + for line in out.splitlines(): + if len(line) < 4: + continue + path = line[3:].strip().strip('"') + if " -> " in path: + path = path.split(" -> ", 1)[1].strip().strip('"') + paths.append(path) + return paths + + +def touches(files, *prefixes, exclude=()): + """True when any changed file starts with one of `prefixes` and none of `exclude`. + + The one predicate every trigger is written in, so a trigger reads as the rule it + encodes: `touches(files, "src/", exclude=("src/platform/desktop/",))`. + """ + for f in files: + if any(f.startswith(e) for e in exclude): + continue + if any(f.startswith(p) for p in prefixes): + return True + return False + + +class Gate: + """One check: a name, a trigger, and how to run it. + + `applies` is a callable taking the changed-file list and returning a bool — or None + for a gate that always runs. `command` is the argv to execute; a gate with no command + is MANUAL (a human confirms it). + """ + + def __init__(self, name, command=None, applies=None, manual_hint=None): + self.name = name + self.command = command + self.applies = applies + self.manual_hint = manual_hint + + +UV = ["uv", "run"] + +# What "this change could affect the desktop binary" means, and what it means for an +# ESP32 image. Named once here because every event script asks the same two questions; +# re-typing the tuples per script is how the lists drift apart. +COMPILES_DESKTOP = ("src/", "test/", "CMakeLists.txt", "library.json") +COMPILES_ESP32 = ("src/", "esp32/", "CMakeLists.txt", "library.json") +# The desktop-only platform never reaches an ESP32 image. +_NOT_ESP32 = ("src/platform/desktop/",) + + +def mechanical_gates(firmware, esp32="freshness", triggered=True): + """The checks every lifecycle event runs, in order. + + One definition, three callers: the commit, merge and release lists differ only in how + they treat the ESP32 firmware and whether triggers apply, so those are parameters + rather than a reason to re-declare the whole list per script. + + `esp32`: "freshness" checks the binary is newer than its sources (a tenth of a second); + "build" compiles for real (minutes, right before a release); "none" omits it. + `triggered`: False makes every gate unconditional — the release event validates the + tagged tree as a whole, where "nothing changed in src/" is not a reason to skip. + """ + def when(*prefixes, exclude=()): + return None if not triggered else (lambda f: touches(f, *prefixes, exclude=exclude)) + + gates = [ + Gate("spec check", UV + ["moondeck/check/check_specs.py"]), + Gate("desktop build (zero warnings)", ["cmake", "--build", "build"], + when(*COMPILES_DESKTOP)), + Gate("unit tests", ["ctest", "--test-dir", "build", "--output-on-failure"], + when(*COMPILES_DESKTOP)), + # Scenarios also re-run when only a scenario JSON changed. + Gate("scenario tests", UV + ["moondeck/scenario/run_scenario.py"], + when(*COMPILES_DESKTOP, "test/scenarios/")), + Gate("platform boundary", UV + ["moondeck/check/check_platform_boundary.py"], + when("src/", exclude=("src/platform/",))), + # A lint, not a proof: it sees allocation/blocking written directly in a hot + # method, not what a callee does. A finding is a question to answer. + Gate("hot-path discipline", UV + ["moondeck/check/check_hotpath.py"], + when("src/")), + ] + + if esp32 == "build": + gates.append(Gate(f"ESP32 build ({firmware})", + UV + ["moondeck/build/build_esp32.py", "--firmware", firmware], + when(*COMPILES_ESP32, exclude=_NOT_ESP32))) + elif esp32 == "freshness": + gates.append(Gate(f"ESP32 firmware up to date ({firmware})", + UV + ["moondeck/check/check_esp32_built.py", "--firmware", firmware], + when(*COMPILES_ESP32, exclude=_NOT_ESP32))) + return gates + + +def run_gates(gates, files, title, next_step=""): + """Run every applicable gate, print the report, and return the exit code. + + Gates run in declaration order and do NOT stop at the first failure: the product + owner wants the whole picture in one pass, not a bisect-by-rerun. + + `next_step` is what the caller wants said once everything is green (e.g. "waiting for + commit now"). It rides inside the closing DONE block so the end marker is genuinely the + last thing printed — a reader scrolling to the bottom sees the verdict, not a trailing + remark after it. + """ + print(f"\n{title}") + print(f"{len(files)} changed file(s)\n") + + results = [] + for gate in gates: + # The trigger decides first, for manual gates too: a human check that does not + # apply to this change (an Improv smoke test on a diff that touches no + # provisioning code) should drop out of the report rather than be listed as + # something to confirm. Without this the event scripts had to filter their own + # manual gates by display name, which duplicated the mechanism and broke on a + # label rename. + if gate.applies is not None and not gate.applies(files): + if gate.command is None: + continue # a manual gate that does not apply is simply not mentioned + results.append((gate.name, SKIP, "trigger did not match")) + print(f" {_C[SKIP]}{SKIP:<6}{_RESET} {gate.name}") + continue + + if gate.command is None: + results.append((gate.name, MANUAL, gate.manual_hint or "")) + print(f" {_C[MANUAL]}{MANUAL:<6}{_RESET} {gate.name}" + f"{' — ' + gate.manual_hint if gate.manual_hint else ''}") + continue + + # Show what is running (a firmware build takes minutes of silence), then overwrite + # that line with the verdict. \033[K clears to end-of-line so the longer "running" + # text cannot leave a tail behind the shorter result line. Terminal only: piped + # output (MoonDeck's pane, CI logs) has no cursor to move, so the placeholder would + # stack up as a duplicate line above every result instead of being replaced. + if _TTY: + print(f" ... {gate.name}", end="\r", flush=True) + started = time.time() + proc = subprocess.run(gate.command, cwd=ROOT, capture_output=True, text=True) + elapsed = time.time() - started + status = PASS if proc.returncode == 0 else FAIL + detail = "" if status == PASS else (proc.stdout + proc.stderr) + results.append((gate.name, status, detail)) + clear = "\033[K" if _TTY else "" + print(f" {_C[status]}{status:<6}{_RESET} {gate.name} ({elapsed:.1f}s){clear}") + + failed = [r for r in results if r[1] == FAIL] + for name, _, detail in failed: + print(f"\n--- {name} output ---") + # The tail is where a build/test failure states its reason; the head is setup noise. + tail = detail.strip().splitlines()[-40:] + print("\n".join(tail)) + + counts = {s: sum(1 for r in results if r[1] == s) for s in (PASS, FAIL, SKIP, MANUAL)} + print(f"\n{counts[PASS]} passed, {counts[FAIL]} failed, " + f"{counts[SKIP]} skipped, {counts[MANUAL]} manual") + + manual = [r for r in results if r[1] == MANUAL] + if manual: + print("\nManual gates — the product owner confirms these:") + for name, _, hint in manual: + print(f" - {name}{': ' + hint if hint else ''}") + + # An explicit end marker. Without it a reader watching a long run cannot tell "still + # working on a silent gate" from "finished" — the gates print nothing while a firmware + # build runs, so silence is ambiguous right up until the process exits. + if failed: + print("\nA failing gate blocks the event. Fix it, or skip it deliberately with a " + "one-line reason in the commit body / PR description / release notes.") + print(f"\n=== {title}: DONE — {counts[FAIL]} FAILED ===") + return 1 + + if next_step: + print(f"\n{next_step}") + print(f"\n=== {title}: DONE — all gates green ===") + return 0 diff --git a/moondeck/event/precommit.py b/moondeck/event/precommit.py new file mode 100644 index 00000000..3efa6434 --- /dev/null +++ b/moondeck/event/precommit.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Run the commit-event gates: "this snapshot is internally consistent". + +Usage: + uv run moondeck/event/precommit.py # every applicable gate + uv run moondeck/event/precommit.py --build-esp32 # compile the firmware for real + uv run moondeck/event/precommit.py --firmware esp32s3-n16r8 + +Each gate states its own trigger, so a docs-only change runs the spec check and nothing +else, while a `src/` change runs the full set. Product-owner initiated (see CLAUDE.md +§ The Process); this script never runs itself and never commits. +""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _gates import ( # noqa: E402 + UV, Gate, changed_files, mechanical_gates, run_gates, touches, +) + + +def build_gates(firmware, full_esp32=False): + """The commit gate list: the shared mechanical checks, plus the ones only a commit runs. + + ESP32 defaults to a freshness check rather than a compile — the binary being newer + than every source costs a tenth of a second and catches the case that matters (an + edit that was never compiled), whereas a cold `idf.py build` costs minutes, and a + gate list too slow to run is one that stops being run. `--build-esp32` forces the + real compile for the moments it is worth waiting for (after an sdkconfig or toolchain + change); CI builds every variant on every PR regardless. + """ + return mechanical_gates(firmware, esp32="build" if full_esp32 else "freshness") + [ + # --no-live-capture keeps this a few seconds instead of ~80: a live ESP32 tick + # reading opens the serial port for 15 s and needs a bench board attached, which + # makes the gate's cost unpredictable. Run collect_kpi.py without the flag when + # composing the commit message, where the fresh reading is the point. + # Triggers on everything the repo-health snapshot measures, not just `src/`: a + # moondeck/ or docs-only commit still moves lines of code, comment density and the + # docs inventory, and a snapshot that skipped those commits would drift out of + # step with the tree it claims to describe. + Gate("KPI collection", + UV + ["moondeck/check/collect_kpi.py", "--commit", "--no-live-capture"], + lambda f: touches(f, "src/", "test/", "moondeck/", "docs/", "CLAUDE.md")), + + Gate("device-model catalog", + UV + ["moondeck/check/check_devices.py"], + lambda f: touches(f, "web-installer/deviceModels.json", + "moondeck/check/check_devices.py")), + + Gate("firmware list", + UV + ["moondeck/check/check_firmwares.py"], + lambda f: touches(f, "moondeck/build/build_esp32.py", + "web-installer/firmwares.json", + "moondeck/check/check_firmwares.py")), + + # The cross-language contracts ctest cannot reach: the Improv frame wire format + # and the WLED /json shape. Deps ride in each test file's PEP-723 block. + Gate("host tests (Python)", + UV + ["--with", "pytest", "--with", "pyserial", "--with", "markdown", + "--with", "wled", "pytest", "test/python", "-q"], + lambda f: touches(f, "moondeck/", "test/python/")), + + Gate("host tests (JS)", + ["node", "--test", "test/js/**/*.test.mjs"], + lambda f: touches(f, "web-installer/", "test/js/")), + + # Needs a board plugged in, so it is recommended rather than blocking. Its trigger + # is the provisioning path it covers; run_gates drops it from the report entirely + # when the change doesn't touch that, so the manual list stays honest. + Gate("Improv smoke test", None, + lambda f: touches(f, "src/core/ImprovFrame.h", + "src/platform/esp32/platform_esp32_improv.cpp", + "web-installer/index.html", "src/ui/install-picker.js", + "moondeck/build/improv_"), + manual_hint="recommended with an ESP32 connected: " + "uv run moondeck/build/improv_smoke_test.py --port <port>"), + ] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--firmware", default="esp32s3-n16r8", + help="ESP32 variant the firmware gate checks " + "(the local gate covers one variant; CI covers all).") + parser.add_argument("--build-esp32", action="store_true", + help="Compile the ESP32 firmware instead of only checking that the " + "existing binary is newer than every source. Minutes rather " + "than a second; worth it before a release or after an " + "sdkconfig / toolchain change.") + args = parser.parse_args() + + sys.exit(run_gates(build_gates(args.firmware, full_esp32=args.build_esp32), + changed_files(), "Commit gates", + next_step="Waiting for the product owner to say \"commit now\" — " + "this script never commits.")) + + +if __name__ == "__main__": + main() diff --git a/moondeck/event/premerge.py b/moondeck/event/premerge.py new file mode 100644 index 00000000..83853b44 --- /dev/null +++ b/moondeck/event/premerge.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Run the merge-event gates: "this is now trunk". + +Usage: + uv run moondeck/event/premerge.py # against main + uv run moondeck/event/premerge.py --base other-branch + +Scope is the whole branch diff (merge-base to HEAD), because architectural drift is +visible across N commits in a way one commit hides. The judgment gates (Reviewer agent, +external review, lessons, PR description) are MANUAL by construction — an agent reports, +the product owner decides. Product-owner initiated; never runs itself, never merges. +""" + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _gates import ( # noqa: E402 + Gate, changed_files, mechanical_gates, run_gates, touches, +) + +# Code that runs in the tick path: a change here means the perf snapshot is re-measured. +_TICK_PATH = ("src/light/", "src/core/Scheduler.h", "src/core/HttpServerModule.cpp", + "src/platform/") + + +def build_gates(firmware): + """The merge gate list: the shared mechanical checks re-run over the whole branch + diff, plus the judgment gates only a human can settle. + + ESP32 stays a freshness check rather than a compile because CI already builds every + variant on the PR; what CI cannot tell you is whether the binary on your bench matches + the branch you are about to merge. + + Re-running the mechanical checks here is not redundant with the commit gates: an + individually-green commit series can still land a drifted tree (a spec renamed in + commit 3, its module edited in commit 5). + """ + return mechanical_gates(firmware, esp32="freshness") + [ + Gate("Reviewer agent over the branch diff", None, + manual_hint="start it FIRST so it runs while the rest proceed; scope: " + "boundaries, bespoke conventions, unnecessary abstractions, " + "duplication, hot path, spec conformance, bloat"), + + Gate("external review addressed", None, + manual_hint="CodeRabbit + human findings fixed or accepted with a reason"), + + Gate("lessons carried forward", None, + manual_hint="only when VERY important: a real gotcha to lessons.md, a major " + "decision to a new ADR, a hardened rule to CLAUDE.md"), + + Gate("docs sync", None, + manual_hint="every new module / control / endpoint documented; plan text " + "moved into the PR description and the plan file deleted"), + + Gate("PR title and description match the diff", None, + manual_hint="the description is the permanent record of what landed"), + + # Triggered like any other gate: run_gates drops it when the branch touches + # nothing in the tick path, so the manual list never asks for a measurement that + # cannot have changed. + Gate("performance snapshot in performance.md", None, + lambda f: touches(f, *_TICK_PATH), + manual_hint="tick-path code changed on this branch — compare tick/FPS to the " + "previous committed values and explain significant changes"), + + Gate("permission review", None, + manual_hint="prune .claude/settings.local.json, then snapshot the approved " + "list to .claude/settings.local.cleaned.json and commit it; never " + "broaden destructive or network-mutating permissions"), + + Gate("README / quick-start refresh", None, + manual_hint="only if build, flash, or first-run UX changed"), + ] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--base", default="main", + help="Branch to diff against (default: main).") + parser.add_argument("--firmware", default="esp32s3-n16r8", + help="ESP32 variant the firmware-freshness gate checks.") + args = parser.parse_args() + + sys.exit(run_gates(build_gates(args.firmware), changed_files(base=args.base), + f"Merge gates (branch diff vs {args.base})", + next_step="The manual gates above are the product owner's call — " + "this script never merges.")) + + +if __name__ == "__main__": + main() diff --git a/moondeck/event/prerelease.py b/moondeck/event/prerelease.py new file mode 100644 index 00000000..fa5100bd --- /dev/null +++ b/moondeck/event/prerelease.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Run the release-event gates: "end users will use this". + +Usage: + uv run moondeck/event/prerelease.py # against the previous tag + uv run moondeck/event/prerelease.py --base v3.0.0 + +The release envelope is mostly human judgment — real hardware, release criteria, known +bugs — so most gates here are MANUAL by design. What IS automated is the mechanical +readiness of the tagged tree. Product-owner initiated; never runs itself, never tags. +""" + +import argparse +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from _gates import ( # noqa: E402 + ROOT, UV, Gate, changed_files, mechanical_gates, run_gates, +) + + +def previous_tag(): + """The most recent tag, the natural diff base for a release. Empty when none exists.""" + out = subprocess.run(["git", "describe", "--tags", "--abbrev=0"], + cwd=ROOT, capture_output=True, text=True) + return out.stdout.strip() if out.returncode == 0 else "" + + +def build_gates(firmware): + """The release gate list. + + Two differences from the other events. The ESP32 firmware is **compiled**, not just + checked for freshness: this is the one event where the binary itself ships, so minutes + of build time are cheap against tagging a release whose firmware was never compiled + from the tagged tree. And the mechanical checks are **untriggered** — a release + validates the tagged tree as a whole, where "nothing changed under src/ since the last + tag" is not a reason to skip the tests. + """ + return mechanical_gates(firmware, esp32="build", triggered=False) + [ + Gate("device-model catalog", UV + ["moondeck/check/check_devices.py"]), + Gate("firmware list", UV + ["moondeck/check/check_firmwares.py"]), + + Gate("all merge gates passed on the tagged commit", None, + manual_hint="every PR merged into this tag cleared its own gates"), + + Gate("real-hardware test", None, + manual_hint="PRODUCT OWNER ONLY: at minimum one ESP32, plus every other " + "target this release claims to support"), + + Gate("no known release-blockers", None, + manual_hint="open issues reviewed; anything flagged blocking is closed or " + "downgraded"), + + Gate("per-release criteria done", None, + manual_hint="every criterion the product owner set for this tag"), + + Gate("release notes drafted", None, + manual_hint="in the GitHub release body; skip only for a pre-1.0 unreleased tag"), + + Gate("cross-platform smoke", None, + manual_hint="scenarios on every supported platform — required when the " + "release claims new platform support or bumps major/minor"), + + Gate("principles audit", None, + manual_hint="sweep docs/ (except backlog, history, adr) and src/ for " + "forward-looking language and principle violations; the Reviewer " + "agent can run this end-to-end"), + ] + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--base", default=None, + help="Tag or ref to diff against (default: the previous tag).") + parser.add_argument("--firmware", default="esp32s3-n16r8", + help="ESP32 variant to build (CI builds every shipped variant).") + args = parser.parse_args() + + base = args.base or previous_tag() + files = changed_files(base=base) if base else [] + label = f"since {base}" if base else "no previous tag" + + sys.exit(run_gates(build_gates(args.firmware), files, f"Release gates ({label})", + next_step="The manual gates above are the product owner's call — " + "this script never tags.")) + + +if __name__ == "__main__": + main() diff --git a/moondeck/moondeck.py b/moondeck/moondeck.py index 133f0c5d..4e1ac195 100644 --- a/moondeck/moondeck.py +++ b/moondeck/moondeck.py @@ -972,6 +972,43 @@ def _port_serial(path: str) -> str: return m.group(1) if m else "" +def _heal_last_ports(network: dict | None) -> None: + """Correct `last_port` breadcrumbs that the currently-present ports disprove. + + `last_port` is a flash-time breadcrumb, and macOS renumbers `/dev/cu.usbserial-*` + paths as adapters come and go — so a record can end up naming a port that no longer + exists while the board sits on a different one. The Live tab then shows a port the + ESP32 tab knows is wrong, which is exactly the kind of quietly-stale state that sends + a flash at the wrong board. + + Two repairs, both driven by `usbSerial` (the ADAPTER's own serial, embedded in the port + name and immune to path drift), never by the path: + + 1. the adapter is present on a different path → move `last_port` to that path; + 2. the adapter is absent and the recorded path is gone → drop `last_port`, so the UI + shows no port rather than a fictional one. + + A record with no `usbSerial` is left alone: without the drift-immune key there is + nothing to prove the breadcrumb wrong, and guessing would be worse than stale. + """ + if not network: + return + present = {p: _port_serial(p) for p in list_serial_ports()} + by_serial = {s: p for p, s in present.items() if s} + + for dev in network.get("devices", []): + serial = dev.get("usbSerial") + if not serial: + continue + actual = by_serial.get(serial) + recorded = dev.get("last_port") + if actual: + if recorded != actual: + dev["last_port"] = actual + elif recorded and recorded not in present: + dev.pop("last_port", None) + + def _resolve_port(path: str, usb: dict, devices: list) -> dict: """Build the {path, chip, board, ip} identity for one port. `usb` is that port's USB descriptor ({vid,pid,product,serial}) or {} if unknown; `devices` @@ -1266,8 +1303,11 @@ def do_GET(self): elif self.path == "/api/ports": # Enrich each port with chip family + specific board (levels 2/3), - # resolved against the active network's registered devices. - state = load_state() + # resolved against the active network's registered devices, and heal any + # `last_port` this listing proves wrong (see _heal_last_ports). + state = mutate_state(lambda s: _heal_last_ports( + next((n for n in s.get("networks", []) + if n.get("name") == s.get("active_network")), None))) active = next((n for n in state.get("networks", []) if n.get("name") == state.get("active_network")), None) devices = (active or {}).get("devices", []) diff --git a/moondeck/moondeck_config.json b/moondeck/moondeck_config.json index 35de98cd..c1a9796e 100644 --- a/moondeck/moondeck_config.json +++ b/moondeck/moondeck_config.json @@ -5,6 +5,7 @@ "tab": "desktop", "group": "build", "label": "Build", + "speed": "slow", "help": "build_desktop", "script": "build/build_desktop.py" }, @@ -13,6 +14,7 @@ "tab": "desktop", "group": "test", "label": "Compile Tests", + "speed": "slow", "help": "compile_tests", "script": "build/build_desktop.py", "args": ["--tests"] @@ -22,6 +24,7 @@ "tab": "desktop", "group": "test", "label": "Unit Test", + "speed": "medium", "help": "test_desktop", "script": "test/test_desktop.py", "needs_module": true @@ -31,6 +34,7 @@ "tab": "desktop", "group": "test", "label": "Scenarios", + "speed": "medium", "help": "scenario_pipeline", "script": "scenario/run_scenario.py", "needs_module": true, @@ -41,6 +45,7 @@ "tab": "desktop", "group": "run", "label": "Run", + "speed": "instant", "help": "run_desktop", "script": "run/run_desktop.py", "long_running": true, @@ -51,6 +56,7 @@ "tab": "desktop", "group": "run", "label": "Crash Log", + "speed": "instant", "help": "show_crash_log", "script": "run/show_crash_log.py" }, @@ -59,6 +65,7 @@ "tab": "desktop", "group": "run", "label": "Installer Preview", + "speed": "medium", "help": "preview_installer", "script": "run/preview_installer.py", "long_running": true, @@ -69,6 +76,7 @@ "tab": "desktop", "group": "check", "label": "Spec Check", + "speed": "instant", "help": "check_specs", "script": "check/check_specs.py" }, @@ -77,6 +85,7 @@ "tab": "desktop", "group": "check", "label": "Platform Boundary", + "speed": "instant", "help": "check_platform_boundary", "script": "check/check_platform_boundary.py" }, @@ -85,6 +94,7 @@ "tab": "desktop", "group": "check", "label": "Board Catalog", + "speed": "instant", "help": "check_devices", "script": "check/check_devices.py" }, @@ -93,6 +103,7 @@ "tab": "desktop", "group": "check", "label": "Firmware List", + "speed": "instant", "help": "check_firmwares", "script": "check/check_firmwares.py" }, @@ -101,22 +112,126 @@ "tab": "desktop", "group": "check", "label": "KPI Report", + "speed": "slow", "help": "collect_kpi", "script": "check/collect_kpi.py" }, + { + "id": "repo_health", + "tab": "desktop", + "group": "check", + "label": "Repo Health", + "speed": "medium", + "help": "repo_health", + "script": "check/repo_health.py" + }, { "id": "history_report", "tab": "desktop", "group": "check", "label": "History Report", + "speed": "instant", "help": "history_report", "script": "report/history_report.py" }, + { + "id": "check_hotpath", + "tab": "desktop", + "group": "check", + "label": "Hot Path", + "speed": "instant", + "help": "check_hotpath", + "script": "check/check_hotpath.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", + "group": "check", + "label": "Pre-Commit Gates", + "speed": "slow", + "help": "event_precommit", + "script": "event/precommit.py" + }, + { + "id": "event_premerge", + "tab": "desktop", + "group": "check", + "label": "Pre-Merge Gates", + "speed": "slow", + "help": "event_premerge", + "script": "event/premerge.py" + }, + { + "id": "event_prerelease", + "tab": "desktop", + "group": "check", + "label": "Pre-Release Gates", + "speed": "slow", + "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_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", "group": "docs", "label": "Install Playwright", + "speed": "slow", "help": "screenshot_modules", "script": "docs/install_playwright.py" }, @@ -125,13 +240,24 @@ "tab": "desktop", "group": "docs", "label": "Screenshot Modules", + "speed": "slow", "help": "screenshot_modules", "script": "docs/screenshot_modules.py", "long_running": true, "process_name": "screenshot_modules.py", "flags": [ - { "id": "gif", "label": "GIF", "arg": "--gif", "default": true }, - { "id": "force", "label": "Force", "arg": "--force", "default": false } + { + "id": "gif", + "label": "GIF", + "arg": "--gif", + "default": true + }, + { + "id": "force", + "label": "Force", + "arg": "--force", + "default": false + } ] }, { @@ -139,6 +265,7 @@ "tab": "desktop", "group": "docs", "label": "Update Module Docs", + "speed": "slow", "help": "update_module_docs", "script": "docs/update_module_docs.py" }, @@ -147,6 +274,7 @@ "tab": "desktop", "group": "docs", "label": "Preview Docs Site", + "speed": "slow", "help": "build_docs", "script": "docs/build_docs.py", "args": ["--serve"], @@ -158,6 +286,7 @@ "tab": "live", "group": "scenario", "label": "Live Scenarios", + "speed": "medium", "help": "live_scenario", "script": "scenario/run_live_scenario.py", "needs_device": true, @@ -169,6 +298,7 @@ "tab": "live", "group": "scenario", "label": "Network Live Test", + "speed": "medium", "help": "run_network_live", "script": "scenario/run_network_live.py" }, @@ -177,6 +307,7 @@ "tab": "live", "group": "scenario", "label": "Network Round-trip", + "speed": "medium", "help": "run_network_roundtrip", "script": "scenario/run_network_roundtrip.py" }, @@ -185,6 +316,7 @@ "tab": "live", "group": "scenario", "label": "Preview Health", + "speed": "slow", "help": "preview_health", "script": "diag/preview_health.py", "needs_device": true @@ -194,6 +326,7 @@ "tab": "esp32", "group": "setup", "label": "Setup ESP-IDF", + "speed": "slow", "help": "setup_esp_idf", "script": "build/setup_esp_idf.py" }, @@ -202,6 +335,7 @@ "tab": "esp32", "group": "setup", "label": "Clean", + "speed": "instant", "help": "clean_esp32", "script": "build/clean_esp32.py" }, @@ -210,6 +344,7 @@ "tab": "esp32", "group": "build", "label": "Build", + "speed": "slow", "help": "build_esp32", "script": "build/build_esp32.py", "needs_firmware": true @@ -219,6 +354,7 @@ "tab": "esp32", "group": "flash", "label": "Flash", + "speed": "slow", "help": "flash_esp32", "script": "build/flash_esp32.py", "needs_port": true, @@ -230,6 +366,7 @@ "tab": "esp32", "group": "flash", "label": "Erase Flash", + "speed": "slow", "help": "erase_flash_esp32", "script": "build/erase_flash_esp32.py", "needs_port": true, @@ -240,6 +377,7 @@ "tab": "esp32", "group": "run", "label": "Monitor", + "speed": "medium", "help": "monitor_esp32", "script": "run/monitor_esp32.py", "needs_port": true, @@ -251,6 +389,7 @@ "tab": "esp32", "group": "run", "label": "Improv WiFi", + "speed": "medium", "help": "improv_provision", "script": "build/improv_provision.py", "needs_port": true, @@ -261,6 +400,7 @@ "tab": "esp32", "group": "run", "label": "Improv Probe", + "speed": "instant", "help": "improv_probe", "script": "build/improv_probe.py", "needs_port": true @@ -270,6 +410,7 @@ "tab": "esp32", "group": "run", "label": "Improv Smoke Test", + "speed": "slow", "help": "improv_smoke_test", "script": "build/improv_smoke_test.py", "needs_port": true diff --git a/moondeck/moondeck_ui/app.js b/moondeck/moondeck_ui/app.js index 9bcaee83..460834ea 100644 --- a/moondeck/moondeck_ui/app.js +++ b/moondeck/moondeck_ui/app.js @@ -74,6 +74,9 @@ async function init() { state.provisionDeviceModel = state.provisionBoard; delete state.provisionBoard; } + // Restore the "online only" device filter from the persisted state (absent = show all). + const onlineOnlyInit = document.getElementById("online-only"); + if (onlineOnlyInit) onlineOnlyInit.checked = !!state.onlineOnly; const scenResp = await fetch("/api/scenarios"); const scenData = await scenResp.json(); @@ -227,6 +230,21 @@ window.addEventListener("message", (e) => { // Script cards // --------------------------------------------------------------------------- +// How long a script takes, as one emoji before the buttons — so you can tell at a glance +// whether clicking costs a second or a coffee. `speed` comes from moondeck_config.json. +// All three tiers are shown: a blank would be ambiguous between "medium" and "nobody set a +// speed on this card", and only a visible badge makes a missing one obvious. +const SPEED_BADGE = { + instant: { icon: "⚡", title: "Instant — runs in about a second" }, + medium: { icon: "⏱️", title: "Medium — a few seconds up to about 30" }, + slow: { icon: "🐌", title: "Slow — takes more than 30 seconds" }, +}; + +function speedBadge(speed) { + const b = SPEED_BADGE[speed]; + return b ? `<span class="speed-badge" title="${b.title}">${b.icon}</span>` : ""; +} + // Build a module-filter row: <select> with "all modules" + every test module, // plus a Tests button that opens the per-module unit-test list. Used both for // the shared row above a group and the per-card row inside a script-card. @@ -358,6 +376,7 @@ function renderScripts() { <div class="card-row"> <span class="status-dot" data-id="${script.id}"></span> <span class="label">${script.label}</span> + ${speedBadge(script.speed)} <button class="help-btn" title="Help">?</button> <button class="run-btn" data-id="${script.id}">Run</button> </div> @@ -722,7 +741,20 @@ async function refreshPorts() { } } -document.getElementById("refresh-ports").addEventListener("click", refreshPorts); +// Refreshing the port list usually changes nothing visible — the same ports come back — so +// without feedback the click looks like it did nothing and invites a second press. Spin the +// ↻ glyph for one turn: the affordance the icon already implies, and it confirms the action +// ran without adding a message to read. The spin is on the click handler rather than inside +// refreshPorts() because the other callers (after a flash, after a scan) are not user +// clicks and should not animate. +document.getElementById("refresh-ports").addEventListener("click", async (e) => { + const btn = e.currentTarget; + btn.classList.add("spinning"); + // Hold the spin for at least one full turn even when the fetch resolves sooner, so a + // fast refresh still reads as a deliberate action rather than a flicker. + await Promise.all([refreshPorts(), new Promise(r => setTimeout(r, 500))]); + btn.classList.remove("spinning"); +}); // Identify: probe EVERY ESP-capable port with esptool to read chip + MAC. This // is a dev bench where boards move between ports constantly, so it always does a @@ -877,8 +909,25 @@ function stopWatch(reason) { // Live tab // --------------------------------------------------------------------------- +// "Online only" — a view filter over the same device list, persisted with the rest of the +// state so the choice survives a reload. It hides nothing permanently: the records stay in +// moondeck.json and reappear the moment the box is unticked (or the device answers again). +const onlineOnlyBox = document.getElementById("online-only"); +if (onlineOnlyBox) { + onlineOnlyBox.addEventListener("change", () => { + state.onlineOnly = onlineOnlyBox.checked; + saveState(); + renderDevices(); + }); +} + +// Scan — the Live tab's single action. A subnet scan both finds new devices and settles +// online/offline for every device already registered, so it is a superset of the old +// separate "refresh known devices" step (which is why that button is gone: pressing both +// was the only way to get one complete picture). The /api/refresh endpoint stays for +// scripts and for a device on a subnet this machine isn't scanning. document.getElementById("discover-btn").addEventListener("click", async () => { - appendLog("\n--- Discovering devices ---\n"); + appendLog("\n--- Scanning for devices ---\n"); switchPane("log"); const resp = await fetch("/api/discover", { method: "POST" }); // Server attributes found devices to the matching network (or creates a @@ -898,32 +947,15 @@ document.getElementById("discover-btn").addEventListener("click", async () => { renderNetworkBar(); renderDevices(); refreshPorts(); + // A scan can re-attribute last_port (it consumes the flash breadcrumb and can strip a + // stale link), so the "last flashed: X" hint under the port dropdown is restated too. + updatePortDeviceHint(getActiveNetwork()?.port || ""); const active = getActiveNetwork(); const count = (active && active.devices) ? active.devices.length : 0; const onCount = active ? active.devices.filter(d => d.online).length : 0; appendLog(`Active network "${state.active_network}": ${onCount}/${count} online\n`); }); -document.getElementById("refresh-devices-btn")?.addEventListener("click", async () => { - const active = getActiveNetwork(); - if (!active || !active.devices || active.devices.length === 0) return; - appendLog(`\n--- Refreshing network "${active.name}" ---\n`); - const resp = await fetch("/api/refresh", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ network: active.name }), - }); - state = await resp.json(); - renderNetworkBar(); - renderDevices(); - // /api/refresh can re-attribute last_port (it consumes the flash breadcrumb + can strip a - // stale link), so refresh the "last flashed: X" hint under the port dropdown too. - updatePortDeviceHint(getActiveNetwork()?.port || ""); - const after = getActiveNetwork(); - const onCount = after ? after.devices.filter(d => d.online).length : 0; - appendLog(`${onCount}/${after ? after.devices.length : 0} online\n`); -}); - // Network bar — rebuilds the dropdown options from state.networks and // syncs the WiFi panel to the active network's credentials. Called from // init and after any state mutation that touches networks (add, rename, @@ -1033,12 +1065,25 @@ function setupNetworkBar() { function renderDevices() { const el = document.getElementById("device-list"); const active = getActiveNetwork(); - const devices = (active && active.devices) || []; - if (devices.length === 0) { + const allDevices = (active && active.devices) || []; + if (allDevices.length === 0) { el.textContent = "No devices discovered yet."; return; } + // "Online only" hides what didn't answer the last probe. `online !== false` is the same + // test the status dot uses: a device discovered but never probed has no `online` field, + // and treating that as offline would hide a board that may well be up. + const devices = state.onlineOnly + ? allDevices.filter(d => d.online !== false) + : allDevices; + const hidden = allDevices.length - devices.length; el.innerHTML = ""; + if (devices.length === 0) { + // Say why the list is empty. Without this, filtering everything out looks identical + // to "discovery found nothing" — and the fix (untick the box) isn't discoverable. + el.textContent = `No online devices (${hidden} offline hidden).`; + return; + } for (const device of devices) { // A plain <div>, NOT a <label>: a <label> wrapper toggles its checkbox on a click // ANYWHERE inside it (native behaviour), so clicking the IP/name to open the device @@ -1087,6 +1132,22 @@ function renderDevices() { ipLink.title = "Open this device's UI in the view pane"; ipLink.addEventListener("click", () => showInView("http://" + device.ip)); infoText.appendChild(ipLink); + + // ↗ — the same device UI in a real browser tab, next to (not instead of) the view + // pane. The pane keeps MoonDeck's context; a tab gives the device its own window, + // devtools, and a bookmarkable URL. An <a target="_blank"> rather than a button + // calling window.open, so the browser's own affordances work: middle-click, + // cmd-click, "Open in new window", copy-link-address. rel="noopener" because a + // target="_blank" link otherwise hands the opened page a window.opener handle + // back into MoonDeck. + const ipNewTab = document.createElement("a"); + ipNewTab.className = "device-ip-newtab"; + ipNewTab.href = "http://" + device.ip; + ipNewTab.target = "_blank"; + ipNewTab.rel = "noopener noreferrer"; + ipNewTab.textContent = "↗"; + ipNewTab.title = "Open this device's UI in a new browser tab"; + infoText.appendChild(ipNewTab); if (device.firmware) { infoText.appendChild(document.createTextNode(` · fw:${device.firmware}`)); } @@ -1285,6 +1346,15 @@ function renderDevices() { label.appendChild(row3); el.appendChild(label); } + + // Account for what the filter removed, so a short list is never mistaken for the + // whole registry — the count is the reminder that more devices exist. + if (hidden > 0) { + const note = document.createElement("div"); + note.className = "device-filter-note"; + note.textContent = `${hidden} offline device${hidden === 1 ? "" : "s"} hidden`; + el.appendChild(note); + } } // --------------------------------------------------------------------------- diff --git a/moondeck/moondeck_ui/index.html b/moondeck/moondeck_ui/index.html index 91b5d3d2..76c33bc0 100644 --- a/moondeck/moondeck_ui/index.html +++ b/moondeck/moondeck_ui/index.html @@ -60,7 +60,9 @@ <h1>MoonDeck</h1> <div class="esp32-controls"> <div class="port-header"> <span>Port:</span> - <button type="button" id="refresh-ports" title="Refresh">↻</button> + <!-- The glyph is wrapped so the spin-on-refresh animation rotates the + ↻ alone; rotating the button would tilt its border into a diamond. --> + <button type="button" id="refresh-ports" title="Refresh"><span class="refresh-glyph">↻</span></button> <!-- Probes ports still shown as path-only (no board match) with esptool to read chip + MAC. RESETS each probed board, so it's opt-in, never automatic; results cache to moondeck.json so it's a one-time cost. --> @@ -81,9 +83,23 @@ <h1>MoonDeck</h1> </section> <section id="tab-live" class="tab-content"> + <!-- One button, because the subnet scan is a superset of a known-device + re-probe: it finds new boards AND settles online/offline for every + device already in the registry. Two buttons meant pressing both every + time to get one complete picture. --> + <!-- Scan and its filter share one row: the filter changes what that scan's + result shows, so they read as one control group rather than the filter + looking like a property of the device list below it. + "Online only" hides devices that didn't answer the last probe — the bench + registry accumulates boards that are unplugged or powered down, and they + push the handful actually reachable off the visible list. The setting + persists (moondeck.json), so it survives a reload. --> <div class="live-controls"> - <button id="discover-btn">Discover</button> - <button id="refresh-devices-btn">Refresh</button> + <button id="discover-btn" title="Scan the subnet: find new devices and update online/offline for known ones">Scan</button> + <label class="device-filter" title="Hide devices that did not respond to the last scan"> + <input type="checkbox" id="online-only"> + <span>Online only</span> + </label> </div> <div id="device-list"></div> <div id="scripts-live" class="script-grid"></div> diff --git a/moondeck/moondeck_ui/style.css b/moondeck/moondeck_ui/style.css index c9611f17..cf19ef2b 100644 --- a/moondeck/moondeck_ui/style.css +++ b/moondeck/moondeck_ui/style.css @@ -182,6 +182,20 @@ select { font-weight: 500; } +/* Duration hint (⚡ / 🐌). Sits between the label and the buttons; slightly dimmed so it + reads as metadata rather than competing with the status dot. */ +/* Every card carries one, so these form a column — a fixed width keeps them aligned rather + than letting each emoji's natural advance shift the buttons (⏱️ is wider than ⚡). */ +.speed-badge { + flex-shrink: 0; + width: 16px; + text-align: center; + font-size: 11px; + line-height: 1; + opacity: 0.75; + cursor: default; +} + .script-card .help-btn { background: transparent; border: none; @@ -265,7 +279,12 @@ select { } /* Live tab */ -.live-controls { margin-bottom: 12px; } +/* Scan + its "Online only" filter on one row: the action and what it displays belong + together. Baseline-aligned so the checkbox label sits level with the button text. */ +.live-controls { + display: flex; align-items: center; gap: 12px; + margin-bottom: 12px; +} #device-list { font-size: 12px; @@ -342,6 +361,27 @@ select { .device-ip-link { cursor: pointer; } .device-ip-link:hover { color: #8ab4f8; text-decoration: underline; } +/* ↗ beside the IP — the same device UI in a real browser tab. Muted by default so it + reads as a secondary affordance next to the IP, not a second primary action. */ +.device-ip-newtab { + color: #7a8a9a; text-decoration: none; cursor: pointer; + margin-left: 4px; font-size: 11px; +} +.device-ip-newtab:hover { color: #8ab4f8; } + +/* "Online only" — sits on the Scan row (see .live-controls), so it carries no margin of + its own; the row owns the spacing. */ +.device-filter { + display: flex; align-items: center; gap: 6px; + color: #7a8a9a; font-size: 11px; + cursor: pointer; user-select: none; +} +.device-filter:hover { color: #9aaaba; } +.device-filter-note { + color: #5a6a7a; font-size: 10px; font-style: italic; + margin-top: 6px; padding-left: 2px; +} + /* last_port chip — click to set this device's known flash port as the network port. */ .device-port-chip { font-size: 10px; background: #1c2535; color: #8ab4d8; @@ -468,11 +508,17 @@ select { flex: 1; overflow-y: auto; padding: 8px 16px; + /* A real monospace stack, not the body font's trailing `monospace` fallback (which never + wins on macOS). Script output is column-aligned with spaces — a proportional font makes + every table ragged. */ + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; color: #c0c0c0; white-space: pre-wrap; - word-break: break-all; + /* `break-all` would split a long C++ symbol mid-token and destroy column alignment; + `break-word` only breaks when a word genuinely cannot fit. */ + word-break: break-word; } #log a { color: var(--accent); @@ -502,6 +548,19 @@ button#identify-ports:hover, button#watch-ports:hover { color: #e94560; } button#refresh-ports { padding: 3px 6px; font-size: 12px; } +/* One turn of the ↻ glyph confirms a manual port refresh ran: the list usually comes back + identical, so without this the click reads as a no-op and invites a second press. + The animation targets the GLYPH, not the button — rotating the button would spin its + border into a diamond. inline-block because transforms don't apply to an inline box. */ +@keyframes refresh-spin { to { transform: rotate(360deg); } } +button#refresh-ports .refresh-glyph { display: inline-block; } +button#refresh-ports.spinning .refresh-glyph { animation: refresh-spin 0.5s linear; } +/* Someone who asked the OS for less motion still needs the confirmation — a brief + highlight on the button instead of the rotation. */ +@media (prefers-reduced-motion: reduce) { + button#refresh-ports.spinning .refresh-glyph { animation: none; } + button#refresh-ports.spinning { border-color: #2e9e63; color: #6ee7a0; } +} /* Watcher is live: green while polling so its on-state reads at a glance. */ button#watch-ports.watching { background: #1b5e3a; diff --git a/moondeck/scenario/_net_probe.py b/moondeck/scenario/_net_probe.py index b0b663fa..3acc5734 100644 --- a/moondeck/scenario/_net_probe.py +++ b/moondeck/scenario/_net_probe.py @@ -5,7 +5,7 @@ Kept in its own module (like _preview_ws.py) so the shared bits — protocol ports, the three packet builders, and the MoonDeck device set — have one clear home instead of living inside one of the two consumers. Nothing here is -specific to either test; the matrix-only colour-correction and Board +specific to either test; the matrix-only color-correction and Board orchestration stay in run_network_live.py. The packet builders mirror the firmware encoders byte for byte — cross-language diff --git a/moondeck/scenario/run_network_live.py b/moondeck/scenario/run_network_live.py index 90da1189..2be18541 100644 --- a/moondeck/scenario/run_network_live.py +++ b/moondeck/scenario/run_network_live.py @@ -7,14 +7,14 @@ every other device LISTENS: 1. The desktop seeds the sender three times — once per protocol, each with its own - colour — to the sender's protocol ports (6454/5568/4048); the + color — to the sender's protocol ports (6454/5568/4048); the NetworkReceiveEffect (added to each device's Layer for the run) listens on - all three at once. The sender's /ws preview stream must show each colour — + all three at once. The sender's /ws preview stream must show each color — proves desktop → device receive per protocol. 2. The sender's own NetworkSendDriver is pointed at each listener in turn, with its protocol control cycled round-robin so all three send paths get exercised across a matrix run; the listener's preview must show the - sender's CORRECTED colour (the send driver applies brightness + channel + sender's CORRECTED color (the send driver applies brightness + channel order) — proves device → device over real firmware send + receive. With one online device only step 1 runs (the matrix needs ≥2 boards). All @@ -39,20 +39,20 @@ from run_live_scenario import Client, _control_value # shared HTTP wrapper # noqa: E402 import _preview_ws # noqa: E402 # Shared lights-over-UDP surface (ports, packet builders, device set) — see -# _net_probe.py; the matrix-only colour-correction/Board logic stays below. +# _net_probe.py; the matrix-only color-correction/Board logic stays below. from _net_probe import ( # noqa: E402 ARTNET_PORT, E131_PORT, DDP_PORT, CHANNELS_PER_UNIVERSE, PROTOCOLS, MOONDECK_STATE, build_artdmx, build_e131, build_ddp, load_selected_devices, ) -# Round colours: channel values far apart so they stay distinct after the +# Round colors: channel values far apart so they stay distinct after the # sender's brightness scale (default 20/255 → e.g. (255,128,0) → (20,10,0)), # and distinct per round so a stale frame from an earlier round can't pass. -ROUND_COLOURS = [(255, 128, 0), (0, 255, 128), (128, 0, 255), +ROUND_COLORS = [(255, 128, 0), (0, 255, 128), (128, 0, 255), (255, 0, 128), (128, 255, 0), (0, 128, 255)] # Mirrors src/light/drivers/Correction.h (briLut scale + order[] reorder) — a -# listener sees the sender's corrected bytes, so the expected colour replicates +# listener sees the sender's corrected bytes, so the expected color replicates # that transform. 3-channel presets only; RGBW senders emit 4 bytes/light which # misaligns a 3-channel listener buffer, so those legs are skipped. Keep in sync. PRESET_ORDER = {"RGB": (0, 1, 2), "RBG": (0, 2, 1), "GRB": (1, 0, 2), @@ -72,7 +72,7 @@ def corrected(rgb, brightness, preset): def send_solid(host: str, rgb, protocol: str = "ArtNet", universes: int = 2, repeats: int = 10, pace_ms: int = 50): - """Send `repeats` full frames of solid colour to the device via the given + """Send `repeats` full frames of solid color to the device via the given protocol (the receiver autodetects on all three ports). Repeats absorb WiFi power-save first-packet loss; the receiver's hold-last-frame staging means one arrival suffices.""" @@ -200,7 +200,7 @@ def main() -> int: ap.add_argument("--host", help="only run rounds where this host is the sender " "(MoonDeck forwards the selected device here)") ap.add_argument("--tolerance", type=int, default=0, - help="per-channel colour tolerance (default 0 — preview is byte-exact)") + help="per-channel color tolerance (default 0 — preview is byte-exact)") ap.add_argument("--timeout", type=float, default=10.0, help="seconds to wait for a matching preview frame per leg") ap.add_argument("--packets", type=int, default=10, help="desktop seed frame repeats") @@ -235,40 +235,40 @@ def main() -> int: continue if args.host and sender.host != args.host: continue - colour = ROUND_COLOURS[k % len(ROUND_COLOURS)] + color = ROUND_COLORS[k % len(ROUND_COLORS)] print(f"== round {k + 1}/{len(boards)}: sender {sender.name}, " - f"colour {colour}", flush=True) + f"color {color}", flush=True) # Leg 1 — the seed sweep: the desktop seeds the sender once per protocol, - # each with a rotated colour (a stale frame from the previous + # each with a rotated color (a stale frame from the previous # protocol can't false-pass); the receiver autodetects all three. - # The sender's preview shows the RAW colour (uncorrected buffer). - seeded_colour = None + # The sender's preview shows the RAW color (uncorrected buffer). + seeded_color = None for pi, proto in enumerate(PROTOCOLS): - proto_colour = colour[pi:] + colour[:pi] - send_solid(sender.host, proto_colour, protocol=proto, + proto_color = color[pi:] + color[:pi] + send_solid(sender.host, proto_color, protocol=proto, repeats=args.packets, pace_ms=args.pace_ms) ok, pct, pts, detail = _preview_ws.wait_for_solid( - sender.host, proto_colour, args.tolerance, 100.0, args.timeout) + sender.host, proto_color, args.tolerance, 100.0, args.timeout) if ok: print(f"PASS desktop → {sender.name} [{proto}] (preview solid, {pts} points)", flush=True) passed += 1 - seeded_colour = proto_colour + seeded_color = proto_color else: print(f"FAIL pc → {sender.name} [{proto}] (best {pct:.0f}% of {pts} points" f"{', ' + detail if detail else ''})" " — desktop listeners: check the OS firewall allows UDP 6454/5568/4048", flush=True) failed += 1 - if seeded_colour is None: + if seeded_color is None: continue # without a seeded sender the relay legs can't mean anything - colour = seeded_colour # the sender's buffer now holds the last seeded colour + color = seeded_color # the sender's buffer now holds the last seeded color # Legs 2..N — sender relays to each listener via its own # NetworkSendDriver, cycling the protocol control round-robin so a # full matrix run exercises all three firmware send paths; - # listeners see the sender's CORRECTED colour. + # listeners see the sender's CORRECTED color. for listener in boards: if listener is sender: continue @@ -278,7 +278,7 @@ def main() -> int: continue relay_proto = relay_count % len(PROTOCOLS) relay_count += 1 - expected = corrected(colour, sender.brightness, sender.preset) + expected = corrected(color, sender.brightness, sender.preset) if not sender.artnet_enabled and not sender.enable_changed: sender.set_control("NetworkSend", "enabled", True) sender.enable_changed = True @@ -314,12 +314,12 @@ def main() -> int: def _relay_skip_reason(sender: "Board"): """A relay leg is meaningless when the sender's correction destroys the signal: RGBW presets emit 4 bytes/light (misaligns a 3-channel listener), - and brightness 0 corrects every colour to black — black also matches a + and brightness 0 corrects every color to black — black also matches a listener that received NOTHING (staging zero-fill), a guaranteed false pass.""" if sender.preset not in PRESET_ORDER: return f"sender preset {sender.preset} is 4-channel — relay assert supports 3-channel presets" if all(c == 0 for c in corrected((255, 255, 255), sender.brightness, "RGB")): - return "sender Drivers.brightness too low — corrected colour is black (raise brightness)" + return "sender Drivers.brightness too low — corrected color is black (raise brightness)" return None diff --git a/moondeck/scenario/run_network_roundtrip.py b/moondeck/scenario/run_network_roundtrip.py index b0eae2c7..a3bb45d0 100644 --- a/moondeck/scenario/run_network_roundtrip.py +++ b/moondeck/scenario/run_network_roundtrip.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 """Measure desktop→device→desktop round-trip latency per protocol on the checked boards. -The simplest honest latency probe: the desktop sends one solid-colour frame to a -device, then times how long until that colour shows up in the device's preview +The simplest honest latency probe: the desktop sends one solid-color frame to a +device, then times how long until that color shows up in the device's preview WebSocket stream. The path is desktop → device's NetworkReceiveEffect (writes the layer buffer) → PreviewDriver (broadcasts the buffer over /ws) → desktop. It sweeps all three protocols — ArtNet, E1.31, DDP — per device (the receiver autodetects @@ -19,7 +19,7 @@ Extend later (hooks left intentionally simple): per-frame sequence matching, the device→device forwarding chain, jitter/drop histograms. This version sweeps -the three protocols with one probe colour, N repeats each. +the three protocols with one probe color, N repeats each. Exit codes follow the other live scripts: 0 = measured ok, 1 = no frame came back (a real failure), 2 = environment problem (no device, moondeck.json @@ -43,7 +43,7 @@ import socket # noqa: E402 import _preview_ws # noqa: E402 -# A colour the idle effects are unlikely to paint by themselves, so a preview +# A color the idle effects are unlikely to paint by themselves, so a preview # match means OUR frame arrived, not a coincidence. Distinct on all 3 channels. PROBE_RGB = (0x11, 0x22, 0x33) @@ -72,7 +72,7 @@ def seed_once(ip: str, rgb, universes: int, seq: int, protocol: str = "ArtNet"): def measure_roundtrip(host: str, repeats: int, timeout_s: float, protocol: str): - """Send the probe colour `repeats` times over `protocol`; time + """Send the probe color `repeats` times over `protocol`; time send→preview-arrival each time. Returns the list of latencies in milliseconds (one per successful repeat).""" ip = host.partition(":")[0] @@ -106,7 +106,7 @@ def measure_roundtrip(host: str, repeats: int, timeout_s: float, protocol: str): else: print(f" repeat {i + 1}/{repeats}: NO FRAME (best {best_pct:.0f}% " f"of {points} pts; {detail})", flush=True) - # Clear the probe colour between repeats so the next match is a fresh + # Clear the probe color between repeats so the next match is a fresh # arrival, not the held last frame. Send black on the same protocol; # don't time it. seed_once(ip, (0, 0, 0), universes, seq=(i + 128) & 0xFF, protocol=protocol) @@ -204,7 +204,7 @@ def main() -> int: "every device checked in MoonDeck's Live tab") ap.add_argument("--repeats", type=int, default=10, help="probes per protocol per device") ap.add_argument("--timeout", type=float, default=5.0, - help="seconds to wait for the probe colour per repeat") + help="seconds to wait for the probe color per repeat") args = ap.parse_args() if args.host: diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 7ca6c1e9..07fab165 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -104,7 +104,7 @@ void writeControlValue(JsonSink& sink, const ControlDescriptor& c) { return; case ControlType::Select: case ControlType::Palette: - // The selected index — the option strings / swatch colours go in the + // The selected index — the option strings / swatch colors go in the // metadata block (writeControlMetadata) where the UI also wants them. sink.appendf("%u", *static_cast<uint8_t*>(c.ptr)); return; @@ -165,6 +165,9 @@ void writeControlMetadata(JsonSink& sink, const ControlDescriptor& c) { case ControlType::Select: { sink.append(",\"options\":["); auto* options = reinterpret_cast<const char* const*>(c.aux); + // addSelect takes a uint8_t option count, so c.max can never exceed 255 and the + // counter cannot wrap. + // NOLINTNEXTLINE(bugprone-too-small-loop-variable) for (uint8_t o = 0; o < c.max; o++) { sink.appendf("%s\"%s\"", o > 0 ? "," : "", options[o]); } @@ -236,7 +239,7 @@ ApplyResult applyControlValue(const ControlDescriptor& c, auto clampInto = [](auto* dst, int v, int lo, int hi) { if (v < lo) v = lo; if (v > hi) v = hi; - using T = typename std::remove_pointer<decltype(dst)>::type; + using T = std::remove_pointer_t<decltype(dst)>; *dst = static_cast<T>(v); return ApplyResult::Ok; }; diff --git a/src/core/Control.h b/src/core/Control.h index bc0da580..5e2eacdc 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -89,7 +89,7 @@ inline void sanitizeHostname(char* buf) { /// The type of a control — selects its storage, its UI widget, and its DMX mapping. /// Each `controls_.addX(name, var, …)` binds one of these to a class variable by /// reference. Uint8 (a slider, 0–255) is the preferred default; the non-obvious -/// members are noted per value below. There is no RGB colour-picker type — effects +/// members are noted per value below. There is no RGB color-picker type — effects /// use a palette index (a Uint8) instead; `float` and `Coord3D` exist but are used /// minimally, prefer Uint8. enum class ControlType : uint8_t { @@ -133,8 +133,8 @@ enum class ControlType : uint8_t { ///< No backing storage (ptr unused) and non-persistable — distinct ///< from Bool, which is an on/off STATE that renders as a toggle and a ///< toggle is the wrong affordance for "do this now" (e.g. rescan). - Palette ///< a colour-palette dropdown (ptr → uint8_t index). Like Select, but - ///< each option carries its gradient *colours* (16 hex stops) so the UI + Palette ///< a color-palette dropdown (ptr → uint8_t index). Like Select, but + ///< each option carries its gradient *colors* (16 hex stops) so the UI ///< renders a gradient swatch per option, not just a name. The light ///< domain supplies the names + swatches via the Palette type; the wire ///< shape (options:[{name,colors}]) is serialized in writeControlMetadata. @@ -144,7 +144,7 @@ enum class ControlType : uint8_t { class JsonSink; // A ControlType::Palette control's options come from the light domain (it owns the palette set -// and the swatch colours). The descriptor's `aux` holds a pointer to this function; core calls it +// and the swatch colors). The descriptor's `aux` holds a pointer to this function; core calls it // to emit the `"options":[{"name":…,"colors":…}, …]` array — core stays palette-agnostic. using PaletteOptionsFn = void (*)(JsonSink& sink); @@ -252,6 +252,14 @@ struct ControlDescriptor { // so tooling and the API reach it regardless. Set via setAdvanced(). (The // client composes the two: expertMode is one global toggle in SystemModule, // read UI-side, so no module needs to reach into System's state.) + bool numberField = false; // Renders a numeric control (Uint8/Uint16/Int16) as a plain NUMBER INPUT, + // never a drag-slider — for a value where each integer is a discrete address + // (a PHY MDIO address, an I2C address, a channel number), not a magnitude you + // sweep. A pure UI rendering hint like hidden/advanced; the value, range, and + // persistence are unchanged. Set via setNumberField(). (The Pin type already + // renders number-only for the same reason — a GPIO is an identity, not a + // magnitude; this extends that to non-Pin numerics without the Pin type's + // pin-ownership-map claim.) // Optional per-control input validator (Text/Password only; nullptr = accept anything // that fits the buffer). applyControlValue calls it on the incoming string BEFORE the // write and returns ApplyResult::Malformed on reject, so the check covers EVERY write @@ -353,7 +361,7 @@ class ControlList { void addText(const char* name, char* var, uint16_t bufSize = 16, bool (*validate)(const char*) = nullptr) { grow(); - controls_[count_++] = {var, name, 0, ControlType::Text, 0, bufSize, false, false, false, validate}; + controls_[count_++] = {var, name, 0, ControlType::Text, 0, bufSize, false, false, false, false, validate}; } // Like addText but the UI renders a resizable multi-line <textarea> (e.g. a @@ -361,7 +369,7 @@ class ControlList { void addTextArea(const char* name, char* var, uint16_t bufSize = 16, bool (*validate)(const char*) = nullptr) { grow(); - controls_[count_++] = {var, name, 0, ControlType::TextArea, 0, bufSize, false, false, false, validate}; + controls_[count_++] = {var, name, 0, ControlType::TextArea, 0, bufSize, false, false, false, false, validate}; } // Like addText but the value is a secret: the API serializes it @@ -385,8 +393,8 @@ class ControlList { ControlType::ReadOnlyInt, 0, 0}; } - // A colour-palette dropdown: like a Select (ptr → uint8_t index, max = optionCount), but the - // options carry swatch colours. `optionsFn` (light-domain) emits the {name,colors} objects. + // A color-palette dropdown: like a Select (ptr → uint8_t index, max = optionCount), but the + // options carry swatch colors. `optionsFn` (light-domain) emits the {name,colors} objects. void addPalette(const char* name, uint8_t& var, PaletteOptionsFn optionsFn, uint8_t optionCount) { grow(); controls_[count_++] = {&var, name, reinterpret_cast<uintptr_t>(optionsFn), ControlType::Palette, 0, optionCount}; @@ -457,6 +465,13 @@ class ControlList { if (i < count_) controls_[i].advanced = advanced; } + // Ask the UI to render a numeric control as a plain number input, never a slider — for a value where + // each integer is a discrete identity (a PHY/I2C address, a channel), not a magnitude to sweep. + // Typical use: addInt16()/addUint8() then setNumberField(count() - 1). See the descriptor's field. + void setNumberField(uint8_t i, bool numberField = true) { + if (i < count_) controls_[i].numberField = numberField; + } + private: ControlDescriptor* controls_ = nullptr; uint8_t count_ = 0; diff --git a/src/core/HttpServerModule.cpp b/src/core/HttpServerModule.cpp index 3f3b91a4..4e0ba017 100644 --- a/src/core/HttpServerModule.cpp +++ b/src/core/HttpServerModule.cpp @@ -15,7 +15,7 @@ #include "core/FilesystemModule.h" #include "core/FirmwareUpdateModule.h" #include "core/SystemModule.h" // deviceName() for the WLED /json/info shim -#include "light/Palette.h" // Palettes::nearestForHue — maps HA's RGB colour picker onto our +#include "light/Palette.h" // Palettes::nearestForHue — maps HA's RGB color picker onto our // hue→palette convention (same core→light bridge MqttModule uses // for hsv/set; see the note in MqttModule.cpp:7-14). #include "light/drivers/Drivers.h" // Drivers::latestSummary() — the real light count/channels for @@ -258,7 +258,7 @@ void HttpServerModule::handleConnection(platform::TcpConnection& conn) { // useful independent cross-check that our mDNS advertise resolves. else if (std::strcmp(path, "/json/info") == 0) serveWledInfo(conn); // WLED state + the combined state+info (`/json/si`) the app reads for its device - // card: on/off, brightness, and the segment's primary colour (which the app uses + // card: on/off, brightness, and the segment's primary color (which the app uses // as the card tint). serveWledState reads live brightness from the Drivers module. else if (std::strcmp(path, "/json/state") == 0) serveWledState(conn); else if (std::strcmp(path, "/json/si") == 0) serveWledStateInfo(conn); @@ -882,9 +882,12 @@ static uint32_t fnv1a(const char* s, size_t len) { template <class Fn> void HttpServerModule::forEachStateLeaf(Fn&& fn) { if (!scheduler_) return; + // `fn`, not `std::forward<Fn>(fn)`: forwarding inside a loop moves the callable on the + // first module, leaving every later module a moved-from object. Passing the lvalue binds + // to visitModuleLeaves' own forwarding reference without transferring ownership. for (uint8_t m = 0; m < scheduler_->moduleCount(); m++) if (auto* mod = scheduler_->module(m)) - if (mod->appearsInUi()) visitModuleLeaves(mod, std::forward<Fn>(fn)); + if (mod->appearsInUi()) visitModuleLeaves(mod, fn); } template <class Fn> @@ -934,8 +937,10 @@ void HttpServerModule::visitModuleLeaves(MoonModule* mod, Fn&& fn) { JsonSink vs; writeControlValue(vs, c); fn(fnv1a(path, std::strlen(path)), fnv1a(vs.data(), vs.size()), path, vs); } + // `fn`, not `std::forward<Fn>(fn)` — same reason as the caller above: forwarding inside a + // loop moves the callable into the first child, leaving every later sibling a moved-from one. for (uint8_t i = 0; i < mod->childCount(); i++) - if (auto* ch = mod->child(i)) visitModuleLeaves(ch, std::forward<Fn>(fn)); + if (auto* ch = mod->child(i)) visitModuleLeaves(ch, fn); } // Look up a leaf's cached value-hash by path-hash; returns nullptr if not yet seen. Linear over the @@ -1078,6 +1083,7 @@ void HttpServerModule::writeControls(JsonSink& sink, MoonModule* mod) { // Emit optional flags only when set (common case is false; omit to save bytes). if (c.readonly) sink.append(",\"readonly\":true"); if (c.advanced) sink.append(",\"advanced\":true"); // UI shows it only in expert mode + if (c.numberField) sink.append(",\"numberField\":true"); // render a plain number input, not a slider // An editable List (the CRUD primitive) tells the UI to show add/delete/reorder + inline // row editors; a plain List stays read-only. The row objects carry a stable "id" the // /api/list/* ops address, and each editable row's detail carries its field descriptors. @@ -1272,8 +1278,8 @@ void HttpServerModule::writeWledInfoBody(JsonSink& sink, const char* name, const } // The WLED state object, written into an open sink. `on` + `bri` mirror Drivers on/brightness. -// `seg[0].col[0]` reports the ACTIVE PALETTE's identity colour, not the live first-LED — so -// every WLED consumer (the WLED native app's device card, HA's WLED integration colour picker, +// `seg[0].col[0]` reports the ACTIVE PALETTE's identity color, not the live first-LED — so +// every WLED consumer (the WLED native app's device card, HA's WLED integration color picker, // Homebridge's HSV via the MQTT pair, the /ws push) sees the same stable palette-representative // value and matches the palette-picker → RGB round-trip. Live first-LED was tried first and // dropped: it dimmed the picker under low master brightness (near-black) and jittered with the @@ -1298,8 +1304,8 @@ void HttpServerModule::writeWledStateBody(JsonSink& sink) { // seg[0].pal = the active palette index, so HA's WLED integration highlights the current entry // in its palette dropdown (light.py reads state.segments[<seg>].palette). It shares the Drivers // `palette` control with col[0] above (representativeRgb of the SAME index), so the HA palette - // dropdown and colour picker stay two views of one value: selecting a palette repaints the picker - // on HA's next poll, and picking a colour snaps to the nearest palette (applyWledState). + // dropdown and color picker stay two views of one value: selecting a palette repaints the picker + // on HA's next poll, and picking a color snaps to the nearest palette (applyWledState). const uint8_t pal = driversPalette(scheduler_); sink.appendf("{\"on\":%s,\"bri\":%u,\"transition\":7,\"ps\":-1,\"pl\":-1," "\"nl\":{},\"udpn\":{},\"lor\":0,\"mainseg\":0," @@ -1397,12 +1403,12 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { "\"brand\":\"WLED\",\"product\":\"MoonModules\",\"release\":\"MoonModules\"," // lc + seglc = LightCapability.RGB_COLOR (1) so HA WLED's segment light picks // ColorMode.RGB (via LIGHT_CAPABILITIES_COLOR_MODE_MAPPING in ha-core/wled/const.py), - // which grants a brightness slider AND colour picker. LightCapability.NONE (0) + // which grants a brightness slider AND color picker. LightCapability.NONE (0) // maps to ColorMode.ONOFF, which is why the entity was on/off-only initially. // BOTH are capability CODES, not counts: HA reads seglc[segment_id] as that segment's // capability bitmask (1 = RGB), then LIGHT_CAPABILITIES_COLOR_MODE_MAPPING[seglc[0]] - // gives the colour mode. Putting the LED count here (e.g. seglc:[24]) has no mapping, - // so WLEDSegmentLight ends up with NO supported colour modes and HA refuses to add the + // gives the color mode. Putting the LED count here (e.g. seglc:[24]) has no mapping, + // so WLEDSegmentLight ends up with NO supported color modes and HA refuses to add the // light entity ("does not set supported color modes") — it stays `restored`/unavailable // while the sensors still work. seglc is therefore the constant 1, matching lc; the LED // count lives only in `count`. fps = the real render rate (scheduler_->fps()). @@ -1441,7 +1447,7 @@ void HttpServerModule::serveWledDeviceJson(platform::TcpConnection& conn) { // effects stays one real entry ("Solid"): this shim drives a single Layer, so a longer effect list // would be a lie. palettes is the REAL built-in list (Palette.h paletteNames / kBuiltins) so HA's // palette dropdown offers every palette the device has, indexed to match seg[0].pal and the Drivers - // `palette` control — the same one-narrow-reach into light/ that the representative colour uses. + // `palette` control — the same one-narrow-reach into light/ that the representative color uses. sink.appendf(",\"effects\":[\"Solid\"],\"palettes\":["); mm::paletteNames(sink); sink.appendf("]}"); @@ -1488,7 +1494,7 @@ void HttpServerModule::applyWledState(const char* body) { // WLED palette: seg[0].pal is the palette index. HA's WLED integration writes here when a user // picks from the palette dropdown (the entries served by paletteNames in /json). It maps straight // to the Drivers `palette` control — the direct-index counterpart to the col[] nearest-match below; - // both feed the same control, so the dropdown and the colour picker stay one value. Parsed from the + // both feed the same control, so the dropdown and the color picker stay one value. Parsed from the // segment object so a top-level stray "pal" can't hijack it. const char* segStart = std::strstr(body, "\"seg\":"); const char* palStart = segStart ? std::strstr(segStart, "\"pal\":") : nullptr; @@ -1500,8 +1506,8 @@ void HttpServerModule::applyWledState(const char* body) { std::snprintf(valueJson, sizeof(valueJson), "{\"value\":%d}", pal); applySetControl("Drivers", "palette", valueJson); } - // WLED colour: seg[0].col[0] is [r,g,b]. HA's WLED integration writes here when a user picks a - // colour in the RGB picker. Palettes::nearestForRgb is the canonical RGB→palette entry (see the + // WLED color: seg[0].col[0] is [r,g,b]. HA's WLED integration writes here when a user picks a + // color in the RGB picker. Palettes::nearestForRgb is the canonical RGB→palette entry (see the // comment at its declaration): it applies the same RGB→(hue,sat) conversion representativeHueSat // uses on the palette side, then runs the 2D-distance sweep. Value channel is ignored — HA's own // brightness slider handles bri via the `bri` field above. @@ -2261,11 +2267,11 @@ void HttpServerModule::pushStateToWebSockets() { } // Also push a WLED-shaped {state, info} frame. The native WLED app connects to this - // same /ws and reads live state (colour, brightness, on/off) from a DeviceStateInfo + // same /ws and reads live state (color, brightness, on/off) from a DeviceStateInfo // message — it has no /json/si GET. Our own UI ignores this frame (its JS keys on // `modules`); the WLED app ignores our module frame (its Moshi keys on `state`/`info`). // Two small frames, each consumer parses its own — no client needs to know about the - // other. This is what makes the device's card show the live colour + a working slider. + // other. This is what makes the device's card show the live color + a working slider. pushWledStateToWebSockets(); } @@ -2369,7 +2375,7 @@ bool HttpServerModule::sendWsTextFrame(platform::TcpConnection& conn, const char // can't all go right now. Bounded TOTAL would-block spins (not reset on progress) hard-bound how // long this synchronous send can occupy the caller's loop; a span that doesn't complete in budget // closes the client (the browser reconnects). Used by the begin/push/end stream (the coord table -// and downsampled colour frame); the full-res colour frame uses the resumable sendBufferedFrame. +// and downsampled color frame); the full-res color frame uses the resumable sendBufferedFrame. bool HttpServerModule::sendAllOrClose(platform::TcpConnection& ws, const uint8_t* data, size_t len) { size_t sent = 0; int stalls = 0; @@ -2386,7 +2392,7 @@ bool HttpServerModule::sendAllOrClose(platform::TcpConnection& ws, const uint8_t } // Streamed frame: header now, payload pushed in slices, no frame-sized staging buffer — so a -// large frame (PreviewDriver's coordinate table or colour frame) goes out on a memory-tight +// large frame (PreviewDriver's coordinate table or color frame) goes out on a memory-tight // board where a contiguous block won't fit. The producer (forEachCoord) pushes forward-only; // each slice fans to every client before the next push. A client that can't keep up is closed // (its WS message ends incomplete → it reconnects), so this never blocks the tick indefinitely. diff --git a/src/core/MoonModule.h b/src/core/MoonModule.h index ae81cce1..06ceb9d0 100644 --- a/src/core/MoonModule.h +++ b/src/core/MoonModule.h @@ -338,7 +338,7 @@ class MoonModule { /// Read this module's first output light as RGB into out[3], returning true if it has /// one. Domain-neutral seam (core declares it, the output-owning module overrides): /// the WLED-compatibility shim uses it to tint the app's device card with the live - /// first-LED colour. Default: no output → false. + /// first-LED color. Default: no output → false. virtual bool firstOutputRgb(uint8_t /*out*/[3]) const { return false; } const char* name() const { return name_; } @@ -439,7 +439,7 @@ class MoonModule { // an option index), and Palette (a palette-picker stored as a builtins-array index). The // Palette omission here silently failed driversPalette()/similar callers to dflt=0, // which then made HttpServerModule's WLED shim report seg[0].col as Rainbow's - // representative colour (white) regardless of the actually-picked palette — the "the HA + // representative color (white) regardless of the actually-picked palette — the "the HA // wheel snaps back to the centre and the card renders white" bug pinned on the bench. if (c.ptr && std::strcmp(c.name, name) == 0 && (c.type == ControlType::Uint8 || c.type == ControlType::Select || diff --git a/src/core/MqttModule.cpp b/src/core/MqttModule.cpp index 9db48904..0cce537b 100644 --- a/src/core/MqttModule.cpp +++ b/src/core/MqttModule.cpp @@ -9,7 +9,7 @@ // the OTA task the update entity's install command triggers #include "light/Palette.h" // Palettes::nearestForHue — a pure hue/sat→index CONVERSION with no // light state or objects, the one narrow reach this core module makes - // into the light domain. PO-accepted: routing a HomeKit colour to a + // into the light domain. PO-accepted: routing a HomeKit color to a // palette needs the palette set, which is inherently light-domain, and // a format conversion is the least-coupling way to bridge it (the // module still drives the palette via Scheduler::setControl, not a diff --git a/src/core/MqttModule.h b/src/core/MqttModule.h index 65557cc2..2b66a701 100644 --- a/src/core/MqttModule.h +++ b/src/core/MqttModule.h @@ -12,7 +12,7 @@ namespace mm { /// MQTT client service: bridges the device's light controls (on / brightness / palette) to an MQTT /// broker so home-automation hubs can drive it. The headline consumer is **Homebridge** (via the /// `homebridge-mqttthing` "lightbulb" accessory), which publishes to `set` topics and reads `get` -/// topics — a bare on/off + brightness + colour surface. It is a network sub-service, a code-wired +/// topics — a bare on/off + brightness + color surface. It is a network sub-service, a code-wired /// child of NetworkModule alongside Improv/Devices, and it drives the shared apply-core exactly as /// IR and the WLED bridge do: every command routes through `Scheduler::setControl("Drivers", …)`, /// so MQTT adds a transport, not new control plumbing. @@ -36,7 +36,7 @@ namespace mm { /// device publishes a RETAINED JSON-schema light config to `homeassistant/light/projectMM_<mac6>/config`, /// so HA (and any Discovery-aware hub — the Tasmota/ESPHome/Zigbee2MQTT convention) **auto-creates a /// wired light entity** — no hand-matching topics. It defaults off because the WLED-compat surface (the -/// HttpServerModule `/json` shim) already gives HA a richer light — colour, palette, sensors — over +/// HttpServerModule `/json` shim) already gives HA a richer light — color, palette, sensors — over /// mDNS with no broker, so a device on defaults appears in HA once, not twice; MQTT discovery is the /// opt-in for broker-only / cross-subnet setups where mDNS doesn't reach. When on it speaks HA's own /// schema alongside the mqttthing topics above: diff --git a/src/core/NetworkModule.h b/src/core/NetworkModule.h index 66130ccf..2632965a 100644 --- a/src/core/NetworkModule.h +++ b/src/core/NetworkModule.h @@ -47,7 +47,7 @@ namespace mm { /// - `ethType` — PHY dropdown; the stored index maps 0=None, 1=LAN8720 (RMII), /// 2=IP101 (RMII), 3=W5500 (SPI), 4=YT8531 (RGMII, the S31's on-chip 1 Gb EMAC), /// matching the `EthPhyType` enum order. 0 shows no pin rows; a type reveals only its set. -/// - `ethPhyAddr` — SMI/PHY MDIO address (0..31, typically 0 or 1). +/// - `ethPhyAddr` — SMI/PHY MDIO address: -1 = auto-detect (scan the bus, the RGMII default), else 0..31 (typically 0 or 1). /// - `ethRstGpio` — PHY reset GPIO (−1 = none / module self-resets). /// - `ethMdcGpio` / `ethMdioGpio` — RMII SMI clock / data GPIOs (−1 = IDF default). RMII only. /// - `ethClockGpio` — RMII 50 MHz reference-clock GPIO; `ethClockExtIn` = clock direction @@ -59,6 +59,16 @@ namespace mm { /// interface is active and re-registers when the active interface changes or the name is /// renamed live. Uses ESP-IDF's `mdns_init()` / `mdns_hostname_set()`. /// +/// **Addressing (DHCP / Static):** the `addressing` selector chooses how the active client +/// interface — WiFi STA *or* Ethernet — gets its IPv4 address. DHCP (the default) runs the +/// interface's DHCP client. Static pins the `ip` / `gateway` / `subnet` / `dns` controls onto the +/// netif via `platform::netSetStaticIPv4` (which stops the DHCP client and sets the address); the +/// same octets that on DHCP would come from the server. Applied at each interface's bring-up +/// (`applyStaticIfConfigured`) and live on a change (`syncAddressingLive`, no reboot — toggling +/// back to DHCP restarts the client and re-leases). On Static there is no DHCP `GOT_IP` event, so +/// the platform marks the interface connected when the static IP is set. A static IP on Ethernet +/// bypasses DHCP entirely — useful where a DHCP handshake is unreliable but the link is fine. +/// /// **Device name:** the network name is owned solely by SystemModule; this module only /// READS it (see `readDeviceName`), and it is the single identity behind the mDNS /// `<name>.local`, the SoftAP SSID, and the DHCP hostname — so a device shows one name @@ -211,6 +221,11 @@ class NetworkModule : public MoonModule { // Push the board's eth config (persisted controls, loaded before setup) // into the platform layer before ethInit reads it. syncEthConfig(); + // Baseline the addressing signature so syncAddressingLive only fires on a *later* change: + // the initial static apply is done by the bring-up path (WaitingEth / onConnected), and a + // DHCP-mode device must not have its client needlessly restarted on the first tick. + appliedAddressingSig_ = addressingSig(); + addressingSigApplied_ = true; // Try Ethernet first (non-blocking) if (platform::ethInit()) { state_ = State::WaitingEth; @@ -296,7 +311,7 @@ class NetworkModule : public MoonModule { // but visibility flips based on addressing mode. Toggling the Select triggers a // rebuildControls() in HttpServerModule which re-runs this method and re-evaluates // the hidden flags. - const bool hideStatic = (addressing_ != 1); + const bool hideStatic = (addressing_ != kAddressingStatic); controls_.addIPv4("ip", staticIp_); controls_.setHidden(controls_.count() - 1, hideStatic); controls_.addIPv4("gateway", staticGateway_); @@ -327,11 +342,14 @@ class NetworkModule : public MoonModule { const bool isEth = isRmii || isSpi || isRgmii; // GPIO controls use addPin → a plain number input (ControlType::Pin), // not a slider: a GPIO has no meaningful range to drag. -1 = unused. - // phyAddr is a PHY MDIO address (0..31), NOT a GPIO — it's a plain uint8 - // number, so it uses addUint8, not addPin. (A Pin here would make the pin - // ownership map report it as a false GPIO claim, since that map reads every - // ControlType::Pin as a claimed GPIO.) - controls_.addUint8("ethPhyAddr", ethPhyAddr_, 0, 31); + // phyAddr is a PHY MDIO address (-1 = auto-detect, else 0..31), NOT a GPIO — + // so it uses a signed int control (addInt16), not addPin. (A Pin here would + // make the pin ownership map report it as a false GPIO claim, since that map + // reads every ControlType::Pin as a claimed GPIO.) It must be signed: -1 is + // IDF's ESP_ETH_PHY_ADDR_AUTO sentinel (scan the MDIO bus), the S31's RGMII + // default — a uint8 mangled -1 to 31 and the PHY never answered. + controls_.addInt16("ethPhyAddr", ethPhyAddr_, -1, 31); + controls_.setNumberField(controls_.count() - 1); // an MDIO address is an identity, not a magnitude — a number field, not a slider controls_.setHidden(controls_.count() - 1, !isEth); controls_.addPin("ethRstGpio", ethRstGpio_); controls_.setHidden(controls_.count() - 1, !isEth); @@ -363,11 +381,42 @@ class NetworkModule : public MoonModule { uint32_t now = platform::millis(); uint32_t elapsed = now - stateChangeTime_; + // The Ethernet-degraded warning is held only while the leaseless cable is still plugged in. + // Once the link drops (cable out), reset the retry clock and clear the warning so the normal + // connected status returns; the next re-plug then gets a fresh DHCP window (see ConnectedSta). + if (!platform::ethLinkUp() && (ethDegraded_ || ethLinkUpAt_ != 0)) { + ethLinkUpAt_ = 0; + if (ethDegraded_) { + ethDegraded_ = false; + updateStatusIP(); // reverts to the WiFi/AP IP line (or leaves prior status if none) + } + } + switch (state_) { case State::WaitingEth: + // Static mode: as soon as the link is up, pin the IP (no DHCP round to wait for). + // netSetStaticIPv4 marks eth connected, so the ethConnected() check below promotes + // to Ethernet on the same or next tick. DHCP mode: this is a no-op, cascade as usual. + if (addressing_ == kAddressingStatic && platform::ethLinkUp() && !platform::ethConnected()) + applyStaticIfConfigured(platform::NetIface::Eth); if (platform::ethConnected()) { onConnected("Ethernet"); - } else if ((elapsed > 3000 && !platform::ethLinkUp()) || elapsed > 15000) { + } else if ((elapsed > 3000 && !platform::ethLinkUp()) || elapsed > kEthDhcpWaitMs) { + // Surface WHY Ethernet is being abandoned. Two distinct cases: the link never + // came up (no cable, or the PHY didn't negotiate), vs. the link is up but no + // address was assigned (the S31-at-100M DHCP gap — see docs/backlog-core.md). + // When the link IS up but leaseless, remember it (ethDegraded_) so the warning + // 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). + if (platform::ethLinkUp()) { + ethDegraded_ = true; + writeEthDegradedStatus(); + } else { + ethDegraded_ = false; + std::snprintf(statusBuf_, sizeof(statusBuf_), "Ethernet not detected: no cable/link"); + setStatus(statusBuf_, Severity::Warning); + } if constexpr (platform::hasWiFi) { // No cable after 3s, or link up but no IP after 15s — cascade to WiFi std::printf("NetworkModule: Ethernet %s, cascading\n", @@ -389,6 +438,13 @@ class NetworkModule : public MoonModule { case State::WaitingSta: if constexpr (platform::hasWiFi) { + // Static mode: pin the IP during bring-up, the WaitingEth mirror — a DHCP-less + // network never fires a lease event, so waiting for "connected" before applying + // static would strand the STA into the AP fallback. netSetStaticIPv4 marks the + // STA connected once it is associated (platform-gated), so the check below + // promotes on the same or next tick. DHCP mode: no-op. + if (addressing_ == kAddressingStatic && !platform::wifiStaConnected()) + applyStaticIfConfigured(platform::NetIface::Sta); if (platform::wifiStaConnected()) { onConnected("WiFi STA"); } else if (elapsed > kStaGraceMs) { @@ -431,10 +487,32 @@ class NetworkModule : public MoonModule { // WiFi STA down. Gated on ethConnected() (link + DHCP IP), not // bare link-up, so WiFi is never dropped for a not-yet-working // Ethernet — matches the State::AP upgrade check. + // Static mode: an Ethernet cable that is up needs no DHCP lease — pin its IP + // directly (netSetStaticIPv4 marks eth connected), so the promotion below fires + // this tick. Ethernet ALWAYS outranks WiFi when a cable is present (the AP → STA + // → ETH cascade is the architecture's contract, never conditional on a per-board + // quirk). This lets Ethernet win on a link where DHCP can't complete (the S31 at + // 100M): static bypasses the handshake entirely. DHCP mode skips this and relies + // on the lease (the ethConnected() check). + if (addressing_ == kAddressingStatic && platform::ethLinkUp() && !platform::ethConnected()) + applyStaticIfConfigured(platform::NetIface::Eth); if (platform::ethConnected()) { std::printf("NetworkModule: Ethernet up, switching from WiFi STA\n"); platform::mdnsStop(); onConnected("Ethernet"); + } else if (platform::ethLinkUp()) { + // DHCP mode, cable plugged while on WiFi. IDF restarts the eth DHCP client + // automatically on each link-up, so Ethernet IS being retried — the check above + // promotes to it the moment a lease lands (eth outranks WiFi). Only if the link + // stays up WITHOUT a lease past the DHCP window (the S31-at-100M gap) do we raise + // the "no address assigned" warning; the grace period lets a healthy cable that + // just needs a few seconds to lease get promoted, not flashed as failed. + // (In Static mode this branch is unreachable: the apply above already connected eth.) + if (ethLinkUpAt_ == 0) ethLinkUpAt_ = now; // link just (re)appeared: start the clock + if (!ethDegraded_ && now - ethLinkUpAt_ > kEthDhcpWaitMs) { + ethDegraded_ = true; + writeEthDegradedStatus(); + } } else if (!platform::wifiStaConnected()) { // **A dropout is not a divorce.** The radio reconnects itself (the platform's // STA_DISCONNECTED handler calls esp_wifi_connect), and the common causes — a @@ -526,7 +604,8 @@ class NetworkModule : public MoonModule { syncMdns(); syncTxPower(); - syncEthLive(); // hot-apply a W5500 eth config change (no reboot) + syncEthLive(); // hot-apply a W5500 eth config change (no reboot) + syncAddressingLive(); // hot-apply a DHCP↔Static change (no reboot) // Refresh the live-readout values every tick — the UI polls /api/state // for them, so writing the same storage addresses is enough; no @@ -576,6 +655,11 @@ class NetworkModule : public MoonModule { /// initial connect (WaitingSta) and a mid-session dropout (ConnectedSta) — the question is the /// same in both places, so the answer should be too. static constexpr uint32_t kStaGraceMs = 10000; + /// How long an Ethernet link may sit up without a DHCP lease before we give up on it — the + /// WaitingEth timeout, and the same window a re-plugged cable's automatic DHCP retry gets in + /// ConnectedSta before the "no address assigned" warning is raised. DHCP can take several + /// seconds (~7 s measured on the P4-NANO), so the window is comfortably above that. + static constexpr uint32_t kEthDhcpWaitMs = 15000; /// How often the AP fallback goes back and retries WiFi STA. Long, because each attempt /// re-inits the radio and briefly bounces the AP (a user mid-setup on 4.3.2.1 sees a blip), and /// because the causes it recovers from — a rebooting router, a device carried back into range — @@ -583,6 +667,15 @@ class NetworkModule : public MoonModule { /// does not do it instantly. static constexpr uint32_t kApRetryStaMs = 60000; bool apShutdownPending_ = false; + // Ethernet link is up but never got an address, so we cascaded to WiFi/AP. Kept set so the + // "Ethernet detected: no address assigned" warning outranks the fallback's connected status + // (updateStatusIP), until the cable is unplugged (ethLinkUp drops → cleared in tick1s). + bool ethDegraded_ = false; + // While on WiFi/AP: millis() when the Ethernet link most recently came up (0 = link down). + // A re-plugged cable makes IDF retry DHCP automatically; we give that retry the same window as + // first boot (kEthDhcpWaitMs) before declaring the cable degraded, so a healthy cable that just + // needs a few seconds to lease is promoted to Ethernet, not flashed as failed. See ConnectedSta. + uint32_t ethLinkUpAt_ = 0; bool mdnsRunning_ = false; // The device name last registered with mDNS, so syncMdns() can detect a live // rename (deviceName changed in SystemModule) and re-advertise — without it, @@ -593,7 +686,11 @@ class NetworkModule : public MoonModule { // Controls char ssid_[33] = {}; char password_[64] = {}; - uint8_t addressing_ = 0; // 0=DHCP, 1=Static + // Addressing mode: the `addressing` Select's stored index. Named so the `== kAddressingStatic` + // checks read as intent, not a magic literal (matches the addressingOptions_ order). + static constexpr uint8_t kAddressingDhcp = 0; + static constexpr uint8_t kAddressingStatic = 1; + uint8_t addressing_ = kAddressingDhcp; bool mdnsEnabled_ = true; // Module-owned backing store for the status slot inherited from MoonModule. // The base class only holds a const char* into this buffer (see @@ -653,7 +750,7 @@ class NetworkModule : public MoonModule { // ~54 on any ESP32-family chip, so int8 is ample — bound via addPin (Pin control // → number input). ethConfigDefault's fields are plain int; the values are all // small (≤52 / -1) so the copy into int8_t is lossless. - uint8_t ethPhyAddr_ = static_cast<uint8_t>(platform::ethConfigDefault.phyAddr); // PHY MDIO addr 0..31, not a GPIO + int16_t ethPhyAddr_ = static_cast<int16_t>(platform::ethConfigDefault.phyAddr); // PHY MDIO addr 0..31, or -1 = auto-detect (scan the bus). Signed (int16, via addInt16) so -1 round-trips — a uint8 cast the platform's -1 to 255 and the 0..31 control showed 31, a fixed address no PHY answered, so the S31's RGMII never linked. NOT a GPIO (deliberately not addPin, or the pin-map would false-claim it). int8_t ethMdcGpio_ = static_cast<int8_t>(platform::ethConfigDefault.mdcGpio); int8_t ethMdioGpio_ = static_cast<int8_t>(platform::ethConfigDefault.mdioGpio); int8_t ethRstGpio_ = static_cast<int8_t>(platform::ethConfigDefault.rstGpio); @@ -670,6 +767,10 @@ class NetworkModule : public MoonModule { // valid hash output. setup()'s syncEthConfig() sets it before any compare. uint32_t appliedEthSig_ = 0; bool ethSigApplied_ = false; + // Last-applied addressing signature (mode + static octets), same guard shape as ethSig — so + // syncAddressingLive re-applies only on a real DHCP↔Static / static-field change. + uint32_t appliedAddressingSig_ = 0; + bool addressingSigApplied_ = false; // A cheap order-sensitive hash of the eth control members — changes whenever // any eth control does, so tick1s() can detect a live reconfigure. uint32_t so @@ -681,7 +782,7 @@ class NetworkModule : public MoonModule { ethSpiSck_, ethSpiCs_, ethSpiIrq_}) { h = h * 131u + static_cast<uint32_t>(v); } - h = h * 131u + ethPhyAddr_; // PHY addr (uint8, not a GPIO), folded in separately + h = h * 131u + static_cast<uint32_t>(ethPhyAddr_ & 0xFF); // PHY addr (int16, -1=auto; not a GPIO), folded in separately h = h * 131u + (ethClockExtIn_ ? 1u : 0u); // bool, folded in separately return h; } @@ -751,6 +852,42 @@ class NetworkModule : public MoonModule { } } + // Hot-apply a DHCP↔Static change (or an edit to the static fields while in Static mode) on the + // active interface, no reboot — the "every setting takes effect live" principle. Same + // change-detect shape as syncTxPower/syncEthLive: a signature over addressing_ + the static + // octets, compared to the last applied one. Static → pin the config; DHCP → restart the client + // so it re-leases. Only touches whichever interface is currently connected. + void syncAddressingLive() { + uint32_t sig = addressingSig(); + if (addressingSigApplied_ && sig == appliedAddressingSig_) return; // nothing changed + appliedAddressingSig_ = sig; + addressingSigApplied_ = true; + + platform::NetIface iface; + if (state_ == State::ConnectedEth) iface = platform::NetIface::Eth; + else if constexpr (platform::hasWiFi) { + if (state_ != State::ConnectedSta) return; // not on a client interface: nothing to apply + iface = platform::NetIface::Sta; + } else return; + + if (addressing_ == kAddressingStatic) { + applyStaticIfConfigured(iface); + } else { + platform::netSetDhcp(iface); // Static → DHCP: re-lease live + } + updateStatusIP(); // reflect the new address (static IP, or the re-leased one once it lands) + } + + // Signature folding addressing mode + the four static octets, so an edit to any of them (in + // Static mode) re-triggers syncAddressingLive. Cheap FNV-style roll, same idea as ethSig(). + uint32_t addressingSig() const { + uint32_t h = 2166136261u; + auto fold = [&](uint8_t b) { h = (h ^ b) * 16777619u; }; + fold(addressing_); + for (int i = 0; i < 4; i++) { fold(staticIp_[i]); fold(staticGateway_[i]); fold(staticSubnet_[i]); fold(staticDns_[i]); } + return h; + } + static constexpr const char* addressingOptions_[] = {"DHCP", "Static"}; // ethType dropdown options — index order MUST match the EthPhyType enum // (None=0, LAN8720=1, IP101=2, W5500=3, YT8531=4) since the Select stores the index. @@ -782,8 +919,13 @@ class NetworkModule : public MoonModule { void onConnected(const char* via) { if (std::strcmp(via, "Ethernet") == 0) { state_ = State::ConnectedEth; + ethDegraded_ = false; // Ethernet itself got a lease — no longer degraded } else { state_ = State::ConnectedSta; + // Static mode on WiFi: the STA is associated (wifiStaConnected) but its address comes + // from us, not DHCP — pin it now, before updateStatusIP reads the netif. (Ethernet's + // static apply already happened in WaitingEth, where it also set ethConnected.) + if constexpr (platform::hasWiFi) applyStaticIfConfigured(platform::NetIface::Sta); } stateChangeTime_ = platform::millis(); @@ -835,6 +977,15 @@ class NetworkModule : public MoonModule { } private: + /// If the user selected Static addressing, pin the configured IP/gw/mask/dns onto the given + /// interface (the platform stops its DHCP client and sets the address); a no-op in DHCP mode. + /// Called at each interface's bring-up transition — the one place that turns the `addressing` + /// dropdown + static-IP controls into an actually-applied config, for both STA and Ethernet. + void applyStaticIfConfigured(platform::NetIface iface) { + if (addressing_ != kAddressingStatic) return; // DHCP mode: leave the client running + platform::netSetStaticIPv4(iface, staticIp_, staticGateway_, staticSubnet_, staticDns_); + } + /// The device's network name is owned solely by SystemModule; NetworkModule only /// READS it. This is the single identity behind every network name — the mDNS /// `<name>.local`, the SoftAP SSID, and the DHCP hostname are all this exact string, @@ -846,7 +997,28 @@ class NetworkModule : public MoonModule { const char* readDeviceName() const { return systemModule_ ? systemModule_->deviceName() : ""; } + // The Ethernet-degraded warning: link is up but no address was assigned. Shown (Warning + // severity) in preference to a WiFi/AP-fallback "connected" line, because a live-but-unusable + // cable is worth flagging until the user unplugs it. Reports any address that WAS captured + // (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() { + uint8_t ip[4] = {}; + platform::ethGetIPv4(ip); + if (ip[0] || ip[1] || ip[2] || ip[3]) { + char ipStr[16]; formatDottedQuad(ipStr, ip); + std::snprintf(statusBuf_, sizeof(statusBuf_), "Ethernet detected (%s): no lease", ipStr); + } else { + std::snprintf(statusBuf_, sizeof(statusBuf_), "Ethernet detected: no address assigned"); + } + setStatus(statusBuf_, Severity::Warning); + } + void updateStatusIP() { + // A live-but-leaseless Ethernet cable outranks the WiFi/AP-fallback IP line: keep the + // warning up so it isn't buried the moment the cascade connects WiFi. (On Ethernet itself, + // ethDegraded_ is false — a real ConnectedEth means a lease succeeded.) + if (ethDegraded_ && state_ != State::ConnectedEth) { writeEthDegradedStatus(); return; } uint8_t ip[4]; currentIp(ip); // same eth/wifi getter dispatch, in one place if (!ip[0] && !ip[1] && !ip[2] && !ip[3]) return; // not connected — keep prior status diff --git a/src/core/PinsModule.h b/src/core/PinsModule.h index 7d06804f..5f0dc1ce 100644 --- a/src/core/PinsModule.h +++ b/src/core/PinsModule.h @@ -137,7 +137,7 @@ class PinsModule : public MoonModule { sink.append(",\"role\":"); sink.writeJsonString(c.role); // Emit `severity` only when the pin is unsafe — the UI keys its generic per-row warning - // colour on this field (like the `*Sec` age convention); a safe pin omits it, no colour. + // color on this field (like the `*Sec` age convention); a safe pin omits it, no color. if (c.severity) { sink.append(",\"severity\":"); sink.writeJsonString(c.severity); @@ -180,7 +180,7 @@ class PinsModule : public MoonModule { // Row detail = every claim on this row's GPIO (as "owner · role" chips — a double-claim lists all // co-owners), plus a `warning` line explaining WHY the row is flagged when it is (boot strap, - // reserved, input-only, conflict — so the coloured edge isn't just "something's wrong" but names + // reserved, input-only, conflict — so the colored edge isn't just "something's wrong" but names // it). Scalar fields, so the generic list-detail UI renders the chips + the warning line with no // pins-specific UI code. void writeListRowDetail(JsonSink& sink, uint8_t row) const override { @@ -278,7 +278,7 @@ class PinsModule : public MoonModule { } // Grade a claim against the pin's hardware capability (platform::gpioCapability), setting BOTH - // its severity (the row colour) and its reason (shown when the row is expanded — the *why*). In + // its severity (the row color) and its reason (shown when the row is expanded — the *why*). In // priority order: reserved (flash/PSRAM/USB) or invalid GPIO → "error" for ANY role (routing I/O // there corrupts the device / isn't a pin); a driven role on an input-only pin or a boot strap → // "warn" (the GPIO-46-strap class of bug). An input role on an input-only pin is fine (no flag). diff --git a/src/core/ScratchBuffer.h b/src/core/ScratchBuffer.h index 8a3e65bd..d6be34de 100644 --- a/src/core/ScratchBuffer.h +++ b/src/core/ScratchBuffer.h @@ -19,10 +19,10 @@ class MoonModule; // forward — the full definition is only needed in ScratchB /// effect writes `heat_.resize(n)` and nothing else — the primitive is the free-helper, /// the destructor, the `setDynamicBytes` call, and the null-guard, all at once. class ScratchBufferBase { -protected: - explicit ScratchBufferBase(MoonModule& owner); // registers with owner (.cpp) - ~ScratchBufferBase(); // frees + deregisters (.cpp) - +public: + // Public, though the constructors below are protected: a deleted-but-private member makes the + // compiler say "inaccessible", which sends the reader hunting for an access fix that does not + // exist. Public says "deleted" — the accurate diagnostic. Standard rule-of-five placement. ScratchBufferBase(const ScratchBufferBase&) = delete; ScratchBufferBase& operator=(const ScratchBufferBase&) = delete; // Move is NOT provided: a ScratchBuffer is a fixed member of its module, tied by @@ -32,6 +32,10 @@ class ScratchBufferBase { ScratchBufferBase(ScratchBufferBase&&) = delete; ScratchBufferBase& operator=(ScratchBufferBase&&) = delete; +protected: + explicit ScratchBufferBase(MoonModule& owner); // registers with owner (.cpp) + ~ScratchBufferBase(); // frees + deregisters (.cpp) + /// Size to exactly `bytes` (0 frees), reallocating only when the byte count /// changes. Zero-fills on (re)alloc. Adjusts owner_'s dynamic-bytes total by /// (new - old). Returns true iff the buffer holds `bytes` afterwards (bytes==0 diff --git a/src/core/WledPacket.h b/src/core/WledPacket.h index 9ded679f..4dee493f 100644 --- a/src/core/WledPacket.h +++ b/src/core/WledPacket.h @@ -77,6 +77,9 @@ struct WledPacket { if (name) { size_t n = std::strlen(name); if (n > kNameMax) n = kNameMax; // bytes 6..37, null-padded by the memset + // `out` is memset to 0 above, so the name is null-padded by construction; there is + // no terminator to copy. + // NOLINTNEXTLINE(bugprone-not-null-terminated-result) std::memcpy(out + kNameOff, name, n); } out[kTypeOff] = static_cast<uint8_t>((boardType & 0x7f) | (lightsOn ? 0x80 : 0)); diff --git a/src/core/color.h b/src/core/color.h index 874b37d0..70298d7a 100644 --- a/src/core/color.h +++ b/src/core/color.h @@ -34,7 +34,7 @@ constexpr RGB hsvToRgb(uint8_t h, uint8_t s, uint8_t v) { } // scale8: (val * scale) / 256, with +1 correction so scale8(x, 255) == x. The fundamental -// channel-scale op (brightness, blend), kept here with the colour type it scales. Integer +// channel-scale op (brightness, blend), kept here with the color type it scales. Integer // trig (sin8/cos8/atan2_8/dist8) and the rest of the 8-bit math library live in math8.h. constexpr uint8_t scale8(uint8_t val, uint8_t scale) { return static_cast<uint8_t>(((static_cast<uint16_t>(val) * static_cast<uint16_t>(scale)) + 1 + ((static_cast<uint16_t>(val) * static_cast<uint16_t>(scale)) >> 8)) >> 8); diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h index 1a175457..a3280dc8 100644 --- a/src/core/moonlive/MoonLive.h +++ b/src/core/moonlive/MoonLive.h @@ -28,7 +28,7 @@ class MoonLive { MoonLive(const MoonLive&) = delete; MoonLive& operator=(const MoonLive&) = delete; - // Compile a fixed-colour program direct from the emitter: emit the routine, copy it into + // Compile a fixed-color program direct from the emitter: emit the routine, copy it into // an exec block, ready it to call. Returns ok(). A failure (no exec memory, emit too big) // leaves the engine !ok() with an error() — the caller degrades, never crashes. bool compile(uint8_t r, uint8_t g, uint8_t b); @@ -39,14 +39,14 @@ class MoonLive { // !ok() with error() pointing at the diagnostic — the script editor's failure path. bool compile(const char* source, const BuiltinTable& table); - // Compile the animated routine (colour derived from the per-frame `t`). + // Compile the animated routine (color derived from the per-frame `t`). bool compileAnimated(); bool ok() const { return fn_ != nullptr || anim_ != nullptr || ctrl_ != nullptr; } const char* error() const { return error_; } // The hot path: run the compiled routine over the host's buffer. `t` is the host's - // elapsed() ms; a static routine ignores it, an animated one derives its colour from + // elapsed() ms; a static routine ignores it, an animated one derives its color from // it. No-op if !ok() (a failed compile renders nothing). The emitted routines write // channels +0/+1/+2 per light, so a buffer that can't hold RGB — null, zero lights, or // fewer than 3 channels per light — is left untouched rather than overrun (robust to any diff --git a/src/core/moonlive/moonlive_emit.h b/src/core/moonlive/moonlive_emit.h index ce0c1309..4993b380 100644 --- a/src/core/moonlive/moonlive_emit.h +++ b/src/core/moonlive/moonlive_emit.h @@ -37,14 +37,14 @@ using AnimFn = void (*)(uint8_t* buf, uint32_t nLights, uint8_t cpl, uint32_t t) // signature compileSource()'d code is called through. using CtrlFn = void (*)(uint8_t* buf, uint32_t nLights, uint8_t cpl, uint32_t t, const uint8_t* ctrls); -// Emit the fixed-colour fill routine's machine code into `out` (capacity `cap` bytes), for -// the ISA this translation unit was compiled for, with the colour baked in. Returns the +// Emit the fixed-color fill routine's machine code into `out` (capacity `cap` bytes), for +// the ISA this translation unit was compiled for, with the color baked in. Returns the // number of bytes written, or 0 if `cap` is too small (the caller degrades). The emitted // bytes ARE the function — the engine makes `out` executable and casts it to FillFn. The // parser-driven codegen (MoonLiveCompiler) reproduces these exact bytes (the golden-bytes test). size_t emitFill(uint8_t* out, size_t cap, uint8_t r, uint8_t g, uint8_t b); -// Emit a routine that derives its colour from the runtime arg `t` — +// Emit a routine that derives its color from the runtime arg `t` — // red = (t >> 3) & 0xFF, green = 0, blue = 64 for every light. // Proves a per-frame host value flows into the emitted native code and changes the output // (the grid's red ramps over time). Same emit/exec/call path as emitFill, one extra arg. diff --git a/src/light/ChannelRole.h b/src/light/ChannelRole.h index b2e19779..01d4b22b 100644 --- a/src/light/ChannelRole.h +++ b/src/light/ChannelRole.h @@ -5,26 +5,26 @@ namespace mm { // What a single output channel of a light carries. A light is a run of channels, and this -// names the role of each one — the colour roles a strip/panel needs (Red/Green/Blue/White) +// names the role of each one — the color roles a strip/panel needs (Red/Green/Blue/White) // plus the fixture roles a moving head adds (Pan/Tilt/…). It is the shared vocabulary two // sides use: Correction describes a light's wiring as an array of these (roles[i] = what // channel i is), and the effect-side role writers (setRGB/setPan/…) name the role they drive. // One enum, referenced everywhere, so the wiring description and the writers can't drift. // // None marks a channel that carries no role we drive (a spacer, or a fixture channel set -// elsewhere). The colour roles come first so a plain RGB(W) light only ever uses the low +// elsewhere). The color roles come first so a plain RGB(W) light only ever uses the low // values; the fixture roles extend the list without disturbing them. // // APPEND-ONLY: a persisted preset stores each channel's role as this enum's byte value, so a role -// must NEVER be renumbered — a new one is appended within its group (colour roles before the +// must NEVER be renumbered — a new one is appended within its group (color roles before the // fixture roles) and existing values keep their index. White = a normal/cold white; WarmWhite is -// the second white a CCT fixture adds. Yellow/UV are the extra par-can colours (a 6-channel +// the second white a CCT fixture adds. Yellow/UV are the extra par-can colors (a 6-channel // RGBWYP lightbar). Extending here is safe because the fixture roles below carry no persisted // data yet (no seeded built-in uses them, no effect writes them), so shifting them is a no-op; a // future persisted fixture role would force new roles to the very end instead. enum class ChannelRole : uint8_t { None, - Red, Green, Blue, White, WarmWhite, Yellow, UV, // colour roles — strips/panels/PARs (White = cold) + Red, Green, Blue, White, WarmWhite, Yellow, UV, // color roles — strips/panels/PARs (White = cold) Pan, Tilt, Zoom, Rotate, Gobo, Dimmer, // fixture roles — moving heads }; diff --git a/src/light/Palette.h b/src/light/Palette.h index 8aea6e12..4d8c7e35 100644 --- a/src/light/Palette.h +++ b/src/light/Palette.h @@ -8,7 +8,7 @@ namespace mm { -// A colour palette: the active palette is 16 evenly-spaced RGB entries (the CRGBPalette16 model), +// A color palette: the active palette is 16 evenly-spaced RGB entries (the CRGBPalette16 model), // and colorFromPalette() reads a 0-255 wheel index by interpolating between the two bracketing // entries. The gradient definitions (a {pos,R,G,B,…} stop list) live in flash and expand into the // 16 entries on selection, off the hot path; the per-light lookup is then a single scale8 blend. @@ -35,7 +35,7 @@ struct Palette { } private: - // The colour at `pos` (0..255) on the gradient: find the bracketing stops and lerp. + // The color at `pos` (0..255) on the gradient: find the bracketing stops and lerp. static RGB sampleGradient(const uint8_t* stops, size_t nStops, uint8_t pos) { // Before the first stop (a gradient whose first stop sits above 0): clamp to it, so the // `pos - p0` below can't underflow. @@ -82,11 +82,11 @@ inline RGB colorFromPalette(const Palette& p, uint8_t index, uint8_t brightness return c; } -// Cross-fade two colours: `amt`/255 of the way from `a` to `b` (amt 0 = a, 255 = b). The textbook +// Cross-fade two colors: `amt`/255 of the way from `a` to `b` (amt 0 = a, 255 = b). The textbook // RGB lerp, the staple for compositing/transitions. Prior art: FastLED's blend (colorutils). inline RGB blend(RGB a, RGB b, uint8_t amt) { return Palette::lerpRGB(a, b, amt); } -// Dim a colour toward black by `amt`/255 (amt 0 = unchanged, 255 = black) — the per-frame fade +// Dim a color toward black by `amt`/255 (amt 0 = unchanged, 255 = black) — the per-frame fade // that gives effects a decaying trail. Prior art: FastLED's fadeToBlackBy. inline void fadeToBlackBy(RGB& c, uint8_t amt) { const uint8_t keep = static_cast<uint8_t>(255 - amt); @@ -234,11 +234,11 @@ class Palettes { return p; } - // --- Representative colour of a palette (for the HomeKit color-wheel → palette mapping) --- + // --- Representative color of a palette (for the HomeKit color-wheel → palette mapping) --- // - // HomeKit (via MQTT/Homebridge) has no "palette" concept but a native colour wheel, so we map a - // wheel colour to the nearest palette. Each palette's representative (hue, sat) is COMPUTED from - // its expanded entries — the textbook "dominant colour": average the 16 RGB entries, convert to + // HomeKit (via MQTT/Homebridge) has no "palette" concept but a native color wheel, so we map a + // wheel color to the nearest palette. Each palette's representative (hue, sat) is COMPUTED from + // its expanded entries — the textbook "dominant color": average the 16 RGB entries, convert to // HSV. No hand-maintained table (it auto-covers every built-in + any future one). A rainbow/ // multi-hue palette averages toward grey → low saturation, which correctly matches the wheel's // desaturated centre rather than any single hue. Off the hot path (called on an MQTT set / get). @@ -250,9 +250,9 @@ class Palettes { return hue; } - // Representative RGB colour of built-in `index` — the palette's identity colour, useful anywhere + // Representative RGB color of built-in `index` — the palette's identity color, useful anywhere // an external surface (HA WLED /json seg[0].col, an MQTT hsv/get with an RGB-shaped payload, - // a future HomeKit RGB accessory) needs one RGB triple to name "the colour of this palette". + // a future HomeKit RGB accessory) needs one RGB triple to name "the color of this palette". // Full V=255 in HSV, so the reported hue survives external dimming applied on top (HA's slider // multiplies segment.bri × state.bri, so baking in brightness here would double-dim). // Rainbow / grey palettes (sat=0) intentionally resolve to white (255,255,255) — the honest @@ -267,8 +267,8 @@ class Palettes { static_cast<uint8_t>(sat), 255); } - // RGB → nearest palette, the one call every RGB-input consumer (HA's WLED colour picker via - // /json/state, an ESP-NOW / REST colour message, a future BLE-mesh command …) should reach for + // RGB → nearest palette, the one call every RGB-input consumer (HA's WLED color picker via + // /json/state, an ESP-NOW / REST color message, a future BLE-mesh command …) should reach for // instead of open-coding the RGB→HSV conversion. Converts to (hue, sat) with the same maths // representativeHueSat uses on the palette side — so the input and the palette centroids are // measured on the same axes — then delegates to nearestForHue for the 2D distance sweep. @@ -332,13 +332,13 @@ class Palettes { hue = static_cast<uint16_t>(h % 360); } - // Default to a full rainbow (index 0): always colourful, so an effect renders visible output + // Default to a full rainbow (index 0): always colorful, so an effect renders visible output // before any palette is selected. setActive() (Drivers setup) overrides from the saved index. static inline Palette active_ = fromBuiltin(0); }; // Emit the palette dropdown's options for a ControlType::Palette control (the PaletteOptionsFn): -// one {"name":…,"colors":"rrggbb rrggbb …"} object per built-in, the colours being the 16 entries +// one {"name":…,"colors":"rrggbb rrggbb …"} object per built-in, the colors being the 16 entries // as space-separated hex so the UI renders each option as a gradient swatch. inline void paletteOptions(JsonSink& sink) { for (uint8_t i = 0; i < palettes::kCount; i++) { @@ -353,7 +353,7 @@ inline void paletteOptions(JsonSink& sink) { // Emit the built-in palette names as a bare JSON string array (`"Default","Rainbow",…`), the // element list WLED's /json `palettes` array carries — HA's WLED integration renders one dropdown // entry per name, indexed by position (so seg[0].pal picks into this list). Same source of truth as -// paletteOptions (kBuiltins), just names without the colour swatches the native UI needs. +// paletteOptions (kBuiltins), just names without the color swatches the native UI needs. inline void paletteNames(JsonSink& sink) { for (uint8_t i = 0; i < palettes::kCount; i++) sink.appendf("%s\"%s\"", i > 0 ? "," : "", palettes::kBuiltins[i].name); diff --git a/src/light/draw.h b/src/light/draw.h index 246bc81d..89d96839 100644 --- a/src/light/draw.h +++ b/src/light/draw.h @@ -20,8 +20,7 @@ // engine: off = (z·h·w + y·w + x)·cpl. A pixel outside [0,w)×[0,h)×[0,d) is silently clipped, so // a line that runs off the grid just stops drawing — no out-of-bounds write (the robustness rule). -namespace mm { -namespace draw { +namespace mm::draw { // One pixel, clipped to the grid. Writes R/G/B where channels fit (cpl may be 1..N); extra // channels (e.g. a W in RGBW) are left as-is — the driver derives white, same as effects do. @@ -67,6 +66,9 @@ inline void line(Buffer& buf, Coord3D dims, Coord3D a, Coord3D b, RGB c, uint8_t const lengthType sz = b.z >= a.z ? 1 : -1; // Drive the loop off the longest axis; accumulate error toward the other two. + // The `if ((e = e - d) < 0)` step-and-test below is Bresenham's canonical form — the assignment + // inside the condition IS the algorithm, and splitting it reads worse than the textbook. + // NOLINTBEGIN(bugprone-assignment-in-if-condition) if (dx >= dy && dx >= dz) { lengthType ey = static_cast<lengthType>(dx / 2), ez = ey; for (;; p.x = static_cast<lengthType>(p.x + sx)) { @@ -92,6 +94,7 @@ inline void line(Buffer& buf, Coord3D dims, Coord3D a, Coord3D b, RGB c, uint8_t if ((ey = static_cast<lengthType>(ey - dy)) < 0) { ey = static_cast<lengthType>(ey + dz); p.y = static_cast<lengthType>(p.y + sy); } } } + // NOLINTEND(bugprone-assignment-in-if-condition) } // --- Buffer read/modify helpers -------------------------------------------- @@ -110,7 +113,7 @@ inline RGB get(const Buffer& buf, Coord3D dims, Coord3D p) { return {d[off + 0], d[off + 1], d[off + 2]}; } -// Blend a colour into a pixel by amt/255 (amt 0 = leave as-is, 255 = replace). The in-place +// Blend a color into a pixel by amt/255 (amt 0 = leave as-is, 255 = replace). The in-place // read-modify-write that GoL's dead-cell fade-to-background and age-toward-red use // (MoonLight's blendColor). Clipped like pixel(). inline void blendPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c, uint8_t amt) { @@ -122,7 +125,7 @@ inline void blendPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c, uint8_t amt) d[off + 0] = out.r; d[off + 1] = out.g; d[off + 2] = out.b; } -// Add a colour into a pixel, saturating (a bright pixel can't wrap to dark) — WLED's addRGB / additive +// Add a color into a pixel, saturating (a bright pixel can't wrap to dark) — WLED's addRGB / additive // setPixelColor. Used to re-stamp a light on top of a blur so its centre stays bright. Clipped like pixel(). inline void addPixel(Buffer& buf, Coord3D dims, Coord3D p, RGB c) { const size_t off = offsetOf(buf, dims, p); @@ -208,10 +211,10 @@ inline void blur(Buffer& buf, Coord3D dims, uint8_t amt) { if (z > 1) blurAxis(d, cpl, z, w * h * cpl, w * h, cpl, amt); } -// Fill the whole buffer with one colour (MoonLight's fill_solid). +// Fill the whole buffer with one color (MoonLight's fill_solid). inline void fill(Buffer& buf, RGB c) { const uint8_t cpl = buf.channelsPerLight(); - if (cpl == 0) return; // a 0-channel buffer has no colour to write; guards off += 0 spinning + if (cpl == 0) return; // a 0-channel buffer has no color to write; guards off += 0 spinning uint8_t* d = buf.data(); const size_t n = buf.bytes(); for (size_t off = 0; off + cpl <= n; off += cpl) { @@ -260,5 +263,4 @@ inline lengthType text(Buffer& buf, Coord3D dims, const fonts::Font& font, const return onFirstLine ? static_cast<lengthType>(cx - x) : firstLineWidth; } -} // namespace draw -} // namespace mm +} // namespace mm::draw diff --git a/src/light/drivers/Drivers.h b/src/light/drivers/Drivers.h index 54735e06..4968308f 100644 --- a/src/light/drivers/Drivers.h +++ b/src/light/drivers/Drivers.h @@ -168,10 +168,10 @@ class Drivers : public MoonModule { // per driver on DriverBase (defineCorrectionControls) — a GRB strip and an RGBW panel on // the same board each carry their own preset. The container owns only the GLOBAL brightness // above, which each driver's LUT multiplies with its local brightness. - /// The global active colour palette (index into `mm::palettes::kBuiltins`; + /// The global active color palette (index into `mm::palettes::kBuiltins`; /// `Rainbow`, `Party`, `Lava`, `Ocean`, …). Palette-driven effects read it via - /// `Palettes::active()` and colour their pixels through `colorFromPalette(index)`, so - /// changing this recolours every such effect live. The select index expands the chosen + /// `Palettes::active()` and color their pixels through `colorFromPalette(index)`, so + /// changing this recolors every such effect live. The select index expands the chosen /// gradient into the active 16-entry palette on `onControlChanged` (cheap, off the hot path). uint8_t palette = 0; @@ -366,7 +366,7 @@ class Drivers : public MoonModule { passBufferToDrivers(); } - // First output light as RGB — the live colour of pixel 0, read from whichever buffer + // First output light as RGB — the live color of pixel 0, read from whichever buffer // tick() is currently driving (the composited outputBuffer_ when allocated, else the // first enabled layer's own buffer — the zero-copy single-layer path). The WLED shim // tints the app's device card with this. RGB is the buffer's logical channel order diff --git a/src/light/drivers/HueDriver.h b/src/light/drivers/HueDriver.h index 9e6cde65..02f5d396 100644 --- a/src/light/drivers/HueDriver.h +++ b/src/light/drivers/HueDriver.h @@ -11,15 +11,15 @@ namespace mm { /// Output driver: sends the buffer to Philips Hue bulbs as pixels — a driver, not a listed device. /// The bulbs are pixels of an effect: make a small grid (e.g. 4×1×1), run any effect, and this -/// driver reads its window of the shared buffer and pushes each light's colour to the bridge. Same +/// 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 colour, not real-time. The shared output Correction applies as on the LED/network -/// drivers, so the brightness slider and colour-order preset reach the Hue lights too (brightness -/// 0 → light off). Only colour-capable, reachable lights are driven (see `parseLights`); the `room` +/// 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`). /// /// **Wire contract (Hue v1 API, plain HTTP, no TLS — bench-confirmed on a BSB002 bridge, API 1.77):** @@ -66,7 +66,7 @@ class HueDriver : public DriverBase { controls_.addSelect("light", light_, lightOptions_, lightOptionCount_); addWindowControls(); // start / count — its slice of the buffer // The generic "status" line (setStatus) carries the pairing state + driven-of-total light - // count — see refreshStatus(); no separate hueStatus / colourLights controls. + // count — see refreshStatus(); no separate hueStatus / colorLights controls. refreshStatus(); } @@ -147,14 +147,14 @@ class HueDriver : public DriverBase { return true; } - // Test seam: parse a real /lights JSON body through fetchLights' colour-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 colour+reachable lights + uint8_t lightCountForTest() const { return lightCount_; } // kept color+reachable lights uint16_t hueIdForTest(uint8_t i) const { return i < kMaxLights ? hueId_[i] : 0; } 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 colour lights (hueId_), + // 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") @@ -180,17 +180,17 @@ class HueDriver : public DriverBase { static constexpr uint8_t kMaxLights = 32; // a LAN's worth of Hue bulbs; bounded, no heap static constexpr uint8_t kMaxRooms = 16; // bounded room count; option index 0 is "All" static constexpr uint8_t kNameLen = 24; // per-light / per-room friendly-name buffer - // kMaxLights == 32 == the width of a uint32_t, so a Room's colour-light membership fits one - // bitmask (bit i ⇔ colour light hueId_[i]) — resolved at parse time, since fetchGroups runs + // kMaxLights == 32 == the width of a uint32_t, so a Room's color-light membership fits one + // bitmask (bit i ⇔ color light hueId_[i]) — resolved at parse time, since fetchGroups runs // after fetchLights (the sawGroups_ gate), so hueId_ is already populated. A bitmask is the // textbook small-set membership (a bit test replaces a per-id scan), and 16×4 B = 64 B beats a // 16×32 id-list's 1 KB inline. static_assert pins the width assumption. - static_assert(kMaxLights == 32, "Room membership bitmask (roomMask_) assumes 32 colour lights"); + static_assert(kMaxLights == 32, "Room membership bitmask (roomMask_) assumes 32 color lights"); // One PUT at most every kPutIntervalMs (a millis() gate in tick()). Each PUT opens a fresh // TCP connection (the bridge speaks Connection: close), so the rate is bounded by connection // CHURN, not just Hue's command budget: at ~7/s the TIME_WAIT sockets pile into the hundreds // and the bridge starts refusing connections (PUTs fail, lights freeze). 500 ms → ~2 PUTs/s - // keeps TIME_WAIT small and is plenty for smooth ambient colour (each light glides over its + // keeps TIME_WAIT small and is plenty for smooth ambient color (each light glides over its // ~2 s refresh via the matched transitiontime). Real-time would need keep-alive or the // Entertainment API — out of scope; this is the standard API's comfortable rate. static constexpr uint32_t kPutIntervalMs = 500; @@ -211,10 +211,10 @@ class HueDriver : public DriverBase { uint16_t hueId_[kMaxLights] = {}; uint8_t lastRgb_[kMaxLights][3] = {}; bool sent_[kMaxLights] = {}; // have we pushed this light at least once - // hueId_ holds ONLY colour-capable lights (the bridge's "Extended color light"s) — a + // 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 colour. lightCount_ is that filtered count. - uint8_t lightCount_ = 0; // number of colour-capable lights + // 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 @@ -240,15 +240,15 @@ class HueDriver : public DriverBase { platform::free(roomNames_); roomNames_ = nullptr; } - // --- Rooms (GET /api/<key>/groups, type=="Room"): name + a colour-light membership bitmask. - uint32_t roomMask_[kMaxRooms] = {}; // bit i set ⇔ this Room references colour light hueId_[i] + // --- 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] = {}; // colour-light array-indices actually driven (after filter) + 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 @@ -275,7 +275,7 @@ class HueDriver : public DriverBase { 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 colour light has "hue"; the bridge omits it otherwise). + // 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++) @@ -309,8 +309,8 @@ class HueDriver : public DriverBase { } // The single status line, folding what were three separate controls (status / hueStatus / - // colourLights). Shows the pairing state and the light count as driven-of-total: "paired, - // 3-4 lights" = the room/light filter narrowed 4 colour lights to 3 driven. When nothing is + // 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"); @@ -434,12 +434,12 @@ class HueDriver : public DriverBase { dev->upsertHueBridge(bridgeIp, name, static_cast<uint8_t>(colorCount_)); } - // Extract the COLOUR-capable, REACHABLE light ids from a /lights JSON body: - // {"1":{…},"5":{…},…}. A colour light's object carries a "hue" field in its state; a + // 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 colour-capable and reachable + // 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 colour bulb. The bridge response (~8 KB / hundreds of fields) exceeds the + // 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. void parseLights(const char* resp) { @@ -477,7 +477,7 @@ class HueDriver : public DriverBase { commit(resp + std::strlen(resp)); // the last light runs to the end sawLights_ = true; colorCount_ = static_cast<int8_t>(lightCount_ > 127 ? 127 : lightCount_); - rebuildDriven(); // the colour-light set changed → re-derive the filtered driven subset + 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 @@ -540,9 +540,9 @@ class HueDriver : public DriverBase { sawGroups_ = true; } - // Resolve a Room's "lights":["3","5",…] array (within [begin, end)) to a colour-light - // membership bitmask: for each listed bridge id, set bit i if it equals a kept colour light - // hueId_[i]. Ids the Room lists that aren't colour-capable (a white bulb, a plug) simply don't + // 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 { @@ -553,7 +553,7 @@ class HueDriver : public DriverBase { for (const char* q = s; q < end && *q != ']'; ) { if (*q == '"') { const int id = std::atoi(q + 1); - for (uint8_t i = 0; i < lightCount_; i++) // map the id to its colour-light bit + 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 if (!c || c >= end) break; @@ -563,8 +563,8 @@ class HueDriver : public DriverBase { return mask; } - // The colour-light array-indices (into hueId_ / lightName_) that the CURRENT room selection - // exposes: room_==0 ("All") → every colour light, in order; else only the colour lights whose + // 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. @@ -573,14 +573,14 @@ class HueDriver : public DriverBase { // 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. - uint8_t roomColourLights(uint8_t (&out)[kMaxLights]) const { + uint8_t roomColorLights(uint8_t (&out)[kMaxLights]) const { uint8_t n = 0; - if (room_ == 0 || room_ > roomCount_) { // "All" (or a stale index) → every colour light + if (room_ == 0 || room_ > roomCount_) { // "All" (or a stale index) → every color light for (uint8_t i = 0; i < lightCount_ && n < kMaxLights; i++) out[n++] = i; return n; } const uint32_t mask = roomMask_[room_ - 1]; - for (uint8_t i = 0; i < lightCount_ && n < kMaxLights; i++) // keep colour lights in this Room's bitmask + for (uint8_t i = 0; i < lightCount_ && n < kMaxLights; i++) // keep color lights in this Room's bitmask if (mask & (1u << i)) out[n++] = i; return n; } @@ -593,26 +593,26 @@ class HueDriver : public DriverBase { roomOptionCount_ = n; } - // Rebuild the light dropdown options: {"All", <names of the current room's colour lights>}, + // 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]; - const uint8_t m = roomColourLights(idx); + const uint8_t m = roomColorLights(idx); uint8_t n = 1; for (uint8_t i = 0; i < m && n <= kMaxLights; i++) lightOptions_[n++] = lightNameAt(idx[i]); lightOptionCount_ = n; } // Derive drivenIdx_ from the current room+light filter — the subset pushOneChangedLight walks. - // room=All & light=All → every colour light (the original behaviour, unchanged). - // room=X → that room's colour lights. + // 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]; - const uint8_t m = roomColourLights(idx); + const uint8_t m = roomColorLights(idx); if (light_ == 0 || light_ > m) { // "All" within the (possibly room-narrowed) set for (uint8_t i = 0; i < m; i++) drivenIdx_[drivenLightCount_++] = idx[i]; } else { // a single light: the (light_-1)-th listed one @@ -632,18 +632,18 @@ class HueDriver : public DriverBase { const uint8_t cpl = sourceBuffer_->channelsPerLight(); if (cpl < 3) return; const uint8_t* base = sourceBuffer_->data(); - // Walk the FILTERED driven set (drivenIdx_), not every colour light: room=All & light=All - // makes it the full colour-light set (unchanged behaviour), a room/light pick narrows it. + // Walk the FILTERED driven set (drivenIdx_), not every color light: room=All & light=All + // makes it the full color-light set (unchanged behaviour), a room/light pick narrows it. const uint8_t n = drivenLightCount_ < winLen ? drivenLightCount_ : static_cast<uint8_t>(winLen); if (n == 0) return; drivenCount_ = n; // the round-robin size — drives the Hue fade time (transitionDeciseconds) for (uint8_t step = 0; step < n; step++) { const uint8_t i = (pushCursor_ + step) % n; // position within the driven window - const uint8_t li = drivenIdx_[i]; // the colour-light array index it maps to + const uint8_t li = drivenIdx_[i]; // the color-light array index it maps to const uint8_t* px = base + static_cast<size_t>(winStart + i) * cpl; // Apply the shared Correction (brightness LUT + channel order) so the global - // brightness slider and a swapped colour order reach Hue too — same as the physical + // brightness slider and a swapped color order reach Hue too — same as the physical // drivers. apply() writes outChannels bytes; we read the first three (RGB) for HSV. uint8_t rgb[4] = { px[0], px[1], px[2], 0 }; correction_.apply(px, rgb); @@ -667,16 +667,16 @@ class HueDriver : public DriverBase { } // 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 colour- - // capable (parseLights keeps only those), so the body carries the full colour: on/off, plus - // bri (value) + hue + sat from a textbook RGB→HSV — so a colour effect actually animates. + // `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 colour to the next instead of snapping. The bridge's default is + // 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 colour, the standard API's sweet spot. + // API; this is smooth ambient color, the standard API's sweet spot. 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) diff --git a/src/light/drivers/LightPresetsModule.h b/src/light/drivers/LightPresetsModule.h index 9fc8a245..e2eeb21f 100644 --- a/src/light/drivers/LightPresetsModule.h +++ b/src/light/drivers/LightPresetsModule.h @@ -21,7 +21,7 @@ namespace mm { /// /// A "light preset" is a channel-role layout: role `r` at channel `i` says channel `i` of a light /// carries role `r` (Red/Green/Blue/White/WarmWhite/Yellow/UV, and the fixture roles Pan/Tilt/…). -/// A curated set of real fixtures is SEEDED as read-only (`locked`) rows on first boot: the colour +/// A curated set of real fixtures is SEEDED as read-only (`locked`) rows on first boot: the color /// orders (RGB, GRB, BGR, RGBW, GRBW, WRGB), the multi-channel LED/par fixtures (Curtain GRB6, /// Lightbar RGBWYP, RGBCCT, IRGB), and moving heads (MH BeeEyes 15, MH BeTopper 32, MH 19x15W-24). /// A user adds custom named wirings alongside them. A @@ -426,7 +426,7 @@ class LightPresetsModule : public MoonModule, public ListSource { // The curated built-in wirings, each a dense role array of exactly its fixture's width. Data, // not code: a preset of any width seeds directly (the old LightPreset-enum path capped at 4 - // channels via a roles[4] — a wide moving head couldn't be expressed). The colour orders are the + // channels via a roles[4] — a wide moving head couldn't be expressed). The color orders are the // real ones (WS2812 GRB, ws2814 WRGB, sk6812 GRBW, …); RBG/GBR/BRG are deliberately omitted — no // real fixture ships them, so they'd be permutation noise in the built-in list (a user adds a // custom preset if ever needed). The moving-head maps are migrated from MoonLight's DriverNode diff --git a/src/light/drivers/NetworkSendDriver.h b/src/light/drivers/NetworkSendDriver.h index e64bd89c..c48ffce3 100644 --- a/src/light/drivers/NetworkSendDriver.h +++ b/src/light/drivers/NetworkSendDriver.h @@ -62,7 +62,7 @@ namespace mm { /// **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 colours. +/// outputs show identical colors. /// @card NetworkSendDriver.png class NetworkSendDriver : public DriverBase { public: diff --git a/src/light/drivers/ParallelLedDriver.h b/src/light/drivers/ParallelLedDriver.h index 314667ec..8bcfdee9 100644 --- a/src/light/drivers/ParallelLedDriver.h +++ b/src/light/drivers/ParallelLedDriver.h @@ -1139,7 +1139,20 @@ class ParallelLedDriver : public DriverBase { // mid-tick: a use-after-free, the same class the structural-mutation quiesce fixes. The swap is // not a child-array mutation, so it does not pass through MoonModule::quiesceForMutation; reach // the same render-worker hook directly. The trailing reinit()/tick re-engages the split. - MoonModule::notifyQuiesceRender(); + // + // Only when there is a PRIOR backend to free (`peripheral_` set). The hook exists to quiesce the + // worker before the `delete peripheral_` below — so with no backend yet there is nothing to free + // and nothing to guard. This matters because the FIRST swap of a fresh instance (the default- + // peripheral selection in defineDriverControls) runs with `peripheral_` null: a throwaway probe + // that /api/types builds via ModuleFactory to read a type's defaults constructs a ParallelLedDriver + // and hits exactly that path. Firing the hook there tears down the LIVE Drivers' split (the hook + // resolves to it via the static active() seat) for an instance that never had a backend — the "UI + // refresh freezes the LEDs" bug, since /api/types runs on every page load. Gating on `peripheral_` + // fires the notify for precisely the case it protects (a real swap that frees a live backend, whose + // in-flight DMA the worker must be quiesced away from before the free) and skips the harmless + // no-backend first build. (Not gated on parent(): a swap that frees a real backend must quiesce + // regardless of tree attachment — the guard is "is there a backend to protect", not "am I live".) + if (peripheral_) MoonModule::notifyQuiesceRender(); deinit(); // stop any in-flight transfer on the old bus first if (peripheral_) { peripheral_->busDeinit(); diff --git a/src/light/drivers/PreviewDriver.h b/src/light/drivers/PreviewDriver.h index 8c192e4f..c4472382 100644 --- a/src/light/drivers/PreviewDriver.h +++ b/src/light/drivers/PreviewDriver.h @@ -522,6 +522,9 @@ class PreviewDriver : public DriverBase { nrOfLightsType* keptIdx_ = nullptr; // sparse layouts: kept lights' buffer indices, coord-table order nrOfLightsType keptIdxCap_ = 0, keptCount_ = 0; +protected: + // Matches DriverBase's visibility — a private override would silently hide the hook from any + // future caller holding a DriverBase*. ParallelLedDriver keeps it protected for the same reason. /// This driver's heap = the base scratch + the two preview buffers (the resumable-send staging buffer /// and the kept-index cache). Both live only under resumableFrames; summed for the per-module memory /// readout (see DriverBase::driverHeapBytes). PreviewDriver holds no wire_ scratch, but chaining to @@ -531,6 +534,8 @@ class PreviewDriver : public DriverBase { + static_cast<size_t>(keptIdxCap_) * sizeof(nrOfLightsType); } +private: + // Frame cap: the most points one preview frame carries before the spatial-lattice downsample // engages — derived at runtime from free contiguous memory, not a fixed per-board constant // (architecture.md § Scaling to available memory: "sizes determined at runtime based on diff --git a/src/light/drivers/RmtLedDriver.h b/src/light/drivers/RmtLedDriver.h index f5a19808..d973dc4d 100644 --- a/src/light/drivers/RmtLedDriver.h +++ b/src/light/drivers/RmtLedDriver.h @@ -445,6 +445,9 @@ class RmtLedDriver : public DriverBase { if (symbols_) { platform::free(symbols_); symbols_ = nullptr; symbolCap_ = 0; publishHeapBytes(); } } +protected: + // Matches DriverBase's visibility — a private override would silently hide the hook from any + // future caller holding a DriverBase*. ParallelLedDriver keeps it protected for the same reason. /// This driver's heap = the base scratch + the RMT symbol buffer (one word per WS2812 data bit, /// the driver's largest buffer). Summed for the per-module memory readout — see /// DriverBase::driverHeapBytes. @@ -452,6 +455,8 @@ class RmtLedDriver : public DriverBase { return DriverBase::driverHeapBytes() + static_cast<size_t>(symbolCap_) * sizeof(uint32_t); } +private: + // --- loopback self-test (control-driven) --- // Run the one-shot RMT TX→RX loopback on the FIRST pin and report via the diff --git a/src/light/effects/AudioSpectrumEffect.h b/src/light/effects/AudioSpectrumEffect.h index 44278053..59b527b4 100644 --- a/src/light/effects/AudioSpectrumEffect.h +++ b/src/light/effects/AudioSpectrumEffect.h @@ -17,14 +17,14 @@ namespace mm { // bands zero → dark, so it is safe on any target and any grid size (including // 0×0). On a 1D strip (height 1) the bars collapse to per-column brightness. // Author: projectMM original, on the WLED-SR GEQ / spectrum-analyser concept (Andrew Tuline) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h -/// Audio-reactive effect: colours the layer from the 16-band FFT spectrum. +/// Audio-reactive effect: colors the layer from the 16-band FFT spectrum. class AudioSpectrumEffect : public EffectBase { public: const char* tags() const override { return "📊"; } Dim dimensions() const override { return Dim::D2; } // writes the z=0 slice; extrude fills z // 0 = height gradient (green base → red top, the VU look); 1 = per-band hue - // (each column its own colour across the spectrum, the rainbow analyser look). + // (each column its own color across the spectrum, the rainbow analyser look). // Default per-band: the rainbow analyser reads as a spectrum at a glance, which is // what a "spectrum" effect is expected to look like. uint8_t colorMode = 1; @@ -91,8 +91,8 @@ class AudioSpectrumEffect : public EffectBase { ? 1 : static_cast<lengthType>(static_cast<uint32_t>(mag) * specH / 255u); - // Per-band hue: spread the 16 bands across the full colour wheel so - // each column is a distinct colour (bass red → treble violet). + // Per-band hue: spread the 16 bands across the full color wheel so + // each column is a distinct color (bass red → treble violet). const uint8_t bandHue = static_cast<uint8_t>(band * 16); // Spectrum bars sit ABOVE the level row: their bottom is row h-2 when a diff --git a/src/light/effects/AudioVolumeEffect.h b/src/light/effects/AudioVolumeEffect.h index f21e0726..28b20465 100644 --- a/src/light/effects/AudioVolumeEffect.h +++ b/src/light/effects/AudioVolumeEffect.h @@ -6,11 +6,11 @@ namespace mm { // Audio-reactive VU effect: the whole grid pulses with the microphone's sound // level. The simplest audio consumer — one scalar (AudioFrame::level) drives a -// single brightness, a colour shifting from calm to hot as it rises. Reads the +// single brightness, a color shifting from calm to hot as it rises. Reads the // live frame from AudioService::latestFrame(); with no mic (or silence) the frame is // zero and the grid stays dark, so the effect is safe on any target. // Author: projectMM original (VU-meter) -/// Audio-reactive effect: drives brightness/colour from the overall sound level. +/// Audio-reactive effect: drives brightness/color from the overall sound level. class AudioVolumeEffect : public EffectBase { public: const char* tags() const override { return "🔊"; } diff --git a/src/light/effects/BlurzEffect.h b/src/light/effects/BlurzEffect.h index 6919572f..5087fe12 100644 --- a/src/light/effects/BlurzEffect.h +++ b/src/light/effects/BlurzEffect.h @@ -4,10 +4,10 @@ namespace mm { -// Blurz — an audio-reactive "blurred dot" effect. Each frame it lights ONE pixel coloured by the +// Blurz — an audio-reactive "blurred dot" effect. Each frame it lights ONE pixel colored by the // current frequency band's magnitude, then blurs the whole strip, so the dot bleeds into a soft // glowing smear that drifts and fades. A `freqBand` cursor advances one band per frame (0..15, -// wrapping), so over 16 frames the colour cycles through the whole spectrum. Where the lit pixel +// wrapping), so over 16 frames the color cycles through the whole spectrum. Where the lit pixel // lands is the lever: // - freqMap on: the dot's position maps to the dominant frequency (majorPeak) — bass at one end, // treble at the other, so the spectrum scrolls spatially with pitch. @@ -15,7 +15,7 @@ namespace mm { // - default: the dot jumps to a random position each frame (WLED's classic Blurz). // fadeRate dims the trail each frame; blur is the box-blur strength applied after the dot is drawn. // -// Prior art: WLED's "Blurz" audio effect, carried into MoonLight. The per-band colour cursor, the +// Prior art: WLED's "Blurz" audio effect, carried into MoonLight. The per-band color cursor, the // frequency→position map, and the fade-then-blur pipeline are reproduced here, written fresh on // EffectBase + the shared draw primitives. Reads AudioService::latestFrame(); with simulation off or no // publisher the bands read 0 → the strip fades to black, safe on any target and grid size. @@ -105,7 +105,7 @@ class BlurzEffect : public EffectBase { if (segLoc < 0) segLoc = 0; if (segLoc > maxLen - 1) segLoc = maxLen - 1; - // Colour the dot by the current band's magnitude, scaled across the strip the way WLED's + // Color the dot by the current band's magnitude, scaled across the strip the way WLED's // Blurz does: pixColor = (2 * fftResult[band] * 240) / max(1, maxLen - 1). WLED passes this // straight to ColorFromPalette, whose index is a uint8_t — so a value above 255 WRAPS around // the palette wheel (mod 256), it does NOT clamp. We reproduce that by truncating to uint8_t. @@ -132,7 +132,7 @@ class BlurzEffect : public EffectBase { // Re-stamp the dot core ON TOP of the blur (WLED's addRGB after blur2d). The blur spreads the // dot into its halo but also dilutes its centre — a blurred dot fades to near-nothing without - // this. Re-adding the colour keeps the core bright so the smear reads as a glowing dot. + // this. Re-adding the color keeps the core bright so the smear reads as a glowing dot. for (int oy = -r; oy <= r; oy++) for (int ox = -r; ox <= r; ox++) draw::addPixel(buf, dims, {static_cast<lengthType>(dx + ox), static_cast<lengthType>(dy + oy), 0}, c); diff --git a/src/light/effects/BouncingBallsEffect.h b/src/light/effects/BouncingBallsEffect.h index ba3eb8bd..45b9961f 100644 --- a/src/light/effects/BouncingBallsEffect.h +++ b/src/light/effects/BouncingBallsEffect.h @@ -80,6 +80,7 @@ class BouncingBallsEffect : public EffectBase { // (truncating) assigned to a float, NOT a full-float divide (which would keep // sub-millisecond precision the source discards). Fidelity: the truncation shifts // every trajectory identically to the original. + // NOLINTNEXTLINE(bugprone-integer-division) — the truncation is the point; see above. const float timeSinceLastBounce = static_cast<float>((time - ball.lastBounceTime) / timeScale); const float timeSec = timeSinceLastBounce / 1000.0f; float height = (0.5f * gravity * timeSec + ball.impactVelocity) * timeSec; diff --git a/src/light/effects/DistortionWavesEffect.h b/src/light/effects/DistortionWavesEffect.h index 67fab9a9..10188202 100644 --- a/src/light/effects/DistortionWavesEffect.h +++ b/src/light/effects/DistortionWavesEffect.h @@ -4,7 +4,7 @@ namespace mm { -// Two interfering sine waves whose sum drives the hue — a flowing, moiré-like colour +// Two interfering sine waves whose sum drives the hue — a flowing, moiré-like color // field. The horizontal and vertical waves run at independent frequencies and slightly // different time rates, so they beat against each other. 2D (Layer::extrude lifts it to // 3D). Ported from WLED's "Distortion Waves". diff --git a/src/light/effects/EffectBase.h b/src/light/effects/EffectBase.h index 70022557..47882be4 100644 --- a/src/light/effects/EffectBase.h +++ b/src/light/effects/EffectBase.h @@ -1,7 +1,7 @@ #pragma once // Include this one file to write an effect: it brings EffectBase, the render context accessors, and the -// common drawing / palette / maths / noise / colour / scratch / audio helpers, so a new effect is a single +// common drawing / palette / maths / noise / color / scratch / audio helpers, so a new effect is a single // include: // // #pragma once diff --git a/src/light/effects/FireEffect.h b/src/light/effects/FireEffect.h index b887f918..ba02db6e 100644 --- a/src/light/effects/FireEffect.h +++ b/src/light/effects/FireEffect.h @@ -7,7 +7,7 @@ namespace mm { // Author: Mark Kriegsman's Fire2012 (FastLED); MoonLight adapts MatrixFireFast by toggledbits — https://github.com/toggledbits/MatrixFireFast /// Fire2012-style heat field: sparks at the base rise and cool through the active /// palette (heat = palette index, cold at the low end, hottest at the high end); -/// spark count scales with width. The flame colour comes from the active palette — +/// spark count scales with width. The flame color comes from the active palette — /// the Lava palette (black->red->orange->yellow->white) gives the classic look; any /// palette works (Ocean/Forest turn the flame blue/green). /// @card FireEffect.png @@ -89,8 +89,8 @@ class FireEffect : public EffectBase { // palette (black→red→orange→yellow→white) gives the classic fire look; any palette works // (an Ocean/Forest palette makes a blue/green "fire"). // A completely cold cell (heat 0) always stays black — the "sky" above the flame — rather - // than taking the palette's index-0 colour (Lava's is black, but Ocean's is blue, which - // would tint the whole background). Only a warm cell is coloured. + // than taking the palette's index-0 color (Lava's is black, but Ocean's is blue, which + // would tint the whole background). Only a warm cell is colored. const Palette& pal = *Palettes::active(); for (nrOfLightsType i = 0; i < heat_.count(); i++) { RGB c = heat_[i] == 0 ? RGB{0, 0, 0} : colorFromPalette(pal, heat_[i]); diff --git a/src/light/effects/FixedRectangleEffect.h b/src/light/effects/FixedRectangleEffect.h index e26877da..0cc2bbb5 100644 --- a/src/light/effects/FixedRectangleEffect.h +++ b/src/light/effects/FixedRectangleEffect.h @@ -9,15 +9,15 @@ namespace mm { // over a slow motion-trail fade. The box origin (X/Y/Z position) and size (width/height/depth) // are plain controls, so the user dials in exactly which cells light up — a static fixture / // alignment / region paint. When `alternateWhite` is on, the box is rendered as a chequerboard -// of white and the RGB colour: a per-cell toggle flips along the box's dominant axis (it flips +// of white and the RGB color: a per-cell toggle flips along the box's dominant axis (it flips // every cell when the box is wider than tall, and once per row when it is taller than wide), so -// the white/colour pattern follows the box's longer side. On RGBW grids the 4th (white) channel -// carries `white` on the white tiles and is cleared to 0 on the coloured tiles, so a coloured +// the white/color pattern follows the box's longer side. On RGBW grids the 4th (white) channel +// carries `white` on the white tiles and is cleared to 0 on the colored tiles, so a colored // cell never picks up the white LED and no stale W lingers from a prior frame. // // Prior art: MoonLight's FixedRectangle (E_MoonModules / MoonModules). The defaults, the // origin+extent clamping, the alternate-toggle rule (flip per-cell when height<width, flip -// per-row when height>width), and the white-vs-colour chequerboard are reproduced exactly, +// per-row when height>width), and the white-vs-color chequerboard are reproduced exactly, // written fresh on EffectBase + the shared draw primitives. // Author: limpkin (MoonLight) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h /// Test effect: draws a fixed rectangle at set coordinates. @@ -26,7 +26,7 @@ class FixedRectangleEffect : public EffectBase { const char* tags() const override { return "💫"; } // MoonLight origin Dim dimensions() const override { return Dim::D3; } - // Colour of the box (MoonLight defaults). White is the 4th-channel value, used only on RGBW. + // Color of the box (MoonLight defaults). White is the 4th-channel value, used only on RGBW. uint8_t red = 182; uint8_t green = 15; uint8_t blue = 98; @@ -71,7 +71,7 @@ class FixedRectangleEffect : public EffectBase { // Motion trail: dim the whole buffer each frame (source: layer->fadeToBlackBy(10)). layer()->fadeToBlackBy(10); - // The white/colour chequerboard toggle. MoonLight keeps it as a member but resets it to + // The white/color chequerboard toggle. MoonLight keeps it as a member but resets it to // false at the top of each draw, so it is effectively per-frame state — a plain local here. bool alternate = false; @@ -85,21 +85,21 @@ class FixedRectangleEffect : public EffectBase { for (int z = rectZ; z < zEnd; z++) { for (int y = rectY; y < yEnd; y++) { for (int x = rectX; x < xEnd; x++) { - // One chequerboard decision drives both the RGB colour and the W channel: - // a white tile paints white RGB + W=white; a coloured tile paints rgb + W=0. + // One chequerboard decision drives both the RGB color and the W channel: + // a white tile paints white RGB + W=white; a colored tile paints rgb + W=0. const bool isWhiteTile = alternateWhite && alternate; const Coord3D p{static_cast<lengthType>(x), static_cast<lengthType>(y), static_cast<lengthType>(z)}; - // Always write RGB: a white tile paints {255,255,255} even when the colour is all - // zero, and a coloured tile writes rgb (clearing any stale pixel from a prior frame). + // Always write RGB: a white tile paints {255,255,255} even when the color is all + // zero, and a colored tile writes rgb (clearing any stale pixel from a prior frame). draw::pixel(buf, dims, p, isWhiteTile ? RGB{255, 255, 255} : rgb); // White channel (4th) only on RGBW grids. Follow the chequerboard branch: the - // white tile carries `white`, a coloured tile clears W so it never tints the - // colour and no stale W persists in the RGBW buffer. draw::pixel writes RGB only. + // white tile carries `white`, a colored tile clears W so it never tints the + // color and no stale W persists in the RGBW buffer. draw::pixel writes RGB only. if (cpl >= 4) { const size_t off = draw::offsetOf(buf, dims, p); if (off + 3 < buf.bytes()) buf.data()[off + 3] = isWhiteTile ? white : 0; } - // Box wider than tall: flip the white/colour toggle every cell along X. + // Box wider than tall: flip the white/color toggle every cell along X. if (rectH < rectW) alternate = !alternate; } // Box taller than wide: flip once per row instead. diff --git a/src/light/effects/FreqMatrixEffect.h b/src/light/effects/FreqMatrixEffect.h index bf74800c..39eb89ef 100644 --- a/src/light/effects/FreqMatrixEffect.h +++ b/src/light/effects/FreqMatrixEffect.h @@ -8,12 +8,12 @@ namespace mm { // whole column scrolls one pixel away from the source end and a single new pixel is painted at that // end, its HUE derived from the music's major peak frequency (mapped through a tunable // lowBin..highBin frequency window) and its BRIGHTNESS from the overall loudness. The result is a -// scrolling "waterfall" where pitch becomes colour and loudness becomes intensity — speak or play a -// rising tone and a coloured streak climbs the strip. Silence (or a sub-bass-only signal) paints +// scrolling "waterfall" where pitch becomes color and loudness becomes intensity — speak or play a +// rising tone and a colored streak climbs the strip. Silence (or a sub-bass-only signal) paints // black, so quiet rooms scroll dark. // // The column itself IS the shift register: each loop reads pixel y-1 into pixel y (from the far end -// back toward the source) and writes the freshly-computed colour at y=0, so no separate history +// back toward the source) and writes the freshly-computed color at y=0, so no separate history // buffer is needed — the look is entirely in the Buffer's own scroll. As a D1 effect it writes only // the x=0 column running along Y (the project's "1D runs along Y" contract, docs/architecture.md); // Layer::extrude fans that single column across x (and z on a cube) on wider layers, so the same @@ -35,7 +35,7 @@ namespace mm { // /2560 divisor so a full-scale level·fx·sensitivity lands near 255 — the same response curve on our // integer level. These two scale conversions are the only deviations from the verbatim WLED math; // every constant (80 Hz, 0.25, 42·highBin, 3·lowBin) is otherwise preserved. -/// Audio-reactive effect: scrolls the dominant frequency as a colour column. +/// Audio-reactive effect: scrolls the dominant frequency as a color column. class FreqMatrixEffect : public EffectBase { public: const char* tags() const override { return "🐙📊"; } // 1D · audio @@ -96,7 +96,7 @@ class FreqMatrixEffect : public EffectBase { if (pixVal > 255) pixVal = 255; const uint8_t bri = static_cast<uint8_t>(pixVal); - // --- Colour of the new pixel. Black unless there is a real tone above 80 Hz and the (smoothed) + // --- Color of the new pixel. Black unless there is a real tone above 80 Hz and the (smoothed) // volume is above a quarter scale (WLED: peakHz > 80 && volumeSmth > 0.25). 0.25 on WLED's // 0..1 volume is reproduced as level > 64 on our 0..255 smoothed level (fidelity-scale note). RGB newColor{0, 0, 0}; @@ -118,7 +118,7 @@ class FreqMatrixEffect : public EffectBase { } // --- Shift the column one pixel away from the source end (WLED: for i = SEGLEN-1 .. 1, - // setPixelColor(i, getPixelColor(i-1))), then paint the new colour at y=0. The effect writes + // setPixelColor(i, getPixelColor(i-1))), then paint the new color at y=0. The effect writes // only x=0; Layer::extrude duplicates this column across x (and z) on wider layers. for (int y = len - 1; y > 0; y--) { const RGB c = draw::get(buf, dims, {0, static_cast<lengthType>(y - 1), 0}); diff --git a/src/light/effects/FreqSawsEffect.h b/src/light/effects/FreqSawsEffect.h index 259743eb..ba2dea80 100644 --- a/src/light/effects/FreqSawsEffect.h +++ b/src/light/effects/FreqSawsEffect.h @@ -137,7 +137,7 @@ class FreqSawsEffect : public EffectBase { } // Column loop: map each x onto its band and draw that band's cached Y. Per-column concerns - // (invert mirroring, palette colour) stay here; the band physics already ran above. + // (invert mirroring, palette color) stay here; the band physics already ran above. for (int x = 0; x < sizeX; x++) { // Map this column onto one of the 16 GEQ bands (band = map(x, 0, sizeX, 0, 16)). int band = imap(x, 0, sizeX, 0, NUM_GEQ_CHANNELS); @@ -158,7 +158,7 @@ class FreqSawsEffect : public EffectBase { private: static constexpr int NUM_GEQ_CHANNELS = 16; - // Standard integer map (MoonLight's ::map), used for the band/colour/position remaps. Guards a + // Standard integer map (MoonLight's ::map), used for the band/color/position remaps. Guards a // zero input span so a degenerate grid (sizeX/sizeY <= 1) can't divide by zero. static int imap(int v, int inLo, int inHi, int outLo, int outHi) { const int den = inHi - inLo; diff --git a/src/light/effects/GEQEffect.h b/src/light/effects/GEQEffect.h index e6e74752..03a51e8d 100644 --- a/src/light/effects/GEQEffect.h +++ b/src/light/effects/GEQEffect.h @@ -11,7 +11,7 @@ namespace mm { // // Per frame the whole buffer fades a little (fadeOut → motion trail), then for each column x its band // is read, optionally smoothed against its neighbours (smoothBars), mapped to a bar height, and the -// column is filled from the floor up. The bar colour is either per-column (colorBars) or per-row (the +// column is filled from the floor up. The bar color is either per-column (colorBars) or per-row (the // gradient runs up the bar). A per-column peak tracker remembers the tallest the bar reached; when the // live bar is shorter, the remembered peak is drawn as a single dot and decays downward at a rate set // by `ripple` (0 = the peak dot is disabled; otherwise it falls one row every `ripple` frames). @@ -36,7 +36,7 @@ class GEQEffect : public EffectBase { // fade so bars snap rather than smear. uint8_t ripple = 4; // peak-dot fall rate: the dot drops one row every `ripple` frames // (0 = no peak dot). WLED's "ripple" slider gates the falling peak. - bool colorBars = false; // colour each bar by its column (true) instead of by row height (false) + bool colorBars = false; // color each bar by its column (true) instead of by row height (false) bool smoothBars = false; // blend each band with its neighbours for a smoother profile void defineControls() override { @@ -135,7 +135,7 @@ class GEQEffect : public EffectBase { if (ripple > 0 && peaks_[x] > 0 && peaks_[x] > barHeight) { const int y = rows - peaks_[x]; // peaks_[x] rows up from the floor if (y >= 0 && y < rows) { - // Peak colour: top of the palette (index 255) so the dot reads as the crest. + // Peak color: top of the palette (index 255) so the dot reads as the crest. const RGB peakCol = colorFromPalette(*Palettes::active(), 255); draw::pixel(buf, dims, {static_cast<lengthType>(x), static_cast<lengthType>(y), 0}, peakCol); } @@ -146,7 +146,7 @@ class GEQEffect : public EffectBase { private: static constexpr int NUM_GEQ_CHANNELS = 16; - // Standard integer map (WLED/MoonLight's ::map), used for the band/colour/height remaps. Guards a + // Standard integer map (WLED/MoonLight's ::map), used for the band/color/height remaps. Guards a // zero input span so a degenerate grid (cols/rows <= 1) can't divide by zero. static int imap(int v, int inLo, int inHi, int outLo, int outHi) { const int den = inHi - inLo; diff --git a/src/light/effects/GameOfLifeEffect.h b/src/light/effects/GameOfLifeEffect.h index b1fabc13..3ae8eb6c 100644 --- a/src/light/effects/GameOfLifeEffect.h +++ b/src/light/effects/GameOfLifeEffect.h @@ -4,9 +4,9 @@ namespace mm { -// Conway's Game of Life, generalised to 2D and 3D, with selectable rulesets, palette-coloured -// cells that inherit a living neighbour's colour on birth, optional green→red age colouring, a -// dead-cell blur trail that fades toward a configurable background colour, a 1.5 s settle pause on +// Conway's Game of Life, generalised to 2D and 3D, with selectable rulesets, palette-colored +// cells that inherit a living neighbour's color on birth, optional green→red age coloring, a +// dead-cell blur trail that fades toward a configurable background color, a 1.5 s settle pause on // each new game, and self-respawn (R-pentomino / glider) when the pattern goes static. A living // cell survives if its live-neighbour count is in the ruleset's SURVIVE set; a dead cell is born if // its count is in the BIRTH set. Neighbours are the 8 around a cell in 2D, the 26 in 3D, optionally @@ -16,7 +16,7 @@ namespace mm { // // Prior art: MoonLight's GameOfLife (E_MoonModules, MoonModules; Ewoud Wijma 2022 after // natureofcode ch.7 + DougHaber/nlife-color, Brandon Butler / @Brandon502 2024) — its behaviour is -// reproduced here (rulesets, 2D/3D neighbourhoods, neighbour-colour inheritance, age colouring, +// reproduced here (rulesets, 2D/3D neighbourhoods, neighbour-color inheritance, age coloring, // background blur, 3-CRC stasis, R-pentomino respawn, settle pause), written fresh on projectMM's // EffectBase + shared primitives (Random8, colorFromPalette, draw::, crc16). Conway's Game of Life // (John Conway, 1970) is the underlying automaton. @@ -63,7 +63,7 @@ class GameOfLifeEffect : public EffectBase { uint8_t blur = 128; void defineControls() override { - // MoonLight's bgC is a Coord3D 0..255 read as RGB. projectMM has no colour control, so the + // MoonLight's bgC is a Coord3D 0..255 read as RGB. projectMM has no color control, so the // three components are three uint8s — the native, recognisable shape for an RGB triple here. controls_.addUint8("backgroundColorR", backgroundColorR, 0, 255); controls_.addUint8("backgroundColorG", backgroundColorG, 0, 255); @@ -80,8 +80,8 @@ class GameOfLifeEffect : public EffectBase { controls_.addUint8("blur", blur, 0, 255); } - // Grid state lives on the heap (cells + next-gen + per-cell colour), sized to the light count. - // Bit-packed alive/dead keeps it small (16K cells = 2KB each plane); colours are one byte each. + // Grid state lives on the heap (cells + next-gen + per-cell color), sized to the light count. + // Bit-packed alive/dead keeps it small (16K cells = 2KB each plane); colors are one byte each. // Off the hot path (cf. Fire's heat_) — never an inline member, so sizeof(GameOfLife) stays tiny // (an inline array here caused a P4 stack-overflow bootloop with HueDriver). void prepare() override { @@ -247,7 +247,7 @@ class GameOfLifeEffect : public EffectBase { return static_cast<nrOfLightsType>((static_cast<size_t>(z) * h + y) * w + x); } - // A live cell's colour: green when colorByAge (it ages toward red), else its palette colour. + // A live cell's color: green when colorByAge (it ages toward red), else its palette color. RGB liveColor(uint8_t colorIndex) const { return colorByAge ? RGB{0, 255, 0} : colorFromPalette(*Palettes::active(), colorIndex); } @@ -307,7 +307,7 @@ class GameOfLifeEffect : public EffectBase { } // Repaint every live cell on a fresh fill — the "show the start" frame between games while the - // settle timer runs. (MoonLight relies on the redraw loop; here the cells/colours are already + // settle timer runs. (MoonLight relies on the redraw loop; here the cells/colors are already // set by startNewGame, so painting them is a straight pass.) void renderInitial(lengthType w, lengthType h, lengthType d) { Buffer& buf = layer()->buffer(); @@ -352,8 +352,8 @@ class GameOfLifeEffect : public EffectBase { if (nx >= w || ny >= h) continue; const nrOfLightsType i2 = idx(nx, ny, z, w, h); setBit(future_.data(), i2, true); - // Record the cell's colour index so later neighbour-colour inheritance sees a - // live (non-zero marker) colour for these injected cells, not 0 (dead). Drawn + // Record the cell's color index so later neighbour-color inheritance sees a + // live (non-zero marker) color for these injected cells, not 0 (dead). Drawn // green under colorByAge, but colors_ still carries the palette index it ages from. colors_[i2] = colorIndex; if (buf) draw::pixel(*buf, dims, {nx, ny, z}, colorByAge ? RGB{0, 255, 0} : color); @@ -363,7 +363,7 @@ class GameOfLifeEffect : public EffectBase { } } - // One generation: count neighbours (collecting up to 9 neighbour colours for inheritance), apply + // One generation: count neighbours (collecting up to 9 neighbour colors for inheritance), apply // the rules into future_, paint each cell, then run the 3-CRC stasis + respawn / reset logic. // `testMode` skips rendering and the timing/respawn rendering side-effects (test seam path); // buf/dims/bg/frameBlur/fadedBackground are only read off the test path. @@ -399,9 +399,9 @@ class GameOfLifeEffect : public EffectBase { const nrOfLightsType nIndex = idx(nx, ny, nz, w, h); if (getBit(cells_.data(), nIndex)) { neighbors++; - if (cellValue || colorByAge) continue; // colour not needed - if (colors_[nIndex] == 0) continue; // dead-marker colour - // Cap collected colours at nColors' size 9: 3D's 26-neighbour + if (cellValue || colorByAge) continue; // color not needed + if (colors_[nIndex] == 0) continue; // dead-marker color + // Cap collected colors at nColors' size 9: 3D's 26-neighbour // count can exceed 9, and the random pick below indexes with // rng_.below(colorCount), so an uncapped colorCount would read // out of bounds. Nine samples are plenty for the inheritance pick. @@ -421,9 +421,9 @@ class GameOfLifeEffect : public EffectBase { setBit(future_.data(), cIndex, false); if (!testMode && buf) draw::blendPixel(*buf, dims, p, bg, frameBlur); } else if (!cellValue && born) { - // Reproduction: inherit a living neighbour's colour, mutate sometimes. Both + // Reproduction: inherit a living neighbour's color, mutate sometimes. Both // fallbacks use rng_.below(1, 255) (1..254) so a live cell never gets 0, the - // dead-cell marker (matches startNewGame's fill colour). + // dead-cell marker (matches startNewGame's fill color). setBit(future_.data(), cIndex, true); uint8_t colorIndex = (colorCount > 0) ? nColors[rng_.below(colorCount)] : rng_.below(1, 255); if (rng_.below(100) < mutation) colorIndex = rng_.below(1, 255); @@ -431,7 +431,7 @@ class GameOfLifeEffect : public EffectBase { if (!testMode && buf) draw::pixel(*buf, dims, p, liveColor(colorIndex)); } else { // Unchanged cell: dead → blur (honour the faded-background floor); live → - // age toward red, or repaint its palette colour. + // age toward red, or repaint its palette color. if (!cellValue) { setBit(future_.data(), cIndex, false); if (!testMode && buf) { diff --git a/src/light/effects/LavaLampEffect.h b/src/light/effects/LavaLampEffect.h index 5e1e903e..3dfd0bce 100644 --- a/src/light/effects/LavaLampEffect.h +++ b/src/light/effects/LavaLampEffect.h @@ -6,7 +6,7 @@ namespace mm { // Atmospheric lava-lamp: three slow blobs whose summed field is mapped // through a black → red → orange → yellow → white palette. -// Distinct from MetaballsEffect (which is fast, HSV-coloured). +// Distinct from MetaballsEffect (which is fast, HSV-colored). // Author: projectMM original (metaball lava lamp) /// Lava-lamp effect: slow rising/merging palette blobs. /// @card LavaLampEffect.gif @@ -70,7 +70,7 @@ class LavaLampEffect : public EffectBase { uint8_t idx = scaled > 255 ? 255 : static_cast<uint8_t>(scaled); // The metaball field value (0 = between blobs, 255 = blob core) is the palette index, // so the lamp takes the active palette. Lava gives the classic molten look (its low - // end is black, so the space between blobs stays dark); any palette recolours the blobs. + // end is black, so the space between blobs stays dark); any palette recolors the blobs. const RGB c = colorFromPalette(*Palettes::active(), idx); if (cpl >= 1) row[0] = c.r; if (cpl >= 2) row[1] = c.g; diff --git a/src/light/effects/LinesEffect.h b/src/light/effects/LinesEffect.h index a6bbc8f4..0ea919aa 100644 --- a/src/light/effects/LinesEffect.h +++ b/src/light/effects/LinesEffect.h @@ -8,7 +8,7 @@ namespace mm { // Red — YZ plane sweeps left→right (x oscillates) // Green — XZ plane sweeps top→bottom (y oscillates) // Blue — XY plane sweeps front→back (z oscillates) -// Useful for verifying preview axis orientation: each colour names its axis. +// Useful for verifying preview axis orientation: each color names its axis. // Port of MoonLight's Lines effect via projectMM-v1/LinesEffect.h. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h /// Test effect: axis-aligned planes sweeping in sync (RGB = XYZ). diff --git a/src/light/effects/Noise2DEffect.h b/src/light/effects/Noise2DEffect.h index 64ac1d4e..46c63cc9 100644 --- a/src/light/effects/Noise2DEffect.h +++ b/src/light/effects/Noise2DEffect.h @@ -8,7 +8,7 @@ namespace mm { // X/Y coordinates are the grid position scaled by `scale` (larger scale = finer, more detailed // noise; smaller = broad, smooth blobs) and whose Z coordinate is time, so the whole field flows / // morphs over the frames. The 0..255 noise value indexes the active palette directly, giving the -// classic organic, plasma-like colour wash. +// classic organic, plasma-like color wash. // // Source math (MoonLight's Noise2D): for every (x,y), // pixelHue8 = inoise8(x*scale, y*scale, millis()/(16-speed)); diff --git a/src/light/effects/NoiseEffect.h b/src/light/effects/NoiseEffect.h index bca72b0b..9de559d4 100644 --- a/src/light/effects/NoiseEffect.h +++ b/src/light/effects/NoiseEffect.h @@ -74,7 +74,7 @@ class NoiseEffect : public EffectBase { uint32_t lastElapsed_ = 0; bool started_ = false; // first-tick guard: seed lastElapsed_ before the first delta // The value-noise field itself (hash + smoothstep + bi/trilinear interp) is the shared - // inoise8 in core/noise.h — this effect just scales coordinates into it and colours the + // inoise8 in core/noise.h — this effect just scales coordinates into it and colors the // result through the palette. }; diff --git a/src/light/effects/NoiseMeterEffect.h b/src/light/effects/NoiseMeterEffect.h index 8032b083..e94c0114 100644 --- a/src/light/effects/NoiseMeterEffect.h +++ b/src/light/effects/NoiseMeterEffect.h @@ -4,12 +4,12 @@ namespace mm { -// Noise Meter: a vertical VU column whose height tracks the overall sound level and whose colour is a +// Noise Meter: a vertical VU column whose height tracks the overall sound level and whose color is a // scrolling 2D value-noise field, so a loud moment fills the panel from the bottom up with a drifting, // organic gradient instead of a flat bar. Each frame the buffer fades a little (motion trail), the // audio level (scaled by `width`) sets how many rows light up from the bottom, and for each lit row a // noise sample — taken from a field that both scrolls (the aux0/aux1 phase accumulators) and is -// modulated by the live level — picks the palette colour. The colour depends only on the row (y), so +// modulated by the live level — picks the palette color. The color depends only on the row (y), so // the effect writes the x=0 column and Layer::extrude fans each row across every x and z — the meter // reads as one wide block of light without the effect duplicating the broadcast itself (that is the // framework's job; see architecture.md § Dimensionality). @@ -68,7 +68,7 @@ class NoiseMeterEffect : public EffectBase { for (int y = 0; y < maxLen; y++) { // Scrolling, level-modulated 2D noise field. The two coordinates are 16.8 fixed (our // inoise8 treats the high byte as the cell), exactly as WLED feeds inoise8: the row index - // times the live level walks one axis, the aux phase the other, so the colour drifts both + // times the live level walks one axis, the aux phase the other, so the color drifts both // with motion (aux) and with loudness (level). const uint32_t coordA = static_cast<uint32_t>(y) * level + aux0_; const uint32_t coordB = aux1_ + static_cast<uint32_t>(y) * level; diff --git a/src/light/effects/PraxisEffect.h b/src/light/effects/PraxisEffect.h index 861b7b83..68d08153 100644 --- a/src/light/effects/PraxisEffect.h +++ b/src/light/effects/PraxisEffect.h @@ -4,10 +4,10 @@ namespace mm { -// Praxis: a flowing, palette-coloured field whose hue at each pixel is driven by two +// Praxis: a flowing, palette-colored field whose hue at each pixel is driven by two // independently-oscillating "mutators". A slow macro mutator and a faster micro mutator // (each a beatsin16 sweeping a tight high range) combine with the pixel's (x, y) position -// and a steadily-advancing hue base, so the colour pattern continually stretches, shears, +// and a steadily-advancing hue base, so the color pattern continually stretches, shears, // and rolls across the grid. The micro mutator divides the spatial term (so it sets the // pattern's spatial "frequency"), while the macro mutator multiplies the y·x cross term // (so it warps the field), and huebase = elapsed/40 scrolls the whole thing through the diff --git a/src/light/effects/RandomEffect.h b/src/light/effects/RandomEffect.h index 98b1be19..42a52f32 100644 --- a/src/light/effects/RandomEffect.h +++ b/src/light/effects/RandomEffect.h @@ -5,8 +5,8 @@ namespace mm { // Random: each frame the whole buffer is dimmed a little, then exactly ONE randomly chosen -// light is lit to a random palette colour. Over many frames this scatters fading sparkles of -// colour across the whole volume — a slow, twinkling field whose density is set by the fade +// light is lit to a random palette color. Over many frames this scatters fading sparkles of +// color across the whole volume — a slow, twinkling field whose density is set by the fade // amount (less fade = pixels linger and the field fills; more fade = sparse, quick-decaying // specks). // @@ -16,7 +16,7 @@ namespace mm { // chosen by a flat light index across all nrOfLights (the engine's native ordering, the direct // equivalent of MoonLight's index-based setRGB), so it can land anywhere in a 1D/2D/3D layer. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h -/// Effect that fills the layer with animated random colours. +/// Effect that fills the layer with animated random colors. class RandomEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin @@ -39,7 +39,7 @@ class RandomEffect : public EffectBase { // Dim the whole buffer (source: layer->fadeToBlackBy(fade)). layer()->fadeToBlackBy(fade); - // Light one random light to a random palette colour (source: + // Light one random light to a random palette color (source: // setRGB(random16(nrOfLights), ColorFromPalette(pal, random8()))). The index is a flat // light index — the engine's native light ordering — so the write goes straight into the // buffer at that light, the direct equivalent of MoonLight's index-based setRGB. (There is diff --git a/src/light/effects/RipplesEffect.h b/src/light/effects/RipplesEffect.h index b9a13b2d..c4499735 100644 --- a/src/light/effects/RipplesEffect.h +++ b/src/light/effects/RipplesEffect.h @@ -16,7 +16,7 @@ namespace mm { // degenerates to a single y-row, which is honest for a flat layout. // // Float trig in the loop matches the existing wave effects (Plasma, LavaLamp); -// the hot-path integer-math preference is for per-light colour work, not the +// the hot-path integer-math preference is for per-light color work, not the // handful of transcendental ops a wave front needs. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h /// Water-ripple effect: distance-from-centre drives a wave phase. diff --git a/src/light/effects/RubiksCubeEffect.h b/src/light/effects/RubiksCubeEffect.h index 848a8c6a..adb32f5c 100644 --- a/src/light/effects/RubiksCubeEffect.h +++ b/src/light/effects/RubiksCubeEffect.h @@ -8,14 +8,14 @@ namespace mm { // cube visibly un-mixes turn by turn, and re-scrambles once solved. The cube is a full 6-face model // (up to 8×8 stickers per face) with the real face/row/column rotations; each frame it is drawn onto // the LED volume by classifying every in-bounds voxel as belonging to whichever of the six outer -// faces it sits nearest, and colouring it from that face's sticker. Turns play at `turnsPerSecond`; +// faces it sits nearest, and coloring it from that face's sticker. Turns play at `turnsPerSecond`; // `cubeSize` is the order of the cube (2..8 are real cubes, 1 is a degenerate single block); with // `randomTurning` the cube tumbles through endless random moves instead of solving a stored scramble. // // Prior art: MoonLight's RubiksCube effect (E_MoonModules / MoonModules). The cube model // (init/rotateFace/rotateRow/rotateColumn/rotateFaceLayer and the six face rotations), the packed // move list + scramble/playback, and drawCube's nearest-face projection with the -// {Red, DarkOrange, Blue, Green, Yellow, White} colour map are reproduced exactly here, written +// {Red, DarkOrange, Blue, Green, Yellow, White} color map are reproduced exactly here, written // fresh on EffectBase + the shared draw primitive. projectMM has no per-cell mapping mask, so every // in-bounds voxel is treated as mapped (the source's isMapped()-skip and the mapping-driven // sizeX++/sizeY++/sizeZ++ adjustments are dropped; the projection uses sizeX = max(size.x-1, 1)). @@ -30,7 +30,7 @@ class RubiksCubeEffect : public EffectBase { uint8_t turnsPerSecond = 2; // 0..20 uint8_t cubeSize = 3; // 1..8 (cube order) bool randomTurning = false; - bool usePalette = false; // off = the classic 6 Rubik's face colours; on = 6 samples + bool usePalette = false; // off = the classic 6 Rubik's face colors; on = 6 samples // of the system-wide palette (advised: a primary-ish palette) void defineControls() override { @@ -40,9 +40,9 @@ class RubiksCubeEffect : public EffectBase { controls_.addBool("usePalette", usePalette); } - // The 6 face colours drawCube paints with. Classic Rubik's set (red, dark-orange, blue, green, + // The 6 face colors drawCube paints with. Classic Rubik's set (red, dark-orange, blue, green, // yellow, white) by default; with usePalette, 6 evenly-spaced samples of the system-wide active - // palette (0, 51, 102, … 255) so the cube recolours to whatever palette is chosen — advised: a + // palette (0, 51, 102, … 255) so the cube recolors to whatever palette is chosen — advised: a // primary-ish palette so the 6 faces stay distinct. std::array<RGB, 6> faceColors() const { if (!usePalette) @@ -214,9 +214,9 @@ class RubiksCubeEffect : public EffectBase { if (width >= SIZE) rotateFace(top, !clockwise); } - // Project the cube onto the LED volume: every in-bounds voxel is coloured by the outer face + // Project the cube onto the LED volume: every in-bounds voxel is colored by the outer face // it sits nearest. (MoonLight's drawCube, with the isMapped()-skip and sizeX++/etc dropped.) - // The 6 face colours are supplied by the caller (classic Rubik's set, or palette samples). + // The 6 face colors are supplied by the caller (classic Rubik's set, or palette samples). void drawCube(Buffer& buf, Coord3D dims, lengthType sx, lengthType sy, lengthType sz, const std::array<RGB, 6>& COLOR_MAP) const { // This effect owns its background: drawCube writes only the SURFACE voxels (the loop has @@ -287,9 +287,9 @@ class RubiksCubeEffect : public EffectBase { const int moveCount = cubeSize * 10 + rng_.below(20); for (int x = 0; x < 3; x++) { - if (rng_.below(2)) cube_.rotateRight(1, cubeSize); - if (rng_.below(2)) cube_.rotateTop(1, cubeSize); - if (rng_.below(2)) cube_.rotateFront(1, cubeSize); + if (rng_.below(2)) cube_.rotateRight(true, cubeSize); + if (rng_.below(2)) cube_.rotateTop(true, cubeSize); + if (rng_.below(2)) cube_.rotateFront(true, cubeSize); } const int cappedMoves = (moveCount > kMaxMoves) ? kMaxMoves : moveCount; diff --git a/src/light/effects/SineEffect.h b/src/light/effects/SineEffect.h index f0ae5d7f..a1c78817 100644 --- a/src/light/effects/SineEffect.h +++ b/src/light/effects/SineEffect.h @@ -4,8 +4,8 @@ namespace mm { -// A 3D colour sine field: R, G, B each follow a sine along one axis (x, y, z) with a -// 120° phase offset between channels, so the box glows through shifting colours that +// A 3D color sine field: R, G, B each follow a sine along one axis (x, y, z) with a +// 120° phase offset between channels, so the box glows through shifting colors that // scroll over time. True 3D — every axis drives a channel; on a 2D grid the z term is // constant (Layer::extrude handles a lower-dim layer), so it reads as a 2D R/G wash. // diff --git a/src/light/effects/SolidEffect.h b/src/light/effects/SolidEffect.h index bf540f0d..91f916d3 100644 --- a/src/light/effects/SolidEffect.h +++ b/src/light/effects/SolidEffect.h @@ -4,14 +4,14 @@ namespace mm { -// Solid colour fill with five colour modes: a flat RGB(W) colour, the active palette laid across -// the lights, an RMS-averaged single palette colour, or the palette banded along the rows / columns +// Solid color fill with five color modes: a flat RGB(W) color, the active palette laid across +// the lights, an RMS-averaged single palette color, or the palette banded along the rows / columns // of the grid. A brightness scales the flat and palette-spread results. In the two band modes a // `minRGB` floor drops near-black palette entries, and `randomColors` shuffles the surviving entries // with a fixed LCG so the bands re-order deterministically. The R/G/B/white members are the flat- -// colour source; in the palette modes they're unused. +// color source; in the palette modes they're unused. // -// Prior art: MoonLight's Solid effect (E_MoonModules / MoonModules). The five colour modes, the +// Prior art: MoonLight's Solid effect (E_MoonModules / MoonModules). The five color modes, the // brightness scaling, the RMS palette average (skip black, sqrt of the mean of squares), the // minRGB valid-entry filter, and the deterministic LCG shuffle (seed 12345, *25173 +13849) are // reproduced exactly here, written fresh on EffectBase + the shared palette/draw primitives. @@ -20,7 +20,7 @@ namespace mm { // MoonLight's per-entry scan one-for-one. The optional white channel is written only when the layer // carries a 4th channel (channelsPerLight() >= 4); on RGB layers the white member is ignored. // Author: MoonLight — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_MoonLight.h -/// Effect that fills the whole layer with one palette colour. +/// Effect that fills the whole layer with one palette color. class SolidEffect : public EffectBase { public: const char* tags() const override { return "💫"; } // MoonLight origin @@ -75,13 +75,13 @@ class SolidEffect : public EffectBase { const nrOfLightsType nLights = nrOfLights(); switch (colorMode) { - case 0: { // RGB(W): flat colour, brightness pre-applied per channel (CRGB(red*bri/255,…)). + case 0: { // RGB(W): flat color, brightness pre-applied per channel (CRGB(red*bri/255,…)). const RGB c{static_cast<uint8_t>(red * brightness / 255), static_cast<uint8_t>(green * brightness / 255), static_cast<uint8_t>(blue * brightness / 255)}; draw::fill(buf, c); // Write W every frame (white may be 0) so a stale W from a prior frame/effect is cleared. - // Scale W by brightness like RGB, so the whole RGBW colour dims together. + // Scale W by brightness like RGB, so the whole RGBW color dims together. if (cpl >= 4) writeWhite(buf, nLights, cpl, static_cast<uint8_t>(white * brightness / 255)); break; } @@ -99,7 +99,7 @@ class SolidEffect : public EffectBase { if (cpl >= 4) writeWhite(buf, nLights, cpl, 0); break; } - case 2: { // RMS average of the (non-black) palette colours, filled solid (no brightness — source). + case 2: { // RMS average of the (non-black) palette colors, filled solid (no brightness — source). uint32_t sumR = 0, sumG = 0, sumB = 0; int n = 0; for (int i = 0; i < 256; i++) { diff --git a/src/light/effects/StarFieldEffect.h b/src/light/effects/StarFieldEffect.h index 23943804..eb0ed9ac 100644 --- a/src/light/effects/StarFieldEffect.h +++ b/src/light/effects/StarFieldEffect.h @@ -35,7 +35,7 @@ class StarFieldEffect : public EffectBase { uint8_t speed = 20; // advance rate (0..30); 0 = paused. Throttle is 1000/speed ms. uint8_t numStars = 16; // active stars (1..255) uint8_t blur = 128; // per-frame fade-to-black amount (0..255); higher = stronger fade = shorter streaks (draw::fade keep = 255-blur, matching MoonLight's fadeToBlackBy(blur)) - bool usePalette = false; // colour stars from the palette instead of greyscale + bool usePalette = false; // color stars from the palette instead of greyscale void defineControls() override { controls_.addUint8("speed", speed, 0, 30); @@ -147,7 +147,7 @@ class StarFieldEffect : public EffectBase { return (v - inLo) * (outHi - outLo) / den + outLo; } - // Spawn a star at a random x/y far position with a fresh colour index. `far` selects the depth: + // Spawn a star at a random x/y far position with a fresh color index. `far` selects the depth: // far=false → initial seed: z in [0, w) (MoonLight init: z = random(size.x)) // far=true → respawn: z = w (MoonLight respawn: z = size.x) void spawn(Star& s, lengthType w, lengthType h, bool far) { diff --git a/src/light/effects/StarSkyEffect.h b/src/light/effects/StarSkyEffect.h index e1ecdd03..2627f064 100644 --- a/src/light/effects/StarSkyEffect.h +++ b/src/light/effects/StarSkyEffect.h @@ -10,11 +10,11 @@ namespace mm { // toward full (then reverses) or toward zero (then respawns at a fresh random cell). A small per- // frame chance (random8() < 10) flips a star's direction early, scattering the twinkle so it never // pulses in sync. Stars are white (b,b,b) unless usePalette, in which case each carries its own -// palette index. The colour drawn each frame is taken from the brightness BEFORE this frame's step +// palette index. The color drawn each frame is taken from the brightness BEFORE this frame's step // (MoonLight computes `color` once from the current brightness, then steps, then setRGB(color)). // // Prior art: MoonLight's StarSky (E_MoonModules / MoonModules) — the star-pool model (fill-ratio -// sizing, fade-up/fade-down/respawn, the random early-reverse, the optional palette colour) is +// sizing, fade-up/fade-down/respawn, the random early-reverse, the optional palette color) is // reproduced here, written fresh on projectMM's EffectBase + shared primitives (Random8, // colorFromPalette, draw::). The per-star arrays live on the heap (ScratchBuffers), never as inline // members, so sizeof(StarSkyEffect) stays tiny. @@ -29,7 +29,7 @@ class StarSkyEffect : public EffectBase { // Defaults match MoonLight's StarSky exactly. uint8_t speed = 1; // fade step per frame (0..42) uint8_t star_fill_ratio = 42; // stars per 10000 lights (the pool-size lever) - bool usePalette = false; // false → white stars; true → per-star palette colour + bool usePalette = false; // false → white stars; true → per-star palette color void defineControls() override { controls_.addUint8("speed", speed, 0, 42); @@ -84,8 +84,8 @@ class StarSkyEffect : public EffectBase { const lengthType z = static_cast<lengthType>(index / (static_cast<size_t>(w) * h)); const Coord3D p{x, y, z}; - // Colour is computed ONCE from the CURRENT (pre-step) brightness, then the brightness is - // stepped, then the pre-step colour is drawn — matching MoonLight's + // Color is computed ONCE from the CURRENT (pre-step) brightness, then the brightness is + // stepped, then the pre-step color is drawn — matching MoonLight's // color = usePalette ? ColorFromPalette(pal, colors[i], brightness[i]) : CRGB(b,b,b); // brightness[i] += speed; setRGB(pos, color); const uint8_t b = brightness_[i]; @@ -140,7 +140,7 @@ class StarSkyEffect : public EffectBase { } } - // Seed every star: random cell, random fade direction, random mid brightness, random colour. + // Seed every star: random cell, random fade direction, random mid brightness, random color. void initStars(nrOfLightsType count) { for (size_t i = 0; i < nbStars_; i++) { indexes_[i] = randomIndex(count); diff --git a/src/light/effects/TetrixEffect.h b/src/light/effects/TetrixEffect.h index 5262890d..8c04d717 100644 --- a/src/light/effects/TetrixEffect.h +++ b/src/light/effects/TetrixEffect.h @@ -16,7 +16,7 @@ namespace mm { // Prior art: MoonLight's Tetrix (E_MoonModules / MoonModules), descended from the WLED "Tetrix" // effect (Aircoookie / blazoncek). The per-column physics (mapped fall speed = grid-height·FRAMETIME // / map(speed,1,255,40000,250), the `pos`/`stack`/`brick` integers, the millis()+2000 start/blank -// delays, and the step-machine values 0/1/2/>2) and the colour rules are reproduced from the +// delays, and the step-machine values 0/1/2/>2) and the color rules are reproduced from the // MoonLight spec, written fresh on EffectBase + the shared draw primitives. One drop per X column; // safe at any grid size. // Author: Andrew Tuline (WLED-SR) — https://github.com/MoonModules/MoonLight/blob/main/src/MoonLight/Nodes/Effects/E_WLED.h @@ -29,7 +29,7 @@ class TetrixEffect : public EffectBase { // Controls — MoonLight's exact defaults. `speedControl` is the UI "speed" (0 = random per brick). uint8_t speedControl = 0; // 0..255; 0 → each brick gets a random fall speed uint8_t widthControl = 0; // 0..255; 0 → random brick height, else derived from this - bool oneColor = false; // all bricks in a column share one slowly-advancing colour + bool oneColor = false; // all bricks in a column share one slowly-advancing color void defineControls() override { controls_.addUint8("speed", speedControl, 0, 255); @@ -111,7 +111,7 @@ class TetrixEffect : public EffectBase { if (d.pos > static_cast<float>(d.stack)) { d.pos -= d.speed; if (d.pos < static_cast<float>(d.stack)) d.pos = static_cast<float>(d.stack); - // Render the brick: rows [pos, pos+brick) lit in the column colour, above it black. + // Render the brick: rows [pos, pos+brick) lit in the column color, above it black. for (lengthType i = static_cast<lengthType>(d.pos); i < h; i++) { const RGB c = (i < static_cast<lengthType>(d.pos) + static_cast<lengthType>(d.brick)) ? colorFromPalette(*Palettes::active(), d.col) diff --git a/src/light/effects/TextEffect.h b/src/light/effects/TextEffect.h index 80471b08..9bfd763b 100644 --- a/src/light/effects/TextEffect.h +++ b/src/light/effects/TextEffect.h @@ -9,7 +9,7 @@ namespace mm { // Text: renders a multi-line string on the grid in a selectable bitmap font. By DEFAULT the text is // STATIC — laid out from the top-left, each `\n` dropping one font-height, clipped where it runs off // the grid. Turn on `scroll` to march the whole block leftwards as a marquee (wrapping), at `speed`. -// The colour comes from the active palette (one index, so it follows the global palette control). +// The color comes from the active palette (one index, so it follows the global palette control). // // Multi-line entry uses the shared TextArea control (the same widget MoonLive's `source` uses — a // real <textarea>), so a user types several lines and each renders on its own row. The bitmap glyph @@ -31,7 +31,7 @@ class TextEffect : public EffectBase { bool scroll = false; // false = static top-left; true = horizontal marquee uint8_t font = 1; // index into fonts::kAll (0 = 4x6, 1 = 6x8) uint8_t speed = 30; // marquee speed (pixels/sec-ish); only used when scrolling - uint8_t hue = 128; // palette index for the text colour (mid-palette; 0 is black in some palettes) + uint8_t hue = 128; // palette index for the text color (mid-palette; 0 is black in some palettes) void defineControls() override { controls_.addTextArea("text", text_, sizeof(text_)); diff --git a/src/light/effects/WaveEffect.h b/src/light/effects/WaveEffect.h index a38738b3..e83ac967 100644 --- a/src/light/effects/WaveEffect.h +++ b/src/light/effects/WaveEffect.h @@ -8,8 +8,8 @@ namespace mm { // the lit points trace a sawtooth / triangle / sine / square / composite-sine / noise curve that // scrolls sideways over time, leaving a fading trail behind it. The classic "oscilloscope wave" // look. Prior art: MoonLight's Wave effect (Ewoud Wijma) — behaviour reproduced (the six waveform -// types, the per-column phase travel, the time-varying colour, the frame fade), written fresh on -// projectMM's EffectBase + integer primitives (sin8 LUT, scale8); the colour is a global-palette lookup. +// types, the per-column phase travel, the time-varying color, the frame fade), written fresh on +// projectMM's EffectBase + integer primitives (sin8 LUT, scale8); the color is a global-palette lookup. // // Axis convention: the waveform sets a y (its shape lives on HEIGHT); width is the travel axis. // So a 1-tall grid shows no wave — to drive a 1D output (a strip, a row of Hue lights) lay it out @@ -79,7 +79,7 @@ class WaveEffect : public EffectBase { phase_ += static_cast<uint64_t>(now - lastElapsed_) * bpm; lastElapsed_ = now; const uint8_t t = static_cast<uint8_t>((phase_ * 256) / 60000); // uint8 angle (256 = full turn) - // Colour cycles slowly over time: now/50 indexes the active palette via waveColor. + // Color cycles slowly over time: now/50 indexes the active palette via waveColor. const uint8_t colorIndex = static_cast<uint8_t>(now / 50); // 3. Plot the wave point per column, joining discontinuous shapes to the previous column. @@ -122,8 +122,8 @@ class WaveEffect : public EffectBase { uint32_t lastElapsed_ = 0; bool started_ = false; // first-tick guard: seed lastElapsed_ before the first delta - // The colour for the wave this frame — one place: a lookup into the global active palette, - // so the wave recolours when the palette changes. + // The color for the wave this frame — one place: a lookup into the global active palette, + // so the wave recolors when the palette changes. static RGB waveColor(uint8_t index) { return colorFromPalette(*Palettes::active(), index); } // Map a phase (uint8 angle) to a y in [0, h) for the selected waveform. Integer-only. diff --git a/src/light/fonts.h b/src/light/fonts.h index a2f16951..84b915f3 100644 --- a/src/light/fonts.h +++ b/src/light/fonts.h @@ -10,8 +10,7 @@ // Prior art: the public raster-fonts set (https://github.com/idispatch/raster-fonts), the same // bitmap console fonts WLED/MoonLight ship. Carried as data; the blitter (draw::glyph/draw::text) // is ours. Two sizes: a compact 4x6 and a larger 6x8. More fonts are pure data to add later. -namespace mm { -namespace fonts { +namespace mm::fonts { struct Font { const uint8_t* rows; uint8_t width; uint8_t height; }; // rows: 95*height bytes @@ -221,5 +220,4 @@ static_assert(sizeof(kFont6x8_rows) == 95 * 8, "kFont6x8: 95 printable ASCII gly inline constexpr Font kAll[] = { kFont4x6, kFont6x8 }; inline constexpr uint8_t kCount = sizeof(kAll) / sizeof(kAll[0]); -} // namespace fonts -} // namespace mm +} // namespace mm::fonts diff --git a/src/light/layouts/Rings241Layout.h b/src/light/layouts/Rings241Layout.h index 984e989f..1efcbe79 100644 --- a/src/light/layouts/Rings241Layout.h +++ b/src/light/layouts/Rings241Layout.h @@ -1,6 +1,7 @@ #pragma once #include "light/layouts/LayoutBase.h" +#include <numbers> namespace mm { @@ -85,7 +86,7 @@ class Rings241Layout : public LayoutBase { // division is in double then narrowed, matching n / TWO_PI on the double macro). static float getRadius(uint8_t n) { return static_cast<float>(n / kTwoPi); } - static constexpr double kPi = 3.14159265358979323846; + static constexpr double kPi = std::numbers::pi; static constexpr double kTwoPi = 2.0 * kPi; // The nine ring sizes of the 241-LED disc, inner to outer. diff --git a/src/light/layouts/SpiralLayout.h b/src/light/layouts/SpiralLayout.h index 719ddc80..603214eb 100644 --- a/src/light/layouts/SpiralLayout.h +++ b/src/light/layouts/SpiralLayout.h @@ -1,6 +1,7 @@ #pragma once #include "light/layouts/LayoutBase.h" +#include <numbers> namespace mm { @@ -46,9 +47,8 @@ class SpiralLayout : public LayoutBase { const uint32_t limit = lightCount(); if (limit == 0) return; - // π as a float literal — the codebase convention (see platform_desktop.cpp); - // M_PI is not defined portably under MSVC's <cmath> without _USE_MATH_DEFINES. - constexpr float pi = 3.14159265358979323846f; + // std::numbers is the portable π (M_PI needs _USE_MATH_DEFINES under MSVC). + constexpr float pi = std::numbers::pi_v<float>; // Base sits at (bottomRadius, 0, bottomRadius) so all coords are >= 0. const float middleX = static_cast<float>(bottomRadius); diff --git a/src/light/modifiers/PinwheelModifier.h b/src/light/modifiers/PinwheelModifier.h index c72c9b8d..7f7e5648 100644 --- a/src/light/modifiers/PinwheelModifier.h +++ b/src/light/modifiers/PinwheelModifier.h @@ -1,6 +1,7 @@ #pragma once #include "light/modifiers/ModifierBase.h" +#include <numbers> namespace mm { @@ -127,7 +128,7 @@ class PinwheelModifier : public ModifierBase { // Radians → degrees. MoonLight uses Arduino's degrees() macro; reproduced here // as a plain helper so the header carries no Arduino dependency. - static float degrees_(float rad) { return rad * (180.0f / 3.14159265358979323846f); } + static float degrees_(float rad) { return rad * (180.0f / std::numbers::pi_v<float>); } }; } // namespace mm \ No newline at end of file diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index 096b8764..869c366b 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -89,7 +89,7 @@ class MoonLiveEffect : public EffectBase { private: moonlive::MoonLive engine_; - // Default script — random pixels: each tick lights one random light in a random RGB colour. + // Default script — random pixels: each tick lights one random light in a random RGB color. // A live, always-visible starting example (and a good demo-reel slot). The index random16(256) // covers a typical grid; setRGB bounds-guards it (an index past the light count is skipped, and // 0×0 is safe), so most ticks land on a real light and the demo stays visibly lit. diff --git a/src/platform/desktop/main_desktop.cpp b/src/platform/desktop/main_desktop.cpp index eda6a072..4368598b 100644 --- a/src/platform/desktop/main_desktop.cpp +++ b/src/platform/desktop/main_desktop.cpp @@ -46,13 +46,27 @@ static void crashHandler(int sig) { raise(sig); } +// Local time as an ISO-8601 stamp, thread-safely. `std::localtime` returns a pointer to a +// SHARED static tm, so two threads formatting a timestamp can each see the other's value — +// flagged independently by clang-tidy (concurrency-mt-unsafe) and CodeQL (critical). The +// reentrant form is spelled differently per platform, hence the branch. +static void isoTimestamp(char* out, size_t n) { + const std::time_t t = std::time(nullptr); + std::tm tm{}; +#ifdef _WIN32 + localtime_s(&tm, &t); // MSVC: arguments reversed relative to POSIX +#else + localtime_r(&t, &tm); +#endif + std::strftime(out, n, "%Y-%m-%dT%H:%M:%S", &tm); +} + // Fires on std::exit() — distinguishes reboot (platform::reboot prints its own // line first) from a genuine unexpected exit with no preceding crash signal. static void atExitHandler() { if (!cleanExit) { - std::time_t t = std::time(nullptr); char tbuf[32]; - std::strftime(tbuf, sizeof(tbuf), "%Y-%m-%dT%H:%M:%S", std::localtime(&t)); + isoTimestamp(tbuf, sizeof(tbuf)); std::fprintf(stderr, "*** process exited without clean shutdown at %s ***\n", tbuf); std::fflush(stderr); } @@ -99,17 +113,15 @@ int main() { std::atexit(atExitHandler); - std::time_t t = std::time(nullptr); char tbuf[32]; - std::strftime(tbuf, sizeof(tbuf), "%Y-%m-%dT%H:%M:%S", std::localtime(&t)); + isoTimestamp(tbuf, sizeof(tbuf)); std::printf("projectMM started at %s\n", tbuf); std::printf("Press Ctrl-C to stop.\n"); mm_main(running, 8080); cleanExit = true; - t = std::time(nullptr); - std::strftime(tbuf, sizeof(tbuf), "%Y-%m-%dT%H:%M:%S", std::localtime(&t)); + isoTimestamp(tbuf, sizeof(tbuf)); std::printf("projectMM exited cleanly at %s\n", tbuf); return 0; } diff --git a/src/platform/desktop/moonlive_asm_host.cpp b/src/platform/desktop/moonlive_asm_host.cpp index f27c4125..2e9079af 100644 --- a/src/platform/desktop/moonlive_asm_host.cpp +++ b/src/platform/desktop/moonlive_asm_host.cpp @@ -14,7 +14,7 @@ namespace mm::moonlive { // arm64 register map: R0..R4 = the host-ABI arg registers x0..x4 (buf, nLights, cpl, t, ctrls — // the control-values arena pointer, kArg4). R5..R13 = caller-saved scratch x9..x14 then x5..x7. -// Index math uses the 64-bit views (xN) for addresses, 32-bit (wN) for counters/colours — same +// Index math uses the 64-bit views (xN) for addresses, 32-bit (wN) for counters/colors — same // register number, so one map suffices. x15 is the call() address/immediate scratch (not a vreg). static const uint8_t kArm64Reg[kRegCount] = {0, 1, 2, 3, 4, 9, 10, 11, 12, 13, 14, 5, 6, 7}; static uint8_t mr(Reg r) { return kArm64Reg[r]; } diff --git a/src/platform/desktop/moonlive_emit.cpp b/src/platform/desktop/moonlive_emit.cpp index f3df1886..e4598246 100644 --- a/src/platform/desktop/moonlive_emit.cpp +++ b/src/platform/desktop/moonlive_emit.cpp @@ -2,7 +2,7 @@ #include <cstring> -// MoonLive desktop backend (§3.2) — emits the fixed-colour fill as host machine code (arm64 +// MoonLive desktop backend (§3.2) — emits the fixed-color fill as host machine code (arm64 // or x86-64, chosen at compile time). The desktop backend lets a unit test EXECUTE generated // code in-process — proving allocExec → writeExec → call works off-hardware — and exercises // the engine/binding API the same way the device backends do. Hand-encoding the loop here is @@ -44,7 +44,7 @@ size_t emitFill(uint8_t* out, size_t cap, uint8_t r, uint8_t g, uint8_t b) { if (!out || cap < sizeof(kArm64)) return 0; uint32_t code[sizeof(kArm64) / 4]; std::memcpy(code, kArm64, sizeof(kArm64)); - // Patch the colour immediates: mov wN,#imm encodes imm at bits [20:5]; the base word + // Patch the color immediates: mov wN,#imm encodes imm at bits [20:5]; the base word // has imm=0 so OR-ing (imm<<5) sets it cleanly. code[4] = 0x52800004u | (static_cast<uint32_t>(r) << 5); code[5] = 0x52800005u | (static_cast<uint32_t>(g) << 5); @@ -54,7 +54,7 @@ size_t emitFill(uint8_t* out, size_t cap, uint8_t r, uint8_t g, uint8_t b) { } // arm64 animated fill (assembled from anim_arm64.s): red = (t>>3)&0xFF, green=0, blue=64. -// t arrives in w3; nothing to patch — the colour is computed from the runtime arg. +// t arrives in w3; nothing to patch — the color is computed from the runtime arg. static const uint32_t kArm64Anim[] = { 0x34000241, // cbz w1, .done 0x53037c64, // lsr w4, w3, #3 red = t>>3 @@ -116,7 +116,7 @@ size_t emitFill(uint8_t* out, size_t cap, uint8_t r, uint8_t g, uint8_t b) { } // x86-64 animated fill (assembled from anim_x64.s): red = (t>>3)&0xFF, green=0, blue=64. -// t arrives in ecx; nothing to patch — the colour is computed from the runtime arg. +// t arrives in ecx; nothing to patch — the color is computed from the runtime arg. static const uint8_t kX64Anim[] = { 0x85, 0xf6, // test esi, esi 0x74, 0x2d, // je .done (+0x2d) diff --git a/src/platform/desktop/platform_desktop.cpp b/src/platform/desktop/platform_desktop.cpp index 9c79cbc6..b31909d2 100644 --- a/src/platform/desktop/platform_desktop.cpp +++ b/src/platform/desktop/platform_desktop.cpp @@ -31,6 +31,7 @@ #include <sys/mman.h> // mmap/munmap for allocExec (executable pages) #ifdef __APPLE__ #include <pthread.h> // pthread_jit_write_protect_np — macOS arm64 W^X JIT toggle +#include <numbers> #endif #endif @@ -272,7 +273,7 @@ bool spawnPinnedTask(WorkerTask& t, const char* /*name*/, WorkerFn fn, void* use void notifyTask(WorkerTask& t) { auto* w = static_cast<DesktopWorker*>(t.impl); if (!w) return; - { std::lock_guard<std::mutex> lk(w->mtx); w->pending = true; } + { std::scoped_lock<std::mutex> lk(w->mtx); w->pending = true; } w->cv.notify_one(); } @@ -290,7 +291,7 @@ bool waitNotify(WorkerTask& t, uint32_t timeoutMs) { void stopPinnedTask(WorkerTask& t) { auto* w = static_cast<DesktopWorker*>(t.impl); if (!w) return; - { std::lock_guard<std::mutex> lk(w->mtx); w->stop = true; } + { std::scoped_lock<std::mutex> lk(w->mtx); w->stop = true; } w->cv.notify_one(); if (w->thread.joinable()) w->thread.join(); delete w; @@ -660,9 +661,26 @@ void ethGetIPv4(uint8_t out[4]) { } } -bool wifiStaInit(const char* /*ssid*/, const char* /*password*/) { return false; } +// Test seam: the host has no STA radio, so wifiStaInit() reports "no STA" — unless a test fakes +// one to drive NetworkModule's WaitingSta path. Cross-thread atomic, the setTestNowMs contract. +static std::atomic<bool> testWifiStaAvailable{false}; +void setTestWifiStaAvailable(bool available) { testWifiStaAvailable.store(available, std::memory_order_relaxed); } +bool wifiStaInit(const char* /*ssid*/, const char* /*password*/) { + return testWifiStaAvailable.load(std::memory_order_relaxed); +} bool wifiStaConnected() { return false; } void wifiStaGetIPv4(uint8_t out[4]) { out[0] = out[1] = out[2] = out[3] = 0; } +// Addressing is OS-managed on desktop; the static/DHCP setters are inert (no netif to reconfigure). +// The per-interface apply counter is the observable a host test pins the static-addressing path on. +static std::atomic<uint32_t> testStaticApplies[2] = {}; // indexed by NetIface +void netSetStaticIPv4(NetIface iface, const uint8_t[4], const uint8_t[4], + const uint8_t[4], const uint8_t[4]) { + testStaticApplies[static_cast<uint8_t>(iface)].fetch_add(1, std::memory_order_relaxed); +} +uint32_t testNetStaticApplyCount(NetIface iface) { + return testStaticApplies[static_cast<uint8_t>(iface)].load(std::memory_order_relaxed); +} +void netSetDhcp(NetIface /*iface*/) {} void setHostname(const char* /*name*/) {} // no DHCP client on desktop void wifiStaStop() {} int wifiStaRssi() { return 0; } @@ -863,6 +881,10 @@ void reboot() { // browser-side WS reconnect logic expects. std::printf("platform::reboot() — exiting\n"); std::fflush(stdout); + // Exiting the process IS the desktop reboot — there is no firmware to restart into. The + // mt-unsafe warning is about exit() racing other threads' atexit handlers, which is exactly + // the abrupt teardown a reboot models. + // NOLINTNEXTLINE(concurrency-mt-unsafe) std::exit(0); } @@ -1300,7 +1322,7 @@ void audioMicDeinit(AudioMicHandle& /*h*/) {} // fills outMag[0..n/2) with the bin magnitudes. void audioFft(const float* windowed, size_t n, float* outMag) { if (!windowed || !outMag || n == 0) return; - const float twoPiOverN = -2.0f * 3.14159265358979323846f / static_cast<float>(n); + const float twoPiOverN = -2.0f * std::numbers::pi_v<float> / static_cast<float>(n); for (size_t k = 0; k < n / 2; k++) { float re = 0.0f, im = 0.0f; for (size_t t = 0; t < n; t++) { diff --git a/src/platform/esp32/moonlive_emit.cpp b/src/platform/esp32/moonlive_emit.cpp index 0e1c9690..bb35a751 100644 --- a/src/platform/esp32/moonlive_emit.cpp +++ b/src/platform/esp32/moonlive_emit.cpp @@ -26,7 +26,7 @@ namespace mm::moonlive { // --- Xtensa (LX6/LX7: classic ESP32, ESP32-S3) --------------------------------------- // Little-endian, mixed 24-bit and 16-bit (narrow) instructions; windowed ABI prologue/ -// epilogue `entry`/`retw` so a plain C function pointer calls it. Colour bytes are the +// epilogue `entry`/`retw` so a plain C function pointer calls it. Color bytes are the // immediate byte of three wide `movi`s (forced wide so all patch identically), at kR/kG/kB. // Disassembly (offsets in the template below): @@ -62,7 +62,7 @@ static const uint8_t kXtensaFill[] = { 0x37, 0x98, 0xed, // bne a8, a3, .loop 0x1d, 0xf0, // retw.n }; -static constexpr size_t kR = 0x0b, kG = 0x0e, kB = 0x11; // colour-immediate byte offsets +static constexpr size_t kR = 0x0b, kG = 0x0e, kB = 0x11; // color-immediate byte offsets size_t emitFill(uint8_t* out, size_t cap, uint8_t r, uint8_t g, uint8_t b) { if (!out || cap < sizeof(kXtensaFill)) return 0; @@ -75,7 +75,7 @@ size_t emitFill(uint8_t* out, size_t cap, uint8_t r, uint8_t g, uint8_t b) { // Xtensa animated fill (assembled from anim_xt.s, verified by objdump). The 4th windowed-ABI // arg `t` arrives in a5; red = (t>>3)&0xFF is computed at runtime, green=0, blue=64. Nothing -// to patch — the colour is derived from `t`, so the SAME code animates as the host feeds a +// to patch — the color is derived from `t`, so the SAME code animates as the host feeds a // changing elapsed() each tick. // 00: entry a1,32 06: srli a6,a5,3 (red=t>>3) 0e: movi.n a7,0 (green) // 03: beqz.n a3,.done 08: movi a7,0xff 10: movi.n a8,64 (blue) @@ -104,7 +104,7 @@ size_t emitAnimatedFill(uint8_t* out, size_t cap) { // --- RISC-V (RV32IMC: ESP32-P4) ------------------------------------------------------ // Little-endian, fixed 4-byte instructions (assembled with .option norvc so there are no // 2-byte compressed forms — uniform words, simple patching). Standard RV calling convention: -// a0=buf, a1=nLights, a2=cpl, a3=t; `ret` (jalr x0, ra, 0) returns. The colour `li`s sit at +// a0=buf, a1=nLights, a2=cpl, a3=t; `ret` (jalr x0, ra, 0) returns. The color `li`s sit at // fixed WORD indices; a li's 12-bit immediate is bits [31:20], so patch is base | (imm<<20) // with a zero-immediate base. Verbatim from riscv32-esp-elf-as (objcopy of .text). @@ -119,7 +119,7 @@ static const uint8_t kRiscvFill[] = { 0x23, 0x01, 0xdf, 0x01, 0x13, 0x03, 0x13, 0x00, 0xb3, 0x82, 0xc2, 0x00, 0xe3, 0x14, 0xb3, 0xfe, 0x67, 0x80, 0x00, 0x00, }; -// li t2/t3/t4, 0 (zero-immediate bases) at word indices 3/4/5; patch | (colour<<20). +// li t2/t3/t4, 0 (zero-immediate bases) at word indices 3/4/5; patch | (color<<20). static constexpr uint32_t kRvLiBaseR = 0x00000393u; // li t2,0 static constexpr uint32_t kRvLiBaseG = 0x00000e13u; // li t3,0 static constexpr uint32_t kRvLiBaseB = 0x00000e93u; // li t4,0 diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 5b1a7014..6eae7073 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -405,6 +405,19 @@ static const char* NET_TAG = "mm_net"; #ifndef MM_NO_ETH static bool ethLinkUp_ = false; static bool ethConnected_ = false; +// Static-addressing state for Ethernet, so the CONNECTED handler restores the static IP on a +// re-plug instead of letting IDF's per-link-up DHCP-client restart pull a lease. Set by +// netSetStaticIPv4(Eth) (which stores the octets), cleared by netSetDhcp(Eth). +// std::atomic (the wifiStaStopping_ pattern): written on the render task (tick1s), read on the IDF +// event task (link-up re-pin). The octet arrays are published BEFORE the flag's release-store, so a +// handler that acquires a true flag reads a fully-written config; a mid-session octet edit is +// re-applied from the render task itself (syncAddressingLive), which overrides any stale +// handler-side apply on the netif. +static std::atomic<bool> ethStatic_{false}; +static uint8_t ethStaticIp_[4] = {}; +static uint8_t ethStaticGw_[4] = {}; +static uint8_t ethStaticMask_[4] = {}; +static uint8_t ethStaticDns_[4] = {}; static esp_netif_t* ethNetif_ = nullptr; // Retained so a live W5500 reconfigure (ethStop → re-init) can tear the driver // down cleanly. eth_handle is the running driver (set on both RMII and W5500 init); @@ -464,6 +477,19 @@ static void applyHostname(esp_netif_t* netif) { // WiFi-only state — absent in the Ethernet-only build. static bool wifiStaConnected_ = false; static bool wifiApActive_ = false; +// L2 association state, distinct from wifiStaConnected_ (which means "has an IP"): true between +// WIFI_EVENT_STA_CONNECTED and _DISCONNECTED. A static STA is reachable once associated (no DHCP +// round), so this is the signal the static apply keys off — see netSetStaticIPv4(Sta). +static bool wifiStaAssociated_ = false; +// Static-addressing state for WiFi STA, mirroring the eth pair. `wifiStaConnected_` normally means +// "has a DHCP IP" (set on GOT_IP), which never fires on a DHCP-less network — so for a static STA +// the address is pinned at L2 association (WIFI_EVENT_STA_CONNECTED) and connected is marked there. +// std::atomic with the same cross-task publish contract as ethStatic_ (octets before flag). +static std::atomic<bool> staStatic_{false}; +static uint8_t staStaticIp_[4] = {}; +static uint8_t staStaticGw_[4] = {}; +static uint8_t staStaticMask_[4] = {}; +static uint8_t staStaticDns_[4] = {}; static esp_netif_t* staNetif_ = nullptr; static esp_netif_t* apNetif_ = nullptr; static bool wifiInitDone_ = false; @@ -485,14 +511,22 @@ static void ethEventHandler(void* /*arg*/, esp_event_base_t base, if (id == ETHERNET_EVENT_CONNECTED) { ESP_LOGI(NET_TAG, "Ethernet link up"); ethLinkUp_ = true; - // Set the DHCP hostname HERE, on link-up, not in ethInit(): IDF's default - // eth netif starts the DHCP client from its own CONNECTED handler, so a - // hostname set earlier (in ethInit, before link-up) is clobbered when that - // client (re)starts nameless — the lease lands blank. Bouncing the client - // here (after the netif is started, when set_hostname takes) makes the - // DISCOVER carry the name. WiFi doesn't need this: its DHCP client only - // starts on association, well after we set the name in wifiStaInit. - applyHostname(ethNetif_); + if (ethStatic_.load(std::memory_order_acquire)) { + // Static mode: do NOT let the DHCP client restart on this link-up (applyHostname + // would) — that is what made a re-plugged cable grab a DHCP lease instead of the + // configured static IP. Re-pin the stored static config directly so the interface + // returns to its static address immediately (netSetStaticIPv4 stops dhcpc + sets it). + netSetStaticIPv4(NetIface::Eth, ethStaticIp_, ethStaticGw_, ethStaticMask_, ethStaticDns_); + } else { + // Set the DHCP hostname HERE, on link-up, not in ethInit(): IDF's default + // eth netif starts the DHCP client from its own CONNECTED handler, so a + // hostname set earlier (in ethInit, before link-up) is clobbered when that + // client (re)starts nameless — the lease lands blank. Bouncing the client + // here (after the netif is started, when set_hostname takes) makes the + // DISCOVER carry the name. WiFi doesn't need this: its DHCP client only + // starts on association, well after we set the name in wifiStaInit. + applyHostname(ethNetif_); + } } else if (id == ETHERNET_EVENT_DISCONNECTED) { ethLinkUp_ = false; ESP_LOGI(NET_TAG, "Ethernet link down"); @@ -525,6 +559,63 @@ void setEthConfig(const EthPinConfig& cfg) { ethConfig_ = cfg; } // in one function branched on the chip (a compile-time #ifdef, since the RGMII // interface is S31-only) rather than two near-identical copies. #ifdef CONFIG_ETH_USE_ESP32_EMAC + +#ifdef CONFIG_IDF_TARGET_ESP32S31 +// YT8531 (Motorcomm) RGMII PHY board init — the two vendor-specific steps the generic 802.3 driver +// can't do, applied through the standard esp_eth_ioctl() register API (no dedicated PHY driver exists +// for the YT8531; IDF v6 ships only esp_eth_phy_new_generic). Without step 1 the RGMII link never +// negotiates (no speed/duplex agreed) — the reason the S31's link/activity LED stays dark. Mirrors +// IDF's own examples/ethernet/basic YT8531 handler (the S31 is Espressif's reference board for it): +// 1. Re-enable auto-negotiation — the YT8531 disables it on hardware reset (undocumented behaviour; +// the generic driver's reset leaves it off), so no speed/duplex is agreed and the link is unusable. +// 2. Configure the RGMII Tx/Rx internal clock delays (~2 ns each) via the extended-register interface +// (write the ext-reg address to 0x1E, read/modify/write the data via 0x1F): RX coarse delay enable +// in EXT_CHIP_CONFIG (0xA001 bit 8), TX delay 13×150 ps ≈ 1.95 ns in EXT_RGMII_CONFIG1 (0xA003 +// bits [7:0]). These are the delay values IDF's example uses; a board whose PCB trace lengths need +// a different skew tunes them here. (DHCP at 100M on a 10/100 switch needs a further MAC Tx-clock +// fix that isn't here yet — see docs/backlog/backlog-core.md; this init is what brings the link up.) +static esp_err_t ethYt8531BoardInit(esp_eth_handle_t eth_handle) { + bool autoNegoEn = true; + esp_err_t err = esp_eth_ioctl(eth_handle, ETH_CMD_S_AUTONEGO, &autoNegoEn); + if (err != ESP_OK) return err; + + uint32_t regVal = 0; + esp_eth_phy_reg_rw_data_t phyReg = {}; + phyReg.reg_value_p = ®Val; + + // RX ~2 ns coarse delay: EXT_CHIP_CONFIG (0xA001) bit 8 (rxc_dly_en). + regVal = 0xA001; phyReg.reg_addr = 0x1E; + if ((err = esp_eth_ioctl(eth_handle, ETH_CMD_WRITE_PHY_REG, &phyReg)) != ESP_OK) return err; + phyReg.reg_addr = 0x1F; + if ((err = esp_eth_ioctl(eth_handle, ETH_CMD_READ_PHY_REG, &phyReg)) != ESP_OK) return err; + regVal |= (1U << 8); + if ((err = esp_eth_ioctl(eth_handle, ETH_CMD_WRITE_PHY_REG, &phyReg)) != ESP_OK) return err; + + // TX + RX delays: EXT_RGMII_CONFIG1 (0xA003). Bits [3:0] ge_tx_delay, [7:4] fe_tx_delay, + // [13:10] rx_delay — each 0..15 = 0.000..2.250 ns in 0.150 ns steps (Motorcomm YT8521/YT8531 map). + // TX = 13 (1.95 ns). RX data delay [13:10] is set here (the 0xA001 bit-8 above is only the coarse RXC + // enable). MM_YT8531_{RX,TX}_DELAY are per-board tuning knobs; the defaults match IDF's example. +#ifndef MM_YT8531_RX_DELAY +#define MM_YT8531_RX_DELAY 0 +#endif +#ifndef MM_YT8531_TX_DELAY +#define MM_YT8531_TX_DELAY 13 +#endif + regVal = 0xA003; phyReg.reg_addr = 0x1E; + if ((err = esp_eth_ioctl(eth_handle, ETH_CMD_WRITE_PHY_REG, &phyReg)) != ESP_OK) return err; + phyReg.reg_addr = 0x1F; + if ((err = esp_eth_ioctl(eth_handle, ETH_CMD_READ_PHY_REG, &phyReg)) != ESP_OK) return err; + regVal = (regVal & ~0x3CFFU) // clear rx_delay [13:10] + tx [7:0] + | ((uint32_t)(MM_YT8531_RX_DELAY & 0xF) << 10) // rx_delay + | ((uint32_t)(MM_YT8531_TX_DELAY & 0xF) << 4) // fe_tx + | ((uint32_t)(MM_YT8531_TX_DELAY & 0xF) << 0); // ge_tx + if ((err = esp_eth_ioctl(eth_handle, ETH_CMD_WRITE_PHY_REG, &phyReg)) != ESP_OK) return err; + + ESP_LOGI(NET_TAG, "YT8531 RGMII init: auto-nego re-enabled, Rx+Tx delays set"); + return ESP_OK; +} +#endif // CONFIG_IDF_TARGET_ESP32S31 + static bool ethInitEmac() { esp_netif_config_t netif_cfg = ESP_NETIF_DEFAULT_ETH(); ethNetif_ = esp_netif_new(&netif_cfg); @@ -612,6 +703,17 @@ static bool ethInitEmac() { } // From here the driver owns mac+phy (driver_uninstall frees them); the // remaining failure paths uninstall the driver instead of del-ing mac/phy. +#ifdef CONFIG_IDF_TARGET_ESP32S31 + // The YT8531 needs a vendor-specific auto-nego re-enable (+ RGMII delays) the generic driver + // can't do — without it the RGMII link never negotiates. Run right after install (driver/PHY + // exist, before start), the same order IDF's example uses. Non-fatal: a failed register write + // logs a warning and continues (the link just may not come up) rather than dropping Ethernet. + { + esp_err_t yterr = ethYt8531BoardInit(eth_handle); + if (yterr != ESP_OK) ESP_LOGW(NET_TAG, "YT8531 RGMII init failed: %s (link may not come up)", + esp_err_to_name(yterr)); + } +#endif ESP_ERROR_CHECK(esp_netif_attach(ethNetif_, esp_eth_new_netif_glue(eth_handle))); ESP_ERROR_CHECK(esp_event_handler_register(ETH_EVENT, ESP_EVENT_ANY_ID, @@ -824,8 +926,18 @@ static std::atomic<uint32_t> apClients_{0}; static void wifiEventHandler(void* /*arg*/, esp_event_base_t base, int32_t id, void* data) { if (base == WIFI_EVENT) { - if (id == WIFI_EVENT_STA_DISCONNECTED) { + if (id == WIFI_EVENT_STA_CONNECTED) { + // L2 association complete (before DHCP). In Static mode, pin the stored config now and + // mark connected — a DHCP-less network never fires GOT_IP, so waiting for it would strand + // a static STA. Mirrors the eth CONNECTED handler's ethStatic_ re-pin. DHCP mode is a + // no-op here (the DHCP client runs and GOT_IP sets wifiStaConnected_ as before). + wifiStaAssociated_ = true; + if (staStatic_.load(std::memory_order_acquire)) { + netSetStaticIPv4(NetIface::Sta, staStaticIp_, staStaticGw_, staStaticMask_, staStaticDns_); + } + } else if (id == WIFI_EVENT_STA_DISCONNECTED) { wifiStaConnected_ = false; + wifiStaAssociated_ = false; // **The reconnect must be explicit — IDF does not do it for us.** Without this // esp_wifi_connect(), a dropped association is permanent: the device keeps rendering but // is unreachable until it is power-cycled, which for a controller in a ceiling is a real @@ -1170,6 +1282,114 @@ bool networkReady() { return ethConnected() || wifiStaConnected() || wifiApConnected(); } +// Resolve a NetIface to its netif pointer. Each arm is compiled out on a build that lacks that +// interface (MM_NO_ETH / MM_NO_WIFI), so the static-addressing setters below compile everywhere +// and simply no-op for an absent interface (null netif → the callers return early). +static esp_netif_t* resolveNetif(NetIface iface) { + switch (iface) { + case NetIface::Eth: +#ifndef MM_NO_ETH + return ethNetif_; +#else + return nullptr; +#endif + case NetIface::Sta: +#ifndef MM_NO_WIFI + return staNetif_; +#else + return nullptr; +#endif + } + return nullptr; +} + +// Pin a static IPv4 config onto a client interface: stop its DHCP client (so it does not overwrite +// the address with a lease) and set ip/gateway/mask, plus DNS when a non-zero server is given. +// Mirrors the SoftAP static block (see wifiApInit) in client form (dhcpc vs dhcps). All-zero ip is +// a no-op guard. Idempotent. IP4_ADDR (the lwip macro the AP block uses) packs the four octets +// straight into each esp_ip4_addr_t. +void netSetStaticIPv4(NetIface iface, const uint8_t ip[4], const uint8_t gw[4], + const uint8_t mask[4], const uint8_t dns[4]) { + esp_netif_t* netif = resolveNetif(iface); + if (!netif) return; + if (!ip || (!ip[0] && !ip[1] && !ip[2] && !ip[3])) return; // no static IP set — leave DHCP + + esp_netif_dhcpc_stop(netif); // ignore ESP_ERR_ESP_NETIF_DHCP_ALREADY_STOPPED + + esp_netif_ip_info_t info = {}; + IP4_ADDR(&info.ip, ip[0], ip[1], ip[2], ip[3]); + IP4_ADDR(&info.gw, gw[0], gw[1], gw[2], gw[3]); + IP4_ADDR(&info.netmask, mask[0], mask[1], mask[2], mask[3]); + esp_netif_set_ip_info(netif, &info); + + if (dns && (dns[0] || dns[1] || dns[2] || dns[3])) { // only set DNS if one was given + esp_netif_dns_info_t dnsInfo = {}; + dnsInfo.ip.type = ESP_IPADDR_TYPE_V4; + IP4_ADDR(&dnsInfo.ip.u_addr.ip4, dns[0], dns[1], dns[2], dns[3]); + esp_netif_set_dns_info(netif, ESP_NETIF_DNS_MAIN, &dnsInfo); + } + + // Applying a static IP IS the "interface now has an address" moment — there is no DHCP GOT_IP + // event to wait for. Both interfaces' "connected" flags key off that DHCP event, so a static + // apply must set them itself (symmetric with how GOT_IP would). Record the config + flag too, so + // each interface's link-up handler re-pins static on a reconnect instead of restarting DHCP. +#ifndef MM_NO_ETH + if (iface == NetIface::Eth) { + // Octets first, flag last (release): the event task's link-up re-pin acquires the flag, + // so a true flag guarantees a fully-written config. + for (int i = 0; i < 4; i++) { + ethStaticIp_[i] = ip[i]; ethStaticGw_[i] = gw[i]; + ethStaticMask_[i] = mask[i]; ethStaticDns_[i] = dns ? dns[i] : 0; + } + ethStatic_.store(true, std::memory_order_release); + // Mark connected only if the link is actually up — else a static apply racing a cable pull + // would leave ethConnected() true on a dead link (the state machine would sit in ConnectedEth + // instead of cascading). On a genuine link-up the CONNECTED handler re-applies + sets it. + if (ethLinkUp_) ethConnected_ = true; + } +#endif +#ifndef MM_NO_WIFI + if (iface == NetIface::Sta) { + // Octets first, flag last (release) — same publish contract as the eth arm above. + for (int i = 0; i < 4; i++) { + staStaticIp_[i] = ip[i]; staStaticGw_[i] = gw[i]; + staStaticMask_[i] = mask[i]; staStaticDns_[i] = dns ? dns[i] : 0; + } + staStatic_.store(true, std::memory_order_release); + // wifiStaConnected_ normally means "got a DHCP IP", which never fires on a DHCP-less network + // — the very case static addressing exists for. So mark connected here (the IP is applied); + // WIFI_EVENT_STA_CONNECTED re-applies on a reconnect. Only when the STA is actually + // associated, so a static apply while the radio is down doesn't fake a connection. + if (wifiStaAssociated_) wifiStaConnected_ = true; + } +#endif + ESP_LOGI(NET_TAG, "Static IPv4 set on %s: %u.%u.%u.%u", + iface == NetIface::Eth ? "eth" : "sta", ip[0], ip[1], ip[2], ip[3]); +} + +// Return a client interface to DHCP: (re)start its DHCP client so it re-leases without a reboot. +// The counterpart to netSetStaticIPv4 for a Static→DHCP toggle. Safe if already running. +void netSetDhcp(NetIface iface) { + esp_netif_t* netif = resolveNetif(iface); + if (!netif) return; +#ifndef MM_NO_ETH + if (iface == NetIface::Eth) { + ethStatic_.store(false, std::memory_order_release); // link-up handler goes back to the DHCP hostname path + ethConnected_ = false; // static forced this true; drop it so the state machine re-evaluates + // (GOT_IP re-sets it on a lease). Else a Static→DHCP toggle on a + // network that can't lease wedges in ConnectedEth at 0.0.0.0. + } +#endif +#ifndef MM_NO_WIFI + if (iface == NetIface::Sta) { + staStatic_.store(false, std::memory_order_release); // stop re-pinning static on the next association + wifiStaConnected_ = false; // static forced this true; GOT_IP re-sets it once DHCP leases + } +#endif + esp_netif_dhcpc_start(netif); // ignore ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED + ESP_LOGI(NET_TAG, "DHCP restarted on %s", iface == NetIface::Eth ? "eth" : "sta"); +} + // Bring the mDNS stack up (idempotent) and ADVERTISE this device as <deviceName>.local. // Advertising is gated by the user's mDNS toggle; the stack init stays — mdnsStop() // removes the services + hostname but keeps the stack up, so toggling mDNS back on diff --git a/src/platform/platform.h b/src/platform/platform.h index 63cd5379..f68dbdd8 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -341,6 +341,27 @@ int wifiStaRssi(); void wifiStaBssid(uint8_t out[6]); int wifiStaChannel(); +// A client interface (STA or Ethernet) whose addressing NetworkModule sets. One enum so a +// single netSetStaticIPv4 serves both, rather than a per-interface duplicate. (The AP is not +// here: it is always the DHCP *server* at a fixed IP, a different role — wifiApInit sets that.) +enum class NetIface : uint8_t { Sta, Eth }; +// Switch a client interface to a STATIC IPv4 config: stop its DHCP client and pin ip/gateway/mask +// (+ DNS if non-zero) onto the netif. Octets, matching ethGetIPv4/wifiStaGetIPv4. Passing an +// all-zero `ip` is a no-op guard (treated as "not static"). Idempotent — safe to re-apply. To go +// back to DHCP, call netSetDhcp(iface). Desktop: no-op (host uses OS networking). The netif must +// exist (interface init has run); NetworkModule calls this after bring-up / on a live toggle. +void netSetStaticIPv4(NetIface iface, const uint8_t ip[4], const uint8_t gw[4], + const uint8_t mask[4], const uint8_t dns[4]); +// Return a client interface to DHCP: restart its DHCP client so it re-leases live (no reboot). +// The counterpart to netSetStaticIPv4 for a Static→DHCP toggle. Desktop: no-op. +void netSetDhcp(NetIface iface); +// Test seams (desktop-only impls, same contract as setTestBindFails — reset in release so cases +// stay independent): make wifiStaInit() succeed so a host test can drive the STA cascade +// (WaitingSta) that the radio-less desktop otherwise never enters, and count netSetStaticIPv4() +// applies per interface so the test can pin that the static-addressing path reached the platform. +void setTestWifiStaAvailable(bool available); +uint32_t testNetStaticApplyCount(NetIface iface); + bool wifiApInit(const char* apName, const char* ip); bool wifiApConnected(); void wifiApStop(); diff --git a/src/ui/app.js b/src/ui/app.js index 0b8b9ac3..b8bb7a77 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -1366,6 +1366,41 @@ function createControl(moduleName, moduleType, ctrl) { const key = moduleName + ":" + ctrl.name; const def = defaultFor(moduleType, ctrl.name); + // numberField: a numeric control that opted out of the slider (server sets it for a value where each + // integer is a discrete identity, not a magnitude — a PHY/I2C address, a channel). Render a plain + // number input, same shape as the `pin` case, whatever the underlying numeric type. The WS-patch path + // (updateModuleControls) reads the input by [data-mid][data-key] the same way, so no extra patch case. + const isNumericType = ctrl.type === "uint8" || ctrl.type === "uint16" || ctrl.type === "int16"; + if (ctrl.numberField && isNumericType) { + const nMin = Number(ctrl.min ?? 0); + const nMax = Number(ctrl.max ?? 65535); + const input = document.createElement("input"); + input.type = "number"; + input.min = nMin; + input.max = nMax; + input.value = ctrl.value ?? 0; + input.dataset.mid = moduleName; + input.dataset.key = ctrl.name; + input.addEventListener("input", () => { + dragTs[key] = Date.now(); + let v = parseInt(input.value, 10); + if (Number.isNaN(v)) return; // mid-edit empty field: send nothing until digits arrive + v = Math.max(nMin, Math.min(nMax, v)); + if (String(v) !== input.value) input.value = v; // display always matches what's sent + debounceSend(key, 500, () => sendControl(moduleName, ctrl.name, v)); + }); + input.addEventListener("change", () => { // blur/Enter with a still-empty field: snap to min + send + if (Number.isNaN(parseInt(input.value, 10))) { + dragTs[key] = Date.now(); + input.value = nMin; + debounceSend(key, 500, () => sendControl(moduleName, ctrl.name, nMin)); + } + }); + row.appendChild(input); + appendResetButton(row, moduleName, ctrl, def, () => { input.value = def; }); + return row; + } + switch (ctrl.type) { case "uint8": { const input = document.createElement("input"); @@ -1678,7 +1713,7 @@ function createControl(moduleName, moduleType, ctrl) { break; } case "palette": { - // A colour-palette dropdown where EVERY option shows its own gradient — so the colours + // A color-palette dropdown where EVERY option shows its own gradient — so the colors // are visible before selecting, not just after. A native <select> can't do this // (browsers ignore a gradient background on <option>, and the macOS popup is OS-drawn), // so this is a custom dropdown: a trigger button (selected swatch + name + caret) that @@ -1948,7 +1983,7 @@ function buildListEntries(container, rows, details, openSet, opts) { summary.tabIndex = 0; summary.setAttribute("role", "button"); // Freshness dot (always-visible age at a glance) when the row carries a `*Sec` - // duration; coloured by ageBucketClass. Generic — no device knowledge here. + // duration; colored by ageBucketClass. Generic — no device knowledge here. // The age fields (`ageSec`/`cached`) live in the DETAIL object, not the summary, // so read the detail for the dot (it also carries `self`); fall back to the // summary item when a list has no separate detail. @@ -2246,7 +2281,7 @@ function rowAgeClass(item) { // Severity CSS class from a row's `severity` field ("error"/"warn"), or "" if none/absent. Generic // over the field name, exactly like rowAgeClass over `*Sec`: any ListSource that emits a `severity` -// string gets the same visual (a coloured row marker). PinsModule is the first user — it flags a GPIO +// string gets the same visual (a colored row marker). PinsModule is the first user — it flags a GPIO // claim on a reserved/strap/input-only pin — but nothing here is pins-specific; a task in a bad state // or a device with an error could emit `severity` and light up the same way. function rowSeverityClass(item) { @@ -2281,6 +2316,9 @@ function appendResetButton(row, moduleName, ctrl, def, applyVisually) { const eq = controlValuesEqual(ctrl, def); btn.classList.toggle("active", !eq); btn.addEventListener("click", () => { + const key = moduleName + ":" + ctrl.name; + clearTimeout(dragTimers[key]); // a pending debounced edit must not overwrite the reset + dragTs[key] = Date.now(); // and a stale WS patch must not revert it applyVisually(); sendControl(moduleName, ctrl.name, def); }); @@ -2739,7 +2777,7 @@ function cssEscape(s) { // source of truth in the UI saves repeating the same character in ~30 module // headers and a few bytes per type in /api/types. Each module's tags() then // only carries its categorical origin (🐙 WLED · 💫 MoonLight · ⚡️ FastLED) -// and any feature extras (audio: ♫ FFT · ♪ volume · moving-head: 🚨 colour · +// and any feature extras (audio: ♫ FFT · ♪ volume · moving-head: 🚨 color · // 🗼 movement). The dimensional emoji (📏 1D · 🟦 2D · 🧊 3D) is derived from // the type's `dim` field. All three are merged in emojiTagsFor(). const ROLE_EMOJI = { @@ -3085,7 +3123,7 @@ function setupStatusBarButtons() { applyTheme(theme); // Repaint the preview to the new theme's background — a live preview would // pick it up on its next frame, but an idle one (no incoming frames) needs - // a nudge so the canvas doesn't keep the previous theme's clear colour. + // a nudge so the canvas doesn't keep the previous theme's clear color. preview.redraw(); }); @@ -3735,7 +3773,7 @@ function fmFilesystemUsage(mod) { // Format a file's text for the editor, by extension. JSON is re-indented (2 spaces) so the persisted // config files are readable; anything that doesn't parse is shown verbatim rather than erroring. -// Extension seam for later: MoonLive `.ml` source wants syntax *highlighting* (a colour layer over +// Extension seam for later: MoonLive `.ml` source wants syntax *highlighting* (a color layer over // the textarea), not reformatting — that's a bigger editor change, added when MoonLive `.ml` files // land on disk. function fmPrettify(text, relPath) { diff --git a/src/ui/install-picker.js b/src/ui/install-picker.js index 42aa526b..6d348298 100644 --- a/src/ui/install-picker.js +++ b/src/ui/install-picker.js @@ -375,7 +375,7 @@ function render(state) { } // One option per release, newest-first. RC tags carry a "(beta)" suffix - // and a different colour so a casual user can't mistake them for a + // and a different color so a casual user can't mistake them for a // production release. The compatibility filter at the firmware step // handles the "is this binary for my chip?" question, so the release // dropdown doesn't pre-filter on it — a user can still see every release diff --git a/src/ui/preview3d.js b/src/ui/preview3d.js index 55108d53..095f810a 100644 --- a/src/ui/preview3d.js +++ b/src/ui/preview3d.js @@ -5,7 +5,7 @@ // preview.setupLayout() once, for docked-split ↔ floating-PiP responsiveness // preview.onBinaryMessage(buf) per WebSocket binary frame // It owns its own GL context, camera, and geometry; it talks to the rest of the -// app only through the DOM (#preview canvas, --bg-0 theme colour) and +// app only through the DOM (#preview canvas, --bg-0 theme color) and // localStorage (mm_cam). No app.js state crosses the boundary. let gl = null; @@ -45,7 +45,7 @@ let lastVertCount = 0; let lastMaxDim = 1; let vertsBuf = null; // reused worst-case Float32Array; grows but never shrinks // True-shape preview geometry, set from the 0x03 coordinate table and reused -// across 0x02 colour frames (positions change only on a layout/LUT rebuild). +// across 0x02 color frames (positions change only on a layout/LUT rebuild). let previewCoords_ = null; // Float32Array[count*3], normalised + box-centred positions let previewCoordCount_ = 0; let previewMaxDim_ = 1; @@ -120,7 +120,7 @@ function initWebGL() { if (a < 0.01) discard; gl_FragColor = vec4(vec3(0.32), a); } else { - // Pass 2 — lit LEDs only, solid disc in the real colour, on top. + // Pass 2 — lit LEDs only, solid disc in the real color, on top. if (lit < 0.5) discard; // off LEDs were pass 1 if (disc < 0.01) discard; gl_FragColor = vec4(bright, disc); @@ -156,7 +156,7 @@ function initWebGL() { // A second, minimal program for the wireframe bounding box (a faint cuboid around // the light volume — gives the scene bounds + 3D orientation while orbiting, and a - // frame even when every LED is off). Flat colour, no per-vertex attributes beyond pos. + // frame even when every LED is off). Flat color, no per-vertex attributes beyond pos. const lvs = `attribute vec3 aPos; uniform mat4 uMVP; void main(){ gl_Position = uMVP * vec4(aPos,1.0); }`; const lfs = `precision mediump float; uniform vec4 uColor; void main(){ gl_FragColor = uColor; }`; const lv = gl.createShader(gl.VERTEX_SHADER); gl.shaderSource(lv, lvs); gl.compileShader(lv); @@ -428,9 +428,9 @@ function setupLayout() { // 0x03 coordinate table (once per layout/LUT rebuild + ~1 Hz keepalive): // [0x03][count:u32][bx:u8][by:u8][bz:u8][stride:u16][(x,y,z):u8×3 × count] // Stores the real lights' normalised positions in previewCoords_ (the -// geometry); per-frame 0x02 messages then just recolour those points. +// geometry); per-frame 0x02 messages then just recolor those points. // 0x02 per-frame channels: [0x02][count:u32][stride:u16][(r,g,b) × count] -// Colour for light i sits at position previewCoords_[i]. +// Color for light i sits at position previewCoords_[i]. // count is u32 so a >65535-light panel (HUB75 walls) isn't capped by the wire format. // Light index i in the 0x02 stream matches coordinate-table entry i (both are // every stride-th driver light, in the same order) — no dense grid, no decompress. @@ -483,8 +483,8 @@ function parsePreviewCoords(view, buf) { previewCoordCount_ = count; previewBox_ = { x: bx, y: by, z: bz }; // Draw the grid layout NOW, off (placeholder rings), so a fresh page / UI refresh shows the - // geometry the instant the table arrives — not only once the first colour frame happens to land - // (which never comes if the scene is paused/idle). Colour frames then light it. + // geometry the instant the table arrives — not only once the first color frame happens to land + // (which never comes if the scene is paused/idle). Color frames then light it. drawLights(null); } @@ -500,10 +500,10 @@ function renderPreviewFrame(view, buf) { const stride = view.getUint16(5, true) || 1; if (buf.byteLength < 7 + count * 3) return; const rgb = new Uint8Array(buf, 7); - // RGB[i] colours the light at previewCoords_[i]. The colour frame and the coordinate table + // RGB[i] colors the light at previewCoords_[i]. The color frame and the coordinate table // MUST describe the same light set — if their count OR stride (downscale factor) disagree, a // geometry rebuild (a resize, or the device's adaptive downscale changing the lattice) is - // mid-flight: the colours would land on the wrong positions (a visibly scrambled frame). + // mid-flight: the colors would land on the wrong positions (a visibly scrambled frame). // Skip such a frame; the matching coord table arrives within ~1 frame and they realign. if (count !== previewCoordCount_ || stride !== previewStride_) return; drawLights(rgb); @@ -527,10 +527,10 @@ function measureFrameRate() { updatePreviewStatus(); } -// Build the vertex buffer from previewCoords_ + per-light colour and (re)start the render loop. +// Build the vertex buffer from previewCoords_ + per-light color and (re)start the render loop. // rgb may be null — then every light is drawn off (the shader's placeholder ring), so the grid // LAYOUT shows the instant the coordinate table arrives (a fresh page / UI refresh), before any -// colour frame. A colour frame then calls this again with its rgb to light the scene. +// color frame. A color frame then calls this again with its rgb to light the scene. function drawLights(rgb) { if (!gl) initWebGL(); if (!gl) return; @@ -633,7 +633,7 @@ function drawVerts() { const ez = camTgtZ + camDist * Math.cos(camPhi) * Math.cos(camTheta); const mvp = buildMVP(ex, ey, ez, camTgtX, camTgtY, camTgtZ, canvas.width / Math.max(1, canvas.height)); - // alpha:false context — clear to page background colour so the canvas + // alpha:false context — clear to page background color so the canvas // blends seamlessly in both light and dark themes. Read from <body>, not // <html>: the theme override is `body[data-theme="light"]`, so --bg-0 is // redefined on the body; getComputedStyle(documentElement) would only ever @@ -858,6 +858,6 @@ export const preview = { resetCamera: resetCamera, // Redraw the last frame with the current theme's background — call on a theme // toggle so an idle preview (no live frames) repaints to the new --bg-0 - // instead of keeping the previous theme's clear colour until the next frame. + // instead of keeping the previous theme's clear color until the next frame. redraw: redrawCached, }; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index a001d532..33c1e16d 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -67,6 +67,7 @@ add_executable(mm_tests unit/light/unit_TextEffect.cpp unit/light/unit_Coord3D.cpp unit/light/unit_effects_render.cpp + unit/light/unit_Effects_gridsweep.cpp unit/light/unit_SineEffect.cpp unit/light/unit_DistortionWavesEffect.cpp unit/light/unit_Correction.cpp @@ -119,8 +120,21 @@ add_executable(mm_tests unit/light/unit_RmtLedDriver_pins.cpp ) -target_include_directories(mm_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +# The grid-size sweep drives every effect, including ones added after this file was +# written, so its effect list is DERIVED from src/light/effects/ rather than typed here +# (a hand-kept list would silently miss the next effect — the exact gap the sweep closes). +# Always out-of-date on purpose, same rule as build_info.h: adding an effect file must +# regenerate. The generator rewrites only on change, so an unchanged effect set is a no-op. +add_custom_target(effect_sweep_gen ALL + COMMAND ${UV_EXECUTABLE} run python ${CMAKE_SOURCE_DIR}/moondeck/build/generate_effect_sweep.py ${CMAKE_CURRENT_BINARY_DIR}/generated/effect_sweep.h + BYPRODUCTS ${CMAKE_CURRENT_BINARY_DIR}/generated/effect_sweep.h + COMMENT "Generating effect_sweep.h (every effect, for the grid-size sweep)" + VERBATIM +) + +target_include_directories(mm_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}/generated) target_link_libraries(mm_tests PRIVATE mm_core mm_platform) +add_dependencies(mm_tests effect_sweep_gen) add_test(NAME unit_tests COMMAND mm_tests) diff --git a/test/scenario_runner.cpp b/test/scenario_runner.cpp index 5c9fb3c6..4b757103 100644 --- a/test/scenario_runner.cpp +++ b/test/scenario_runner.cpp @@ -925,6 +925,10 @@ static int runScenario(const char* path) { return result.passed ? 0 : 1; } +// Directory iteration can throw filesystem_error (a scenarios/ dir deleted mid-run). Letting it +// escape main is the correct outcome for a CLI test runner: it terminates with a diagnostic and +// a non-zero status, which is exactly what a harness needs to see. +// NOLINTNEXTLINE(bugprone-exception-escape) int main(int argc, char* argv[]) { if (argc < 2) { // Run all scenarios in the scenarios/ directory tree. diff --git a/test/scenarios/light/scenario_GridBlacks_blackpixel.json b/test/scenarios/light/scenario_GridBlacks_blackpixel.json index 7b096a4f..c8eea976 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, - 3 + 5 ], "free_heap": [ 0, @@ -103,7 +103,7 @@ ], "at": [ "2026-07-22", - "2026-07-23" + "2026-07-28" ] }, "esp32s3-n16r8": { @@ -172,7 +172,7 @@ "desktop-macos": { "tick_us": [ 1, - 5 + 8 ], "free_heap": [ 0, @@ -184,7 +184,7 @@ ], "at": [ "2026-07-22", - "2026-07-23" + "2026-07-28" ] }, "esp32s3-n16r8": { diff --git a/test/scenarios/light/scenario_Layouts_mutation.json b/test/scenarios/light/scenario_Layouts_mutation.json index e17a8390..128c9755 100644 --- a/test/scenarios/light/scenario_Layouts_mutation.json +++ b/test/scenarios/light/scenario_Layouts_mutation.json @@ -79,7 +79,7 @@ "desktop-macos": { "tick_us": [ 8, - 84 + 123 ], "free_heap": [ 0, @@ -91,7 +91,7 @@ ], "at": [ "2026-06-05", - "2026-07-15" + "2026-07-27" ] }, "desktop-windows": { diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index 97607de6..73649f95 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -14,7 +14,7 @@ "Drivers", "NetworkSendDriver" ], - "description": "Exercise a scripted MoonLiveEffect as a wired MoonModule end-to-end — the integration layer the unit tests can't reach. The effect compiles its `source` text to native code on-device and renders it into the Layer buffer each tick. Prepares its own canvas: Layout(Grid 16x16) + Layer + MoonLiveEffect, measures the default compile, then edits `source` live (a new fill colour recompiles and keeps rendering), pushes a BROKEN script (compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status, no crash), recovers with a valid script, and finally removes + re-adds the effect (add/remove robustness in any order). A crash in the JIT/emit path, a failed recompile that wedges the tick, or a buffer overrun on an odd grid all show up as a failed measure. The compiler + emit golden bytes are pinned by unit_moonlive_compiler / unit_moonlive_fill; this is the live wired-module gate.", + "description": "Exercise a scripted MoonLiveEffect as a wired MoonModule end-to-end — the integration layer the unit tests can't reach. The effect compiles its `source` text to native code on-device and renders it into the Layer buffer each tick. Prepares its own canvas: Layout(Grid 16x16) + Layer + MoonLiveEffect, measures the default compile, then edits `source` live (a new fill color recompiles and keeps rendering), pushes a BROKEN script (compile fails, the previous code is freed, the effect renders dark and the parse error surfaces in status, no crash), recovers with a valid script, and finally removes + re-adds the effect (add/remove robustness in any order). A crash in the JIT/emit path, a failed recompile that wedges the tick, or a buffer overrun on an odd grid all show up as a failed measure. The compiler + emit golden bytes are pinned by unit_moonlive_compiler / unit_moonlive_fill; this is the live wired-module gate.", "fixture": [ { "name": "fix-layouts", @@ -155,7 +155,7 @@ }, { "name": "edit-source-red", - "description": "Live-edit the script source to a new colour. A source edit triggers a recompile (affectsPrepare gates on `source`); the new native code swaps in and keeps rendering.", + "description": "Live-edit the script source to a new color. A source edit triggers a recompile (affectsPrepare gates on `source`); the new native code swaps in and keeps rendering.", "op": "set_control", "id": "ML", "key": "source", @@ -661,7 +661,7 @@ "desktop-macos": { "tick_us": [ 0, - 34 + 36 ], "free_heap": [ 0, @@ -673,7 +673,7 @@ ], "at": [ "2026-06-26", - "2026-07-16" + "2026-07-27" ] }, "esp32s3-n16r8": { diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index 025c235d..ca7d7f64 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -152,7 +152,7 @@ "desktop-macos": { "tick_us": [ 6, - 43 + 56 ], "free_heap": [ 0, @@ -164,7 +164,7 @@ ], "at": [ "2026-06-07", - "2026-07-15" + "2026-07-28" ] }, "esp32-eth": { @@ -396,7 +396,7 @@ "desktop-macos": { "tick_us": [ 6, - 99 + 106 ], "free_heap": [ 0, @@ -408,7 +408,7 @@ ], "at": [ "2026-06-07", - "2026-07-01" + "2026-07-28" ] }, "esp32-eth": { diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json new file mode 100644 index 00000000..9b07e56b --- /dev/null +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -0,0 +1,1434 @@ +{ + "name": "scenario_peripheral_grid_sweep", + "module": "ParallelLedDriver", + "mode": "mutate", + "also": [ + "MultiPinLedDriver", + "MoonLedDriver", + "ParlioLedDriver", + "RmtLedDriver", + "Layouts", + "GridLayout", + "Layers", + "Layer", + "NoiseEffect", + "Drivers", + "PreviewDriver" + ], + "description": "Cross-product benchmark: for EACH output peripheral (RMT, i80, MoonI80, Parlio), sweep the grid 16x16 -> 32x32 -> 64x64 -> 128x128 and measure the tick/heap at each size, driving the FULL grid out that peripheral (16 lanes so 128x128=16384 lights distribute ~1024/lane). This is the apples-to-apples per-board number set: run it identically on any board and the per-peripheral, per-size ticks compare directly (an S31 vs a P4, say). Each peripheral block is optional:true, so a board that lacks one (a classic ESP32 has no MoonI80/Parlio; a P4 has all three) skips it without failing, and the observed blocks record only where the peripheral is real. A NoiseEffect fills every cell so the render cost is non-trivial at every size. Pins are the S3/P4/S31 clean 16-lane set; a board whose pins differ records a driver that failed to init (a small bailout tick, not an encode) for that peripheral. NOTE the driver drives the full grid, so at 128x128 (16384 lights) a whole-frame path may exceed its DMA budget and idle with a status (a clean degrade, captured as a fast bail tick) while the streaming ring / PSRAM path keeps driving; that contrast IS the measurement.", + "fixture": [ + { + "name": "fix-layouts", + "op": "add_module", + "id": "Layouts", + "type": "Layouts" + }, + { + "name": "fix-layers", + "op": "add_module", + "id": "Layers", + "type": "Layers", + "props": { + "layouts": "Layouts" + } + }, + { + "name": "fix-drivers", + "op": "add_module", + "id": "Drivers", + "type": "Drivers", + "props": { + "layers": "Layers" + } + } + ], + "steps": [ + { + "name": "clear-layers", + "op": "clear_children", + "id": "Layers" + }, + { + "name": "clear-drivers", + "op": "clear_children", + "id": "Drivers" + }, + { + "name": "build-grid", + "description": "One GridLayout, resized live per sweep step below.", + "op": "add_module", + "id": "Grid", + "type": "GridLayout", + "parent_id": "Layouts", + "props": { + "width": 16, + "height": 16 + } + }, + { + "name": "build-layer", + "op": "add_module", + "id": "Layer", + "type": "Layer", + "parent_id": "Layers", + "props": { + "channelsPerLight": 3 + } + }, + { + "name": "build-fx", + "description": "A NoiseEffect fills every cell, so render is non-trivial at every grid size.", + "op": "add_module", + "id": "FX", + "type": "NoiseEffect", + "parent_id": "Layer" + }, + { + "name": "add-rmt", + "description": "RMT output (every ESP32). One pin; RMT drives per-channel, so this is the single-strand output baseline.", + "op": "add_module", + "id": "Out", + "type": "RmtLedDriver", + "parent_id": "Drivers", + "props": { + "pins": "18" + }, + "optional": true + }, + { + "name": "rmt-16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "optional": true + }, + { + "name": "rmt-16-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "optional": true + }, + { + "name": "measure-rmt-16", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 186, + 236 + ], + "free_heap": [ + 16970835, + 16995271 + ], + "max_alloc_block": [ + 167936, + 188416 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 103, + 165 + ], + "free_heap": [ + 33938615, + 33942295 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 14 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "rmt-32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32, + "optional": true + }, + { + "name": "rmt-32-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "optional": true + }, + { + "name": "measure-rmt-32", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 679, + 883 + ], + "free_heap": [ + 16896067, + 16916943 + ], + "max_alloc_block": [ + 167936, + 188416 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 491, + 559 + ], + "free_heap": [ + 33860283, + 33863779 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 17, + 48 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "rmt-64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "optional": true + }, + { + "name": "rmt-64-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "optional": true + }, + { + "name": "measure-rmt-64", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 2888, + 3989 + ], + "free_heap": [ + 16582719, + 16603591 + ], + "max_alloc_block": [ + 167936, + 188416 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 2208, + 2480 + ], + "free_heap": [ + 33546939, + 33550603 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 69, + 232 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "rmt-128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128, + "optional": true + }, + { + "name": "rmt-128-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "optional": true + }, + { + "name": "measure-rmt-128", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 6188, + 8730 + ], + "free_heap": [ + 15325471, + 15346695 + ], + "max_alloc_block": [ + 167936, + 188416 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 11495, + 11565 + ], + "free_heap": [ + 32293559, + 32297207 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 275, + 1036 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "remove-rmt", + "op": "remove_module", + "id": "Out", + "optional": true + }, + { + "name": "add-i80", + "description": "i80 (esp_lcd): LCD_CAM on S3/P4/S31, I2S on classic. 16 lanes so 128x128=16384 distributes ~1024/lane.", + "op": "add_module", + "id": "Out", + "type": "ParallelLedDriver", + "parent_id": "Drivers", + "props": { + "pins": "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16" + }, + "controls": { + "peripheral": "i80" + }, + "optional": true + }, + { + "name": "i80-16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "optional": true + }, + { + "name": "i80-16-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "optional": true + }, + { + "name": "measure-i80-16", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 405, + 577 + ], + "free_heap": [ + 16997363, + 17014551 + ], + "max_alloc_block": [ + 167936, + 192512 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 208, + 581 + ], + "free_heap": [ + 33961747, + 33965431 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 13 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "i80-32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32, + "optional": true + }, + { + "name": "i80-32-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "optional": true + }, + { + "name": "measure-i80-32", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 545, + 863 + ], + "free_heap": [ + 16992607, + 17006071 + ], + "max_alloc_block": [ + 167936, + 192512 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 446, + 495 + ], + "free_heap": [ + 33956971, + 33960823 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 17, + 56 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "i80-64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "optional": true + }, + { + "name": "i80-64-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "optional": true + }, + { + "name": "measure-i80-64", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 2279, + 4090 + ], + "free_heap": [ + 16974167, + 16995367 + ], + "max_alloc_block": [ + 167936, + 196608 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 1867, + 2296 + ], + "free_heap": [ + 33938707, + 33942223 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 68, + 227 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "i80-128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128, + "optional": true + }, + { + "name": "i80-128-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "optional": true + }, + { + "name": "measure-i80-128", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 6638, + 10961 + ], + "free_heap": [ + 16900439, + 16921639 + ], + "max_alloc_block": [ + 167936, + 196608 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 9046, + 11371 + ], + "free_heap": [ + 33864991, + 33868491 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 273, + 984 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "remove-i80", + "op": "remove_module", + "id": "Out", + "optional": true + }, + { + "name": "add-moonI80", + "description": "MoonI80 (own-GDMA below esp_lcd, LCD_CAM only): the streaming ring, so length is not a memory question. Same 16-lane pin set.", + "op": "add_module", + "id": "Out", + "type": "ParallelLedDriver", + "parent_id": "Drivers", + "props": { + "pins": "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16" + }, + "controls": { + "peripheral": "MoonI80" + }, + "optional": true + }, + { + "name": "moon-16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "optional": true + }, + { + "name": "moon-16-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "optional": true + }, + { + "name": "measure-moon-16", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 240, + 638 + ], + "free_heap": [ + 16997207, + 17014551 + ], + "max_alloc_block": [ + 167936, + 192512 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 240, + 598 + ], + "free_heap": [ + 33961783, + 33965427 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 20 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "moon-32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32, + "optional": true + }, + { + "name": "moon-32-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "optional": true + }, + { + "name": "measure-moon-32", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 677, + 824 + ], + "free_heap": [ + 16992811, + 17013811 + ], + "max_alloc_block": [ + 167936, + 196608 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 502, + 685 + ], + "free_heap": [ + 33957171, + 33960823 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 17, + 81 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "moon-64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "optional": true + }, + { + "name": "moon-64-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "optional": true + }, + { + "name": "measure-moon-64", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 1531, + 3852 + ], + "free_heap": [ + 16974503, + 16989575 + ], + "max_alloc_block": [ + 167936, + 192512 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 2304, + 2609 + ], + "free_heap": [ + 33938739, + 33942371 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 68, + 347 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "moon-128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128, + "optional": true + }, + { + "name": "moon-128-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "optional": true + }, + { + "name": "measure-moon-128", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 6047, + 7911 + ], + "free_heap": [ + 16895471, + 16921583 + ], + "max_alloc_block": [ + 167936, + 196608 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 6674, + 11410 + ], + "free_heap": [ + 33864999, + 33868651 + ], + "max_alloc_block": [ + 352256, + 360448 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 274, + 945 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "remove-moonI80", + "op": "remove_module", + "id": "Out", + "optional": true + }, + { + "name": "add-parlio", + "description": "Parlio (P4 / S31): the Parallel-IO peripheral, 16 lanes.", + "op": "add_module", + "id": "Out", + "type": "ParallelLedDriver", + "parent_id": "Drivers", + "props": { + "pins": "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16" + }, + "controls": { + "peripheral": "Parlio" + }, + "optional": true + }, + { + "name": "parlio-16-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 16, + "optional": true + }, + { + "name": "parlio-16-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 16, + "optional": true + }, + { + "name": "measure-parlio-16", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 370, + 1001 + ], + "free_heap": [ + 16993499, + 17018415 + ], + "max_alloc_block": [ + 167936, + 196608 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 387, + 572 + ], + "free_heap": [ + 33961771, + 33965099 + ], + "max_alloc_block": [ + 352256, + 352256 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 4, + 13 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "parlio-32-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 32, + "optional": true + }, + { + "name": "parlio-32-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 32, + "optional": true + }, + { + "name": "measure-parlio-32", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 573, + 703 + ], + "free_heap": [ + 16992939, + 17013807 + ], + "max_alloc_block": [ + 167936, + 196608 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 510, + 535 + ], + "free_heap": [ + 33957163, + 33960819 + ], + "max_alloc_block": [ + 352256, + 352256 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 17, + 56 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "parlio-64-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 64, + "optional": true + }, + { + "name": "parlio-64-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 64, + "optional": true + }, + { + "name": "measure-parlio-64", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 2545, + 2820 + ], + "free_heap": [ + 16974347, + 16995383 + ], + "max_alloc_block": [ + 167936, + 196608 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 2313, + 2384 + ], + "free_heap": [ + 33938727, + 33942387 + ], + "max_alloc_block": [ + 352256, + 352256 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 70, + 245 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "parlio-128-w", + "op": "set_control", + "id": "Grid", + "key": "width", + "value": 128, + "optional": true + }, + { + "name": "parlio-128-h", + "op": "set_control", + "id": "Grid", + "key": "height", + "value": 128, + "optional": true + }, + { + "name": "measure-parlio-128", + "op": "measure", + "measure": true, + "optional": true, + "observed": { + "esp32s31": { + "tick_us": [ + 12273, + 14105 + ], + "free_heap": [ + 16900583, + 16921655 + ], + "max_alloc_block": [ + 167936, + 196608 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "esp32p4-eth": { + "tick_us": [ + 4622, + 11440 + ], + "free_heap": [ + 33865011, + 33868663 + ], + "max_alloc_block": [ + 352256, + 352256 + ], + "at": [ + "2026-07-25", + "2026-07-25" + ] + }, + "desktop-macos": { + "tick_us": [ + 273, + 948 + ], + "free_heap": [ + 0, + 0 + ], + "max_alloc_block": [ + 0, + 0 + ], + "at": [ + "2026-07-26", + "2026-07-28" + ] + } + } + }, + { + "name": "remove-parlio", + "op": "remove_module", + "id": "Out", + "optional": true + }, + { + "name": "cleanup-fx", + "op": "remove_module", + "id": "FX", + "optional": true + }, + { + "name": "cleanup-layer", + "op": "remove_module", + "id": "Layer", + "optional": true + }, + { + "name": "cleanup-grid", + "op": "remove_module", + "id": "Grid", + "optional": true + } + ] +} diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json index 744c3778..c4859440 100644 --- a/test/scenarios/light/scenario_peripheral_switch.json +++ b/test/scenarios/light/scenario_peripheral_switch.json @@ -164,7 +164,7 @@ "desktop-macos": { "tick_us": [ 4, - 6 + 15 ], "free_heap": [ 0, @@ -176,7 +176,7 @@ ], "at": [ "2026-07-24", - "2026-07-24" + "2026-07-28" ] }, "esp32p4-eth": { @@ -254,7 +254,7 @@ "desktop-macos": { "tick_us": [ 4, - 6 + 15 ], "free_heap": [ 0, @@ -266,7 +266,7 @@ ], "at": [ "2026-07-24", - "2026-07-24" + "2026-07-28" ] }, "esp32p4-eth": { @@ -344,7 +344,7 @@ "desktop-macos": { "tick_us": [ 4, - 6 + 13 ], "free_heap": [ 0, @@ -356,7 +356,7 @@ ], "at": [ "2026-07-24", - "2026-07-24" + "2026-07-28" ] }, "esp32p4-eth": { @@ -433,7 +433,7 @@ "desktop-macos": { "tick_us": [ 4, - 6 + 14 ], "free_heap": [ 0, @@ -445,7 +445,7 @@ ], "at": [ "2026-07-24", - "2026-07-24" + "2026-07-28" ] }, "esp32p4-eth": { @@ -523,7 +523,7 @@ "desktop-macos": { "tick_us": [ 4, - 6 + 17 ], "free_heap": [ 0, @@ -535,7 +535,7 @@ ], "at": [ "2026-07-24", - "2026-07-24" + "2026-07-28" ] }, "esp32p4-eth": { @@ -629,7 +629,7 @@ "desktop-macos": { "tick_us": [ 4, - 6 + 13 ], "free_heap": [ 0, @@ -641,7 +641,7 @@ ], "at": [ "2026-07-24", - "2026-07-24" + "2026-07-28" ] }, "esp32p4-eth": { diff --git a/test/unit/core/unit_Buffer.cpp b/test/unit/core/unit_Buffer.cpp index e3baa37d..579ab62f 100644 --- a/test/unit/core/unit_Buffer.cpp +++ b/test/unit/core/unit_Buffer.cpp @@ -37,6 +37,9 @@ TEST_CASE("Buffer move constructor") { CHECK(b.data() == ptr); CHECK(b.count() == 100); CHECK(b.channelsPerLight() == 3); + // Asserting the moved-from state IS the test: Buffer's move must leave the source empty, + // not merely valid-but-unspecified. + // NOLINTNEXTLINE(bugprone-use-after-move) CHECK(a.data() == nullptr); CHECK(a.count() == 0); } @@ -51,6 +54,9 @@ TEST_CASE("Buffer move assignment") { b = std::move(a); CHECK(b.data() == ptr); CHECK(b.count() == 100); + // Asserting the moved-from state IS the test: Buffer's move must leave the source empty, + // not merely valid-but-unspecified. + // NOLINTNEXTLINE(bugprone-use-after-move) CHECK(a.data() == nullptr); } diff --git a/test/unit/core/unit_FileManagerModule.cpp b/test/unit/core/unit_FileManagerModule.cpp index 9cd93d60..98582904 100644 --- a/test/unit/core/unit_FileManagerModule.cpp +++ b/test/unit/core/unit_FileManagerModule.cpp @@ -53,7 +53,14 @@ struct Rig { } // Restore the DEFAULT root (fsSetRoot("") → "build"), not ".", so a later test in the same // binary starts from the same baseline this Rig assumed, never a leaked "." repo-root. - ~Rig() { platform::fsSetRoot(""); std::filesystem::remove_all(root); } + // Teardown must never propagate: this Rig is destroyed while the stack unwinds from a failed + // CHECK, and a throw there terminates the process, losing the very failure being reported. + // Hence both the error_code overload of remove_all (which cannot throw) and noexcept. + // The only residual throw path is fsSetRoot's std::filesystem::path assignment (a + // theoretical bad_alloc on a short literal). noexcept turning that into terminate is the + // right trade here: a test rig that cannot reset the fs root must not limp on. + // NOLINTNEXTLINE(bugprone-exception-escape) + ~Rig() noexcept { platform::fsSetRoot(""); std::error_code ec; std::filesystem::remove_all(root, ec); } bool onDisk(const char* rel) const { return std::filesystem::exists(std::string(root) + rel); @@ -146,6 +153,9 @@ TEST_CASE("HttpServer::parseFilePath rejects traversal, empty, missing, and over // byte-for-byte — the streamed-upload contract. namespace { struct SpanSrc { const char* p; size_t left; }; +// The signature must match the FsWriteSrc typedef, where `abort` is an out-parameter a source +// sets to stop the stream early — so it cannot become a pointer-to-const. +// NOLINTNEXTLINE(readability-non-const-parameter) size_t spanPull(char* out, size_t cap, void* user, bool* abort) { (void)abort; // this source always ends cleanly (no early close / timeout to signal) auto* s = static_cast<SpanSrc*>(user); diff --git a/test/unit/core/unit_NetworkModule_ethernet.cpp b/test/unit/core/unit_NetworkModule_ethernet.cpp index cc469b94..68ad9c58 100644 --- a/test/unit/core/unit_NetworkModule_ethernet.cpp +++ b/test/unit/core/unit_NetworkModule_ethernet.cpp @@ -28,6 +28,8 @@ #include "doctest.h" #include "platform_config.h" // EthPhyType, EthPinConfig, hasEthernet, ethConfigDefault #include "platform/platform.h" // setEthConfig / ethStop / ethInit / ethConnected +#include "core/NetworkModule.h" +#include <cstring> // The enum values are a wire contract: the Select index, the ethInit() switch, and // every deviceModels.json `ethType` all agree on these. Pin them so a reorder fails here. @@ -83,3 +85,100 @@ TEST_CASE("Desktop Ethernet seam is a safe no-op") { // but keep the test order-independent regardless of platform). mm::platform::setEthConfig(mm::platform::ethConfigDefault); } + +// Regression: the `ethPhyAddr` control MUST be a SIGNED int16 whose range starts at -1 and +// which renders as a number field (not a slider). -1 is ESP_ETH_PHY_ADDR_AUTO (scan the MDIO +// bus — the RGMII default). It was once an addUint8(0,31): the uint8 mangled the platform's -1 +// default to 255 and the 0..31 control clamped it to 31, a fixed address no PHY answered, so +// the S31's RGMII never linked. This pins the control-metadata contract that fixed it — signed +// storage so -1 round-trips, min == -1 so the sentinel is in-range, and numberField because an +// MDIO address is an identity, not a magnitude. Tests the addInt16 + setNumberField seam +// directly (the NetworkModule control is `if constexpr (hasEthernet)`-gated, absent on desktop), +// so a future edit that reverts to a slider or an unsigned type fails here, off-hardware. +TEST_CASE("ethPhyAddr-style control: signed int16, -1 sentinel in range, number field") { + mm::ControlList controls; + int16_t phyAddr = -1; // ESP_ETH_PHY_ADDR_AUTO — must survive as -1, not become 255/31 + controls.addInt16("ethPhyAddr", phyAddr, -1, 31); + controls.setNumberField(controls.count() - 1); + + const auto& c = controls[controls.count() - 1]; + CHECK(std::strcmp(c.name, "ethPhyAddr") == 0); + CHECK(c.type == mm::ControlType::Int16); // signed, so -1 is representable (a uint8 mangled it) + CHECK(c.min == -1); // the auto-detect sentinel is in-range, not clamped away + CHECK(c.max == 31); + CHECK(c.numberField); // rendered as a plain number input, not a 0..31 slider + CHECK(phyAddr == -1); // the bound value still reads -1 through the int16 control +} + +// Static-IP addressing contract. The `addressing` Select (DHCP=0 / Static=1) and the four IPv4 +// controls (ip/gateway/subnet/dns) are what platform::netSetStaticIPv4 applies to the active +// interface. Pin the control shape a future edit could break: the Select stores the mode index and +// defaults to DHCP, the static fields exist with their documented defaults, and they are HIDDEN in +// DHCP mode (visible only when addressing==Static). These are always bound (not hasEthernet-gated), +// so the contract is testable on the desktop host. +TEST_CASE("addressing Select + static-IP controls: DHCP default, Static reveals the fields") { + mm::NetworkModule net; + net.setup(); + net.rebuildControls(); // single clean build (setup already built once via startAP); see mode test + + const mm::ControlDescriptor* addressing = nullptr; + const mm::ControlDescriptor* ip = nullptr; + const mm::ControlDescriptor* subnet = nullptr; + for (uint8_t i = 0; i < net.controls().count(); i++) { + const auto& c = net.controls()[i]; + if (std::strcmp(c.name, "addressing") == 0) addressing = &c; + else if (std::strcmp(c.name, "ip") == 0) ip = &c; + else if (std::strcmp(c.name, "subnet") == 0) subnet = &c; + } + REQUIRE(addressing != nullptr); + CHECK(addressing->type == mm::ControlType::Select); + // Default is DHCP: the static fields are present but hidden until Static is selected. + REQUIRE(ip != nullptr); + REQUIRE(subnet != nullptr); + CHECK(ip->type == mm::ControlType::IPv4); + CHECK(ip->hidden); // DHCP mode → static fields hidden + CHECK(subnet->hidden); +} + +// The desktop platform's static/DHCP setters are inert no-ops: addressing is OS-managed on the +// host, so netSetStaticIPv4 / netSetDhcp must accept any input and change nothing (no crash, no +// interface brought up). Mirrors the "desktop net seam is a safe no-op" guarantee for ethInit etc. +TEST_CASE("Desktop static-addressing seam is a safe no-op") { + const uint8_t ip[4] = {192, 168, 1, 50}; + const uint8_t gw[4] = {192, 168, 1, 1}; + const uint8_t mask[4] = {255, 255, 255, 0}; + const uint8_t dns[4] = {192, 168, 1, 1}; + mm::platform::netSetStaticIPv4(mm::platform::NetIface::Eth, ip, gw, mask, dns); + mm::platform::netSetStaticIPv4(mm::platform::NetIface::Sta, ip, gw, mask, dns); + mm::platform::netSetDhcp(mm::platform::NetIface::Eth); + mm::platform::netSetDhcp(mm::platform::NetIface::Sta); + // Desktop reports no eth/sta connection regardless — the setters didn't fake one. + CHECK_FALSE(mm::platform::ethConnected()); +} + +// Static addressing on WiFi STA is applied during BRING-UP (WaitingSta), not only after a lease +// event: a DHCP-less network never fires one, so waiting for "connected" before pinning the static +// IP would strand a static STA into the AP fallback (the WaitingEth static poll's mirror). The test +// seam fakes an STA radio so the host can drive the cascade into WaitingSta; the platform apply +// counter pins that tick1s invoked netSetStaticIPv4(Sta). +TEST_CASE("Static mode pins the static IP during STA bring-up (WaitingSta)") { + mm::platform::setTestWifiStaAvailable(true); + { + mm::NetworkModule net; + net.setWifiCredentials("bench-ssid", "bench-pass"); + net.setup(); // desktop ethInit() fails → cascades to STA; the seam lands it in WaitingSta + // Switch to Static with a real address via the normal control-apply path (the octets bind + // by reference, so the module reads them directly). + for (uint8_t i = 0; i < net.controls().count(); i++) { + auto& c = net.controls()[i]; + if (std::strcmp(c.name, "addressing") == 0) + mm::applyControlValue(c, "{\"addressing\":1}", "addressing", mm::ApplyPolicy::Clamp); + else if (std::strcmp(c.name, "ip") == 0) + mm::applyControlValue(c, "{\"ip\":\"192.168.1.250\"}", "ip", mm::ApplyPolicy::Clamp); + } + uint32_t before = mm::platform::testNetStaticApplyCount(mm::platform::NetIface::Sta); + net.tick1s(); // WaitingSta: Static + not connected → applyStaticIfConfigured(Sta) + CHECK(mm::platform::testNetStaticApplyCount(mm::platform::NetIface::Sta) > before); + } + mm::platform::setTestWifiStaAvailable(false); // reset — cases stay independent +} diff --git a/test/unit/core/unit_PinsModule.cpp b/test/unit/core/unit_PinsModule.cpp index 6845c6ff..50bc4f8e 100644 --- a/test/unit/core/unit_PinsModule.cpp +++ b/test/unit/core/unit_PinsModule.cpp @@ -419,7 +419,7 @@ TEST_CASE("PinsModule: a safe pin carries no severity field") { const std::string rows = allRows(*pinsSource(pins)); CHECK(rows.find("\"gpio\":18") != std::string::npos); - CHECK(rows.find("\"severity\"") == std::string::npos); // safe → no field, no colour + CHECK(rows.find("\"severity\"") == std::string::npos); // safe → no field, no color } // --- live state (increment #4) ---------------------------------------------------------------- diff --git a/test/unit/core/unit_moonlive_fill.cpp b/test/unit/core/unit_moonlive_fill.cpp index 147f29f1..118ca612 100644 --- a/test/unit/core/unit_moonlive_fill.cpp +++ b/test/unit/core/unit_moonlive_fill.cpp @@ -9,7 +9,7 @@ #include <cstdint> #include <vector> -// MoonLive Stage 1a: the load-bearing slice — emit a fixed-colour fill as native code, +// MoonLive Stage 1a: the load-bearing slice — emit a fixed-color fill as native code, // place it in executable memory, and call it over a buffer. These tests run the WHOLE path // in-process on the desktop host backend (the host ISA's emit + platform::allocExec/ // writeExec + a real call), so they prove emit → exec → call → buffer-write works off @@ -41,7 +41,7 @@ TEST_CASE("MoonLive emitFill/emitAnimatedFill reject a null output buffer (no cr // would fail on the REQUIRE. Guarded on the emit-header capability macro so they compile // out where the backend is unimplemented — the same "runs dark" degradation on-device. #if MM_MOONLIVE_HAS_HOST_JIT -TEST_CASE("MoonLive compiles and fills a buffer with the chosen colour") { +TEST_CASE("MoonLive compiles and fills a buffer with the chosen color") { moonlive::MoonLive engine; REQUIRE(engine.compile(/*r*/ 10, /*g*/ 20, /*b*/ 200)); REQUIRE(engine.ok()); @@ -79,14 +79,14 @@ TEST_CASE("MoonLive run is a no-op on sub-RGB buffers (cpl 1 and 2)") { engine.run(nullptr, 8, 3, 0); } -TEST_CASE("MoonLive recompile swaps the colour; free returns to !ok") { +TEST_CASE("MoonLive recompile swaps the color; free returns to !ok") { moonlive::MoonLive engine; REQUIRE(engine.compile(1, 1, 1)); std::vector<uint8_t> buf(3, 0); engine.run(buf.data(), 1, 3, 0); CHECK(buf[0] == 1); - REQUIRE(engine.compile(9, 8, 7)); // recompile a new colour + REQUIRE(engine.compile(9, 8, 7)); // recompile a new color engine.run(buf.data(), 1, 3, 0); CHECK(buf[0] == 9); CHECK(buf[1] == 8); CHECK(buf[2] == 7); @@ -96,7 +96,7 @@ TEST_CASE("MoonLive recompile swaps the colour; free returns to !ok") { engine.run(buf.data(), 1, 3, 0); } -TEST_CASE("MoonLive animated fill derives colour from the per-frame t") { +TEST_CASE("MoonLive animated fill derives color from the per-frame t") { moonlive::MoonLive engine; REQUIRE(engine.compileAnimated()); REQUIRE(engine.ok()); diff --git a/test/unit/light/unit_BlendMap.cpp b/test/unit/light/unit_BlendMap.cpp index 9fdd39da..31a6f467 100644 --- a/test/unit/light/unit_BlendMap.cpp +++ b/test/unit/light/unit_BlendMap.cpp @@ -28,7 +28,7 @@ TEST_CASE("blendMap identity (no LUT) copies buffer") { } } -// One logical light routed to multiple physical positions copies the colour to each (mirror-style mappings work). +// One logical light routed to multiple physical positions copies the color to each (mirror-style mappings work). TEST_CASE("blendMap 1:N mapping duplicates pixels") { // 2 logical lights, 4 physical lights // Logical 0 → physical {0, 3} diff --git a/test/unit/light/unit_BlurzEffect.cpp b/test/unit/light/unit_BlurzEffect.cpp index 91b5f016..fbdf0bdf 100644 --- a/test/unit/light/unit_BlurzEffect.cpp +++ b/test/unit/light/unit_BlurzEffect.cpp @@ -7,7 +7,7 @@ #include "light/layouts/GridLayout.h" #include "core/AudioService.h" -// Blurz is an audio-reactive effect: its dot is coloured by the current band's magnitude and only +// Blurz is an audio-reactive effect: its dot is colored by the current band's magnitude and only // appears when there is a signal. The frame comes from AudioService::latestFrame() (a process-wide // static). To feed a signal on the host (no I2S mic) we run a live AudioService with `simulate` set to // an "always" mode — synthesizeFrame() then fills the bands each tick(). Every case brackets its own @@ -43,7 +43,7 @@ TEST_CASE("BlurzEffect stays black without an audio frame") { CHECK_FALSE(anyLit); } -// With a synthesized audio frame the effect lights the buffer: the coloured dot appears and the blur +// With a synthesized audio frame the effect lights the buffer: the colored dot appears and the blur // smears it into a soft blob, so at least some lights become non-zero. TEST_CASE("BlurzEffect lights the buffer when fed a signal") { mm::AudioService audio; @@ -88,7 +88,7 @@ TEST_CASE("BlurzEffect lights the buffer when fed a signal") { TEST_CASE("BlurzEffect geqScanner sweeps the dot to a new position each frame") { mm::AudioService audio; audio.defineControls(); - audio.simulate = 3; // music (always): keeps the bands non-zero so the dot has colour + audio.simulate = 3; // music (always): keeps the bands non-zero so the dot has color audio.setup(); mm::Layouts layouts; diff --git a/test/unit/light/unit_Drivers_firstOutputRgb.cpp b/test/unit/light/unit_Drivers_firstOutputRgb.cpp index b9f26e6b..a4ad7c7b 100644 --- a/test/unit/light/unit_Drivers_firstOutputRgb.cpp +++ b/test/unit/light/unit_Drivers_firstOutputRgb.cpp @@ -1,7 +1,7 @@ // @module Drivers // Pins Drivers::firstOutputRgb — the domain-neutral seam the WLED-compatibility shim uses to -// tint the app's device card with the live first-LED colour. It reads pixel 0 of whichever +// tint the app's device card with the live first-LED color. It reads pixel 0 of whichever // buffer Drivers is driving (the single-layer fast path here: the layer's own buffer). #include "doctest.h" diff --git a/test/unit/light/unit_Drivers_rendersplit.cpp b/test/unit/light/unit_Drivers_rendersplit.cpp index 871876d9..4af9be6d 100644 --- a/test/unit/light/unit_Drivers_rendersplit.cpp +++ b/test/unit/light/unit_Drivers_rendersplit.cpp @@ -10,6 +10,7 @@ #include "doctest.h" #include "light/drivers/Drivers.h" +#include "light/drivers/ParallelLedDriver.h" #include "light/layers/Layers.h" #include "light/layers/Layer.h" #include "light/layouts/Layouts.h" @@ -58,7 +59,7 @@ class SlowDriver : public mm::DriverBase { release.wait_for(lk, 5s, [this] { return released; }); } touched = 0xABCD; // ASan traps here if core 0 freed us mid-tick - std::lock_guard<std::mutex> lk(m); + std::scoped_lock<std::mutex> lk(m); inTick = false; exited.notify_all(); } @@ -69,10 +70,10 @@ class SlowDriver : public mm::DriverBase { return entered.wait_for(lk, 5s, [this] { return inTick; }); } void letGo() { - { std::lock_guard<std::mutex> lk(m); released = true; } + { std::scoped_lock<std::mutex> lk(m); released = true; } release.notify_all(); } - bool isInTick() { std::lock_guard<std::mutex> lk(m); return inTick; } + bool isInTick() { std::scoped_lock<std::mutex> lk(m); return inTick; } std::mutex m; std::condition_variable entered, release, exited; @@ -194,6 +195,45 @@ TEST_CASE("render-split: multicore off → drivers tick inline on the render cor r.drivers.release(); } +// REGRESSION (v3.0.0 "UI refresh freezes the LEDs"): GET /api/types (which the web UI fetches on every +// page load) builds a throwaway probe of each registered type to read its default control values. A +// ParallelLedDriver probe runs selectDefaultPeripheral → swapPeripheral in its constructor/defineControls, +// and swapPeripheral fired MoonModule::notifyQuiesceRender() unconditionally. That global hook resolves to +// the LIVE Drivers via the static ActiveInstance seat (Drivers::active()) — so a DETACHED probe (never in +// the tree) tore down the running split, and nothing re-engaged it. On a fast board it silently dropped to +// single-core; on a slower one the LEDs visibly froze until multicore was toggled. The fix: swapPeripheral +// notifies only when it is about to free a real backend (`peripheral_` non-null). A probe's first swap +// selects the default peripheral from a null backend, so it never notifies — the live worker is untouched. +// This pins that a detached ParallelLedDriver swapping its peripheral leaves a live split untouched. +TEST_CASE("render-split: a detached ParallelLedDriver's peripheral swap does not disturb the live split") { + // RAII: a doctest REQUIRE failure throws, so a bare set-then-reset would leak the global hook + // into later tests; the guard resets it on every exit path. + struct HookGuard { + HookGuard() { mm::MoonModule::setQuiesceRenderHook([] { if (auto* d = mm::Drivers::active()) d->quiesceRenderSplit(); }); } + ~HookGuard() { mm::MoonModule::setQuiesceRenderHook(nullptr); } + } hookGuard; + + Rig r(64); + MockDriver d; + r.drivers.addChild(&d); + r.drivers.setup(); + r.drivers.prepare(); + REQUIRE(r.drivers.renderSplitActive()); // the LIVE split is engaged + + // A DETACHED ParallelLedDriver (never addChild'd — no parent), exactly like the /api/types probe. + // Its construction + defineControls run selectDefaultPeripheral → swapPeripheral, which must NOT reach + // the live render worker through the global hook. + { + mm::ParallelLedDriver probe; + probe.defineDriverControls(); // triggers the default-peripheral selection + swap + } // probe destructs — the moment /api/types tears its throwaway down + + // THE ASSERTION: the detached probe's swap must not have touched the live split. + CHECK(r.drivers.renderSplitActive()); // fails on the v3.0.0 bug (probe fired the hook → live teardown) + + r.drivers.release(); +} + TEST_CASE("render-split: no driver → the split does not engage (nothing to move)") { Rig r(64); r.drivers.setup(); diff --git a/test/unit/light/unit_Effects_gridsweep.cpp b/test/unit/light/unit_Effects_gridsweep.cpp new file mode 100644 index 00000000..a0197751 --- /dev/null +++ b/test/unit/light/unit_Effects_gridsweep.cpp @@ -0,0 +1,106 @@ +// @module EffectBase + +// The grid-size floor, swept across EVERY registered effect rather than one at a time. +// +// The hard rule (CLAUDE.md § Principles, Robustness; architecture.md § Robustness rules): +// an effect must produce a correct result at ANY grid size, including a degenerate one — +// no crash, no divide-by-zero, no out-of-bounds write. A modifier can shrink the logical +// grid to 0x0x0 (every layout child disabled), and a 1-wide or 1-deep grid is what a +// single strand or a flat panel actually is. +// +// Per-effect tests pin this one effect at a time, which means a NEW effect is covered only +// if its author remembers to write that case. This sweep asks the factory for every +// registered effect instead, so the floor is enforced for effects that do not exist yet: +// register an effect, and it is swept the moment it lands. +// +// Reading a failure: the CHECK message names the effect and the grid it died on. "Died" +// here means it crashed the runner — an effect that draws nothing on a zero grid is +// correct (a clean no-op is the specified behaviour), so the assertion is about surviving +// and leaving a well-formed buffer, not about pixels. + +#include "doctest.h" +#include "light/layouts/Layouts.h" +#include "light/layouts/GridLayout.h" +#include "light/layers/Layer.h" +// Generated at build time from src/light/effects/*.h — see test/CMakeLists.txt. Supplies +// forEachEffect(), so this file names no individual effect and cannot drift. +#include "effect_sweep.h" +#include <string> + +namespace { + +// The degenerate and near-degenerate grids every effect must tolerate. Each is a real +// configuration a user can produce, not a synthetic edge case: +// 0x0x0 every layout child disabled (a modifier folded the grid away) +// 1x1x1 a single light +// 1xNx1 one strand +// Nx1x1 one row +struct GridCase { + mm::lengthType w, h, d; + const char* label; +}; + +const GridCase kGrids[] = { + {0, 0, 0, "0x0x0 (empty grid)"}, + {1, 1, 1, "1x1x1 (single light)"}, + {1, 16, 1, "1x16x1 (one strand)"}, + {16, 1, 1, "16x1x1 (one row)"}, +}; + +// Drive one effect through one grid: build the layer, tick it twice, then tear down. +// Two ticks matter because several effects allocate or seed on the first tick and read +// that state on the next one — a zero grid must not leave a trap for frame two. +void runEffectOnGrid(const std::string& name, mm::MoonModule* fx, const GridCase& g) { + mm::Layouts layouts; + mm::GridLayout grid; + mm::Layer layer; + + grid.width = g.w; grid.height = g.h; grid.depth = g.d; + layouts.addChild(&grid); + layer.setLayouts(&layouts); + layer.setChannelsPerLight(3); + + layer.addChild(fx); + layer.applyState(); + layer.tick(); + layer.tick(); + + // The buffer contract holds even at zero size: a zero-light layer reports zero bytes + // rather than a stale non-zero span a driver would then read past. + if (g.w == 0 || g.h == 0 || g.d == 0) { + CHECK_MESSAGE(layer.buffer().bytes() == 0, + name << " left a non-empty buffer on " << g.label); + } + + // release() returns every buffer in the tree (it recurses to children); the caller + // then destroys the effect. The Layer is a local about to go out of scope, so there + // is no detach to do — and a removeChild() here would run a structural mutation over + // a just-released tree. + layer.release(); +} + +} // namespace + +// The sweep. One TEST_CASE over every effect keeps the failure output readable: a broken +// effect names itself and the grid it died on, and the rest still run. +TEST_CASE("every effect survives degenerate grid sizes") { + int swept = 0; + + mm::forEachEffect([&](const char* name, auto make) { + for (const auto& g : kGrids) { + const std::string effectName(name); + CAPTURE(effectName); + CAPTURE(g.label); + mm::MoonModule* fx = make(); + REQUIRE_MESSAGE(fx != nullptr, "could not construct " << effectName); + runEffectOnGrid(effectName, fx, g); + delete fx; + } + swept++; + }); + + // An empty effect list would make this test vacuously green — the most dangerous kind + // of passing test. The generator refuses to emit an empty list; this is the second lock. + CHECK_MESSAGE(swept > 0, "no effects swept — the test would pass without testing anything"); + MESSAGE("swept " << swept << " effects x " << (sizeof(kGrids) / sizeof(kGrids[0])) << " grids"); +} diff --git a/test/unit/light/unit_FixedRectangleEffect.cpp b/test/unit/light/unit_FixedRectangleEffect.cpp index d0274d09..dbcc5b15 100644 --- a/test/unit/light/unit_FixedRectangleEffect.cpp +++ b/test/unit/light/unit_FixedRectangleEffect.cpp @@ -72,7 +72,7 @@ TEST_CASE("FixedRectangleEffect defaults light the origin corner and fill a smal auto* data = layer.buffer().data(); - // Origin corner (0,0) is lit with the default colour {182,15,98}. + // Origin corner (0,0) is lit with the default color {182,15,98}. CHECK(data[0] == 182); CHECK(data[1] == 15); CHECK(data[2] == 98); diff --git a/test/unit/light/unit_FreqMatrixEffect.cpp b/test/unit/light/unit_FreqMatrixEffect.cpp index 923e5de4..32b1a62f 100644 --- a/test/unit/light/unit_FreqMatrixEffect.cpp +++ b/test/unit/light/unit_FreqMatrixEffect.cpp @@ -63,12 +63,12 @@ void driveLoudTone(mm::AudioService& mic) { for (int i = 0; i < 20; i++) mic.tick(); // EMA converges toward level 254 const mm::AudioFrame* f = mm::AudioService::latestFrame(); REQUIRE(f->peakHz > 80); // a real tone, above the effect's gate - REQUIRE(f->levelSmoothed > 64); // above the effect's brightness/colour gate + REQUIRE(f->levelSmoothed > 64); // above the effect's brightness/color gate } } // namespace -// A real tone above the 80 Hz gate paints a lit colour at the source pixel (0,0). +// A real tone above the 80 Hz gate paints a lit color at the source pixel (0,0). TEST_CASE("FreqMatrixEffect paints a lit source pixel from a live tone") { mm::AudioService mic; AudioGuard guard{mic}; @@ -84,7 +84,7 @@ TEST_CASE("FreqMatrixEffect paints a lit source pixel from a live tone") { g.layer.tick(); // paints the new pixel at y=0 (elapsed = kToneMs, throttle passes) auto* data = g.layer.buffer().data(); - // Pixel (0,0) is index 0: the source end carries the freshly-painted lit colour. + // Pixel (0,0) is index 0: the source end carries the freshly-painted lit color. CHECK((data[0] > 0 || data[1] > 0 || data[2] > 0)); } @@ -101,7 +101,7 @@ TEST_CASE("FreqMatrixEffect scrolls the painted pixel one step along Y") { g.layer.addChild(&fx); g.layer.applyState(); - g.layer.tick(); // tick 1 at kToneMs: paints the lit colour at y=0 + g.layer.tick(); // tick 1 at kToneMs: paints the lit color at y=0 auto* data = g.layer.buffer().data(); const uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; REQUIRE((r0 > 0 || g0 > 0 || b0 > 0)); // something lit landed at the source @@ -110,10 +110,10 @@ TEST_CASE("FreqMatrixEffect scrolls the painted pixel one step along Y") { // lit band (pos = (t/250)%16 = 1 at t=380, env still high) — the column shifts. mm::platform::setTestNowMs(kToneMs + 5); mic.tick(); // refresh the frame at the new time (still a loud tone on band 1) - g.layer.tick(); // tick 2: the y=0 colour of tick 1 moves up to y=1 + g.layer.tick(); // tick 2: the y=0 color of tick 1 moves up to y=1 data = g.layer.buffer().data(); // buffer may have been rebuilt; re-read - // Pixel (0,1) is index 1*3 = 3: it now carries the colour painted at y=0 on tick 1. + // Pixel (0,1) is index 1*3 = 3: it now carries the color painted at y=0 on tick 1. CHECK(data[3] == r0); CHECK(data[4] == g0); CHECK(data[5] == b0); diff --git a/test/unit/light/unit_FreqSawsEffect.cpp b/test/unit/light/unit_FreqSawsEffect.cpp index 3508832a..94396178 100644 --- a/test/unit/light/unit_FreqSawsEffect.cpp +++ b/test/unit/light/unit_FreqSawsEffect.cpp @@ -49,7 +49,7 @@ TEST_CASE("FreqSawsEffect keepOn draws bands even with no audio") { mm::Layer layer; layer.setLayouts(&layouts); layer.setChannelsPerLight(3); - mm::Palettes::setActive(0); // colourful palette so a drawn pixel is non-black (order-independent) + mm::Palettes::setActive(0); // colorful palette so a drawn pixel is non-black (order-independent) mm::FreqSawsEffect saws; saws.keepOn = true; // draw a band whose speed has decayed to zero diff --git a/test/unit/light/unit_GEQEffect.cpp b/test/unit/light/unit_GEQEffect.cpp index 0b6d5140..91351b48 100644 --- a/test/unit/light/unit_GEQEffect.cpp +++ b/test/unit/light/unit_GEQEffect.cpp @@ -104,9 +104,9 @@ TEST_CASE("GEQEffect fills columns from the floor upward") { audio.release(); } -// colorBars colours each bar by its column index, so two well-separated lit columns take different hues -// rather than sharing the row-height gradient — the toggle changes what colour a bar is. -TEST_CASE("GEQEffect colorBars colours bars per column") { +// colorBars colors each bar by its column index, so two well-separated lit columns take different hues +// rather than sharing the row-height gradient — the toggle changes what color a bar is. +TEST_CASE("GEQEffect colorBars colors bars per column") { mm::AudioService audio; audio.defineControls(); audio.simulate = 3; // music (always): keeps every band non-zero so many columns rise together @@ -133,7 +133,7 @@ TEST_CASE("GEQEffect colorBars colours bars per column") { layer.applyState(); mm::Palettes::setActive(0); // Rainbow: index maps to a spread of hues, order-independent - // Advance until both an early and a late column have a lit floor, then compare their colours. + // Advance until both an early and a late column have a lit floor, then compare their colors. const int xa = 0, xb = W - 1; auto color = [&](int x) { auto* d = layer.buffer().data(); @@ -149,7 +149,7 @@ TEST_CASE("GEQEffect colorBars colours bars per column") { auto ca = color(xa), cb = color(xb); if (lit(ca) && lit(cb)) { // Column 0 (hue 0) and column 15 (hue 255) are opposite ends of the palette: their bar - // colours differ, confirming the colour is driven by column, not by shared row height. + // colors differ, confirming the color is driven by column, not by shared row height. CHECK((ca[0] != cb[0] || ca[1] != cb[1] || ca[2] != cb[2])); compared = true; } diff --git a/test/unit/light/unit_HueDriver.cpp b/test/unit/light/unit_HueDriver.cpp index 756b926d..65f5fbe5 100644 --- a/test/unit/light/unit_HueDriver.cpp +++ b/test/unit/light/unit_HueDriver.cpp @@ -1,7 +1,7 @@ // @module HueDriver -// Pins HueDriver's host-testable core: the changed-only diff, the RGB→HSV colour body it PUTs, -// and the parse that keeps only colour-capable, reachable lights. Live bridge I/O (httpRequest, +// Pins HueDriver's host-testable core: the changed-only diff, the RGB→HSV color body it PUTs, +// and the parse that keeps only color-capable, reachable lights. Live bridge I/O (httpRequest, // pairing) needs a real bridge — that's the bench; here the seams run with no socket. #include "doctest.h" @@ -11,7 +11,7 @@ #include <cstring> #include <string> -TEST_CASE("HueDriver: a coloured pixel becomes an on/bri/hue/sat state body") { +TEST_CASE("HueDriver: a colored pixel becomes an on/bri/hue/sat state body") { mm::HueDriver hue; char body[80] = {}; CHECK(hue.wouldPushForTest(0, 255, 0, 0, body, sizeof(body))); // pure red @@ -26,7 +26,7 @@ TEST_CASE("HueDriver: a black pixel becomes on:false") { char body[80] = {}; CHECK(hue.wouldPushForTest(1, 0, 0, 0, body, sizeof(body))); CHECK(std::strstr(body, "\"on\":false") != nullptr); - CHECK(std::strstr(body, "\"hue\"") == nullptr); // off → no colour fields + CHECK(std::strstr(body, "\"hue\"") == nullptr); // off → no color fields } TEST_CASE("HueDriver: RGB→HSV maps the primaries to the right Hue wheel positions") { @@ -42,7 +42,7 @@ TEST_CASE("HueDriver: RGB→HSV maps the primaries to the right Hue wheel positi CHECK(s == 0); } -TEST_CASE("HueDriver: unchanged colour is not resent, a changed one is") { +TEST_CASE("HueDriver: unchanged color is not resent, a changed one is") { mm::HueDriver hue; char body[80] = {}; CHECK(hue.wouldPushForTest(2, 10, 20, 30, body, sizeof(body))); // first → yes @@ -50,10 +50,10 @@ TEST_CASE("HueDriver: unchanged colour is not resent, a changed one is") { CHECK(hue.wouldPushForTest(2, 10, 20, 31, body, sizeof(body))); // changed → yes } -TEST_CASE("HueDriver: parseLights keeps only colour-capable, reachable lights") { +TEST_CASE("HueDriver: parseLights keeps only color-capable, reachable lights") { mm::HueDriver hue; - // id 5 colour + reachable (keep); id 7 dimmable-only white (drop); id 10 on/off plug (drop); - // id 8 colour but UNREACHABLE (drop). The shapes the real bridge returns. + // id 5 color + reachable (keep); id 7 dimmable-only white (drop); id 10 on/off plug (drop); + // id 8 color but UNREACHABLE (drop). The shapes the real bridge returns. const char* json = "{\"5\":{\"state\":{\"on\":false,\"bri\":77,\"hue\":8595,\"sat\":121,\"reachable\":true},\"name\":\"Bureau lamp\"}," "\"7\":{\"state\":{\"on\":true,\"bri\":40,\"reachable\":true},\"name\":\"Gang lamp\"}," @@ -65,12 +65,12 @@ TEST_CASE("HueDriver: parseLights keeps only colour-capable, reachable lights") CHECK(hue.colorCountForTest() == 1); } -// Room + light selection filters which colour lights the driver actually drives. Both dropdowns -// default to "All" (index 0): then every colour light is driven (unchanged behaviour). Selecting a -// room narrows the driven set to that room's colour lights; selecting a light drives just that one. +// Room + light selection filters which color lights the driver actually drives. Both dropdowns +// default to "All" (index 0): then every color light is driven (unchanged behaviour). Selecting a +// room narrows the driven set to that room's color lights; selecting a light drives just that one. TEST_CASE("HueDriver: room/light selection filters the driven set") { mm::HueDriver hue; - // Four colour+reachable lights, ids 1..4. + // Four color+reachable lights, ids 1..4. const char* lights = "{\"1\":{\"state\":{\"hue\":1,\"reachable\":true},\"name\":\"Lamp A\"}," "\"2\":{\"state\":{\"hue\":2,\"reachable\":true},\"name\":\"Lamp B\"}," @@ -107,7 +107,7 @@ TEST_CASE("HueDriver: room/light selection filters the driven set") { CHECK(hue.drivenCountForTest() == 4); } -// The single status line (folding what were the separate hueStatus / colourLights controls) shows +// The single status line (folding what were the separate hueStatus / colorLights controls) shows // the light count as driven-of-total: "N-M lights" while filtered, the plain "M lights" when not. TEST_CASE("HueDriver: status reports the driven-of-total light count") { mm::HueDriver hue; diff --git a/test/unit/light/unit_Layer_phase_animation.cpp b/test/unit/light/unit_Layer_phase_animation.cpp index c36564ed..6b707a5b 100644 --- a/test/unit/light/unit_Layer_phase_animation.cpp +++ b/test/unit/light/unit_Layer_phase_animation.cpp @@ -9,7 +9,6 @@ #include "light/effects/MetaballsEffect.h" #include "light/effects/SpiralEffect.h" #include "light/effects/LavaLampEffect.h" -#include "light/effects/SpiralEffect.h" #include "light/effects/NoiseEffect.h" #include "platform/platform.h" @@ -81,11 +80,6 @@ TEST_CASE("LavaLampEffect animates over a 100ms gap") { CHECK(animates_over_ms<mm::LavaLampEffect>(100)); } -// Spiral animates across 100ms (rotation visible). -TEST_CASE("SpiralEffect animates over a 100ms gap") { - CHECK(animates_over_ms<mm::SpiralEffect>(100)); -} - // Replace path: swap one effect for another mid-flight (same shape as // HttpServerModule::handleReplaceModule) and confirm the new effect animates. // Replacing one effect with another mid-tick (HttpServerModule's swap path) leaves the new effect animating, not frozen. diff --git a/test/unit/light/unit_LightPresetsModule.cpp b/test/unit/light/unit_LightPresetsModule.cpp index 00809910..51b33e4f 100644 --- a/test/unit/light/unit_LightPresetsModule.cpp +++ b/test/unit/light/unit_LightPresetsModule.cpp @@ -15,7 +15,6 @@ using mm::LightPresetsModule; using mm::Correction; -using mm::ChannelRole; namespace { // The count of seeded read-only built-ins (see LightPresetsModule::seedBuiltins). Referenced by @@ -141,7 +140,7 @@ TEST_CASE("LightPresets: growing channels preserves existing role picks") { Correction c; REQUIRE(m.deriveCorrection(id, 255, c)); CHECK(c.outChannels == 6); - // Pan(5)/Tilt(6) are non-colour roles → not colour offsets, but ch2 was Blue and MUST survive. + // Pan(5)/Tilt(6) are non-color roles → not color offsets, but ch2 was Blue and MUST survive. CHECK(c.offBlue == 2); // ch2 = B preserved across the grow (the bug lost this) // A second grow then a shrink keeps the head stable. CHECK(m.setListRowField(id, "channels", "{\"value\":4}")); // shrink 6 → 4 @@ -424,7 +423,7 @@ uint32_t builtinId(LightPresetsModule& m, const char* name) { } } // namespace -// The migrated colour-order built-ins resolve to the right channel offsets. WRGB (ws2814) puts +// The migrated color-order built-ins resolve to the right channel offsets. WRGB (ws2814) puts // white at channel 0, so R/G/B shift up one — a distinctive layout that catches a bad migration. TEST_CASE("Built-in WRGB resolves to W,R,G,B offsets") { LightPresetsModule m; @@ -456,7 +455,7 @@ TEST_CASE("Built-in RGBCCT has white (WarmWhite counts) and resolves 5 channels" // A moving-head built-in migrates as a wide fixture: the RGB block sits at its real offset within // the DMX map (BeeEyes: R@10,G@11,B@12 of 15), the fixture is the right width, and it resolves // without crashing at that odd width (Robust-to-any-input). The Pan/Tilt/Zoom/Gobo channels carry -// their roles in the preset but aren't colour offsets, so they're inert until effect writers land. +// their roles in the preset but aren't color offsets, so they're inert until effect writers land. TEST_CASE("Built-in moving head (BeeEyes-15) resolves at full width with the RGB block placed") { LightPresetsModule m; m.setup(); @@ -471,9 +470,9 @@ TEST_CASE("Built-in moving head (BeeEyes-15) resolves at full width with the RGB } // APPEND-ONLY regression: inserting WarmWhite/Yellow/UV after White must NOT renumber the existing -// colour roles, or every persisted RGBW preset's bytes would resolve to the wrong colours. A +// color roles, or every persisted RGBW preset's bytes would resolve to the wrong colors. A // straight RGBW built-in still deriving R@0,G@1,B@2,W@3 proves the low indices are unchanged. -TEST_CASE("Colour roles keep their indices after the vocabulary grew (append-only)") { +TEST_CASE("Color roles keep their indices after the vocabulary grew (append-only)") { LightPresetsModule m; m.setup(); Correction c; diff --git a/test/unit/light/unit_LissajousEffect.cpp b/test/unit/light/unit_LissajousEffect.cpp index 4edf43f4..2b5d30c1 100644 --- a/test/unit/light/unit_LissajousEffect.cpp +++ b/test/unit/light/unit_LissajousEffect.cpp @@ -22,7 +22,7 @@ TEST_CASE("LissajousEffect traces a lit curve on the grid") { layer.addChild(&lissajous); layer.applyState(); - // Pin a colourful palette (Rainbow=0) so painted pixels are non-black regardless of prior tests + // Pin a colorful palette (Rainbow=0) so painted pixels are non-black regardless of prior tests // mutating the process-wide active palette. mm::Palettes::setActive(0); layer.tick(); diff --git a/test/unit/light/unit_MetaballsEffect.cpp b/test/unit/light/unit_MetaballsEffect.cpp index 3d435f50..219775d6 100644 --- a/test/unit/light/unit_MetaballsEffect.cpp +++ b/test/unit/light/unit_MetaballsEffect.cpp @@ -34,7 +34,7 @@ TEST_CASE("MetaballsEffect writes non-zero RGB data to buffer") { CHECK(hasNonZero); } -// Pixels at opposite corners of a 32×32 grid differ in colour (the effect is not flat-filling the buffer). +// Pixels at opposite corners of a 32×32 grid differ in color (the effect is not flat-filling the buffer). TEST_CASE("MetaballsEffect produces spatial variation") { mm::Layouts layouts; mm::GridLayout grid; diff --git a/test/unit/light/unit_Noise2DEffect.cpp b/test/unit/light/unit_Noise2DEffect.cpp index 5aa6ee75..6fda905c 100644 --- a/test/unit/light/unit_Noise2DEffect.cpp +++ b/test/unit/light/unit_Noise2DEffect.cpp @@ -22,7 +22,7 @@ TEST_CASE("Noise2DEffect writes a non-zero palette-mapped noise field") { layer.addChild(&noise); layer.applyState(); - // Palettes::active() is a process-wide static any prior test can mutate; pin a colourful palette + // Palettes::active() is a process-wide static any prior test can mutate; pin a colorful palette // (Rainbow=0) so the non-black assertion is order-independent. mm::Palettes::setActive(0); layer.tick(); @@ -38,8 +38,8 @@ TEST_CASE("Noise2DEffect writes a non-zero palette-mapped noise field") { CHECK(hasNonZero); } -// The field is spatial: distant pixels read different noise samples, so their colours differ. -TEST_CASE("Noise2DEffect distant pixels carry different colours") { +// The field is spatial: distant pixels read different noise samples, so their colors differ. +TEST_CASE("Noise2DEffect distant pixels carry different colors") { mm::Layouts layouts; mm::GridLayout grid; grid.width = 16; diff --git a/test/unit/light/unit_NoiseEffect.cpp b/test/unit/light/unit_NoiseEffect.cpp index 166682d9..0ec7e987 100644 --- a/test/unit/light/unit_NoiseEffect.cpp +++ b/test/unit/light/unit_NoiseEffect.cpp @@ -44,7 +44,7 @@ TEST_CASE("NoiseEffect writes non-zero RGB data to buffer") { CHECK(hasNonZero); } -// Opposite corners of a 16×16 grid carry different colours (noise is not flat). +// Opposite corners of a 16×16 grid carry different colors (noise is not flat). TEST_CASE("NoiseEffect produces spatial variation") { mm::Layouts layouts; mm::GridLayout grid; diff --git a/test/unit/light/unit_NoiseMeterEffect.cpp b/test/unit/light/unit_NoiseMeterEffect.cpp index 2bd905db..02a31f10 100644 --- a/test/unit/light/unit_NoiseMeterEffect.cpp +++ b/test/unit/light/unit_NoiseMeterEffect.cpp @@ -8,7 +8,7 @@ #include "core/AudioService.h" // NoiseMeter is an audio-reactive 1D effect: a vertical VU column whose height tracks the overall sound -// level and whose colour is a scrolling 2D noise field. It writes only the x=0 column and Layer::extrude +// level and whose color is a scrolling 2D noise field. It writes only the x=0 column and Layer::extrude // fans each lit row across every x (and z), so a lit row is a complete horizontal band. The column fills // bottom-up: row y=0 lights first (the floor is buffer row height-1, since drawY = sizeY-1-y). The frame // comes from AudioService::latestFrame() (a process-wide static); on the host with no I2S mic we run a @@ -74,7 +74,7 @@ TEST_CASE("NoiseMeterEffect fills the column from the floor upward") { mm::Layer layer; layer.setLayouts(&layouts); layer.setChannelsPerLight(3); - mm::Palettes::setActive(0); // colourful palette so a drawn pixel is non-black (order-independent) + mm::Palettes::setActive(0); // colorful palette so a drawn pixel is non-black (order-independent) mm::NoiseMeterEffect meter; meter.fadeRate = 254; // fastest fade so a lit row this frame is this frame's fill, not a stale trail diff --git a/test/unit/light/unit_Palette.cpp b/test/unit/light/unit_Palette.cpp index d414a133..5a8e8648 100644 --- a/test/unit/light/unit_Palette.cpp +++ b/test/unit/light/unit_Palette.cpp @@ -7,7 +7,7 @@ #include "doctest.h" #include "light/Palette.h" -TEST_CASE("Palette: gradient endpoints land on the first/last stop colours") { +TEST_CASE("Palette: gradient endpoints land on the first/last stop colors") { // A simple red→green→blue gradient. const uint8_t stops[] = {0,255,0,0, 128,0,255,0, 255,0,0,255}; mm::Palette p; @@ -32,7 +32,7 @@ TEST_CASE("Palette: a mid-gradient sample interpolates between stops") { } TEST_CASE("Palette: colorFromPalette index 0 reads entry 0; brightness scales") { - const uint8_t stops[] = {0,200,100,50, 255,200,100,50}; // flat colour + const uint8_t stops[] = {0,200,100,50, 255,200,100,50}; // flat color mm::Palette p; p.fromGradient(stops, sizeof(stops)); mm::RGB full = mm::colorFromPalette(p, 0, 255); @@ -49,7 +49,7 @@ TEST_CASE("Palette: the index wraps at 255→0 (no out-of-range read)") { p.fromGradient(stops, sizeof(stops)); // index 255 blends entry[15] toward entry[0] (the wrap) — must not read past the array. mm::RGB c = mm::colorFromPalette(p, 255); - CHECK((c.r <= 255)); // a valid colour, no crash/garbage + CHECK((c.r <= 255)); // a valid color, no crash/garbage // Sweeping every index never faults. for (int i = 0; i <= 255; i++) (void)mm::colorFromPalette(p, static_cast<uint8_t>(i)); } @@ -77,10 +77,10 @@ TEST_CASE("Palettes::active swaps the global palette on setActive") { mm::Palettes::setActive(0); } -// The HomeKit-colour-wheel → palette mapping (MQTT/Homebridge). Each palette's representative +// The HomeKit-color-wheel → palette mapping (MQTT/Homebridge). Each palette's representative // (hue, sat) is computed from its expanded entries; nearestForHue picks the closest. A vivid hue // snaps to that hue's palette family; a low-saturation target snaps to the desaturated Rainbow. -TEST_CASE("Palettes::nearestForHue maps a colour to the closest palette") { +TEST_CASE("Palettes::nearestForHue maps a color to the closest palette") { // A vivid red hue lands on a red/orange-family palette (Party≈13° / Lava≈24° are the reds), // never on the all-hue Rainbow (index 0, which has ~0 saturation). const uint8_t redIdx = mm::Palettes::nearestForHue(5, 255); diff --git a/test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp b/test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp index c77662fe..aded09db 100644 --- a/test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp +++ b/test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp @@ -309,7 +309,7 @@ TEST_CASE("ParallelLedDriver drives an N-channel (>4) correction without overflo CHECK(d.frameBytes() > 0); // The status reports the total channel count for a multi-channel fixture (lights × channels) — // the DMX-universe footprint the user sizes against, not just the light count. - CHECK(std::string(d.status()).find("(") != std::string::npos); // "... (N channels)" + CHECK(std::string(d.status()).find('(') != std::string::npos); // "... (N channels)" CHECK(std::string(d.status()).find("channels") != std::string::npos); peripheral.calls.clear(); d.tick(); diff --git a/test/unit/light/unit_ParallelSlots.cpp b/test/unit/light/unit_ParallelSlots.cpp index f03dd778..c095031c 100644 --- a/test/unit/light/unit_ParallelSlots.cpp +++ b/test/unit/light/unit_ParallelSlots.cpp @@ -633,6 +633,8 @@ TEST_CASE("shift encoder: the packed transpose matches the reference at every pi mm::encodeWs2812ShiftData<Slot>(wire, activeMask, physPins, latchBit, kSh, kCh, got.data()); INFO("bus=", sizeof(Slot), " physPins=", physPins, " mask=", activeMask); + // Binary struct compare, not a string: `== 0` is the correct test for identical bytes. + // NOLINTNEXTLINE(bugprone-suspicious-string-compare) CHECK(std::memcmp(ref.data(), got.data(), slots * sizeof(Slot)) == 0); }; diff --git a/test/unit/light/unit_PlasmaEffect.cpp b/test/unit/light/unit_PlasmaEffect.cpp index 44190329..b74374e2 100644 --- a/test/unit/light/unit_PlasmaEffect.cpp +++ b/test/unit/light/unit_PlasmaEffect.cpp @@ -36,7 +36,7 @@ TEST_CASE("PlasmaEffect writes non-zero RGB data to buffer") { CHECK(hasNonZero); } -// Opposite corners of a 16×16 grid differ in colour (the plasma is not flat-filling). +// Opposite corners of a 16×16 grid differ in color (the plasma is not flat-filling). TEST_CASE("PlasmaEffect produces spatial variation") { mm::Layouts layouts; mm::GridLayout grid; diff --git a/test/unit/light/unit_PraxisEffect.cpp b/test/unit/light/unit_PraxisEffect.cpp index 12b68a35..7079c420 100644 --- a/test/unit/light/unit_PraxisEffect.cpp +++ b/test/unit/light/unit_PraxisEffect.cpp @@ -24,7 +24,7 @@ TEST_CASE("PraxisEffect fills every pixel from the palette") { layer.applyState(); // Rainbow palette (0) is generated at full saturation/value, so every wheel index - // maps to a lit colour — makes "every pixel lit" order-independent of prior tests. + // maps to a lit color — makes "every pixel lit" order-independent of prior tests. mm::Palettes::setActive(0); layer.tick(); @@ -42,9 +42,9 @@ TEST_CASE("PraxisEffect fills every pixel from the palette") { CHECK(everyPixelLit); } -// The hue is a function of (x, y): pixels far apart in the grid carry different colours, +// The hue is a function of (x, y): pixels far apart in the grid carry different colors, // so the field is spatial, not a uniform fill. -TEST_CASE("PraxisEffect varies colour across the grid") { +TEST_CASE("PraxisEffect varies color across the grid") { mm::Layouts layouts; mm::GridLayout grid; grid.width = 16; diff --git a/test/unit/light/unit_PreviewDriver.cpp b/test/unit/light/unit_PreviewDriver.cpp index 64020785..0014e635 100644 --- a/test/unit/light/unit_PreviewDriver.cpp +++ b/test/unit/light/unit_PreviewDriver.cpp @@ -31,7 +31,7 @@ struct CaptureBroadcaster : mm::BinaryBroadcaster { std::vector<uint8_t> lastCoord, lastFrame; std::vector<uint8_t> cur_; // payload accumulated across pushBinaryFrame between begin/end uint32_t generation = 0; // bump to simulate a new client connecting - bool acceptNext = true; // false → endBinaryFrame reports a colour frame not fully sent + bool acceptNext = true; // false → endBinaryFrame reports a color frame not fully sent bool dropCoord = false; // true → endBinaryFrame reports a coord table not fully sent void beginBinaryFrame(size_t /*totalLen*/) override { cur_.clear(); } @@ -46,7 +46,7 @@ struct CaptureBroadcaster : mm::BinaryBroadcaster { coordMsgs++; lastCoord = cur_; return true; } if (type == 0x02) { - if (!acceptNext) return false; // simulate the colour frame not reaching the client + if (!acceptNext) return false; // simulate the color frame not reaching the client frameMsgs++; lastFrame = cur_; return true; } return true; @@ -57,7 +57,7 @@ struct CaptureBroadcaster : mm::BinaryBroadcaster { bool tryAcquireSend() override { return true; } void releaseSend() override {} - // Resumable buffered send — the colour-frame path (coord table uses begin/push/end). The mock + // Resumable buffered send — the color-frame path (coord table uses begin/push/end). The mock // captures it as a 0x02 frame (header ++ body). `bufferedDrains` models a slow link: the send // stays "in flight" for that many bufferedSendIdle() polls before going idle (0 = instant). // bufferedFrames counts accepted sends; bufferedDropped counts newest-wins backpressure drops. @@ -176,7 +176,7 @@ TEST_CASE("PreviewDriver small grid sends all lights exactly") { // A large layout is SPATIALLY downsampled (a regular per-axis lattice, not every-Nth-flat- // index) so the payload fits the send-buffer cap without the diagonal moiré that linear // stride produced on a grid whose width didn't divide the stride. The wire "stride" field -// carries the per-axis lattice/downscale factor (colour k still maps 1:1 to coord k). +// carries the per-axis lattice/downscale factor (color k still maps 1:1 to coord k). TEST_CASE("PreviewDriver downsamples a large layout on a regular spatial lattice") { // 200×200 = 40000 lights, over the 4096 display cap → the lattice downsample engages. The // extent (199) is ≤255/axis, so positions are sent at EXACT integer grid coordinates (no @@ -234,7 +234,7 @@ TEST_CASE("PreviewDriver fps default") { CHECK(driver.fps == 24); } -// Regression: a coordinate table dropped under backpressure must be RETRIED, and colour +// Regression: a coordinate table dropped under backpressure must be RETRIED, and color // frames withheld until it lands — otherwise the device sends 0x02 frames the browser skips // (count mismatch) and the preview freezes for the whole session. Drives tick() (where the // coord-pending logic lives) with a broadcaster that drops every 0x03, then lets it through. @@ -250,7 +250,7 @@ TEST_CASE("PreviewDriver retries a dropped coordinate table, withholds frames un uint32_t t = 1000; auto tick = [&] { t += 100; mm::platform::setTestNowMs(t); rig.preview->tick(); }; - // Pump tick() several times. The rebuilt 0x03 never lands, so NO colour frame may go out — + // Pump tick() several times. The rebuilt 0x03 never lands, so NO color frame may go out — // a 0x02 now would carry a count the browser can't map (the freeze the guard prevents). for (int i = 0; i < 5; i++) tick(); CHECK(rig.cap.frameMsgs == 0); // frames withheld while the table is pending @@ -258,7 +258,7 @@ TEST_CASE("PreviewDriver retries a dropped coordinate table, withholds frames un // Link recovers: the table now lands, and frames resume — matching the same count. rig.cap.dropCoord = false; tick(); // retries the pending table (it lands) - tick(); // now a colour frame may go out + tick(); // now a color frame may go out CHECK(rig.cap.coordMsgs > 0); // the table finally reached the client CHECK(rig.cap.frameMsgs > 0); // frames resumed CHECK(rig.cap.coordCount() == rig.cap.frameCount()); // and they agree (no freeze) @@ -324,7 +324,7 @@ TEST_CASE("PreviewDriver sends coordinates only on change / new client, never on CHECK(afterFirst >= 1); // Advance a FULL 3 seconds with no new client and no rebuild: tick() keeps sending - // colour frames but must NOT re-send the coordinate table. This is the regression + // color frames but must NOT re-send the coordinate table. This is the regression // guard — the removed ~1 Hz timer would have re-sent ~3 times here. for (int t = 1; t <= 3; t++) { mm::platform::setTestNowMs(100000 + t * 1000); @@ -387,12 +387,12 @@ TEST_CASE("PreviewDriver reports its resumable-path buffers in dynamicBytes" * d MESSAGE("skipped — see docs/backlog/backlog-light.md (resumableFrames default OFF)"); } -// Dense-grid CLOSED-FORM downsample, exact colour placement: a 200×1 strip pinned over a small cap -// strides in x only, so the kept lights are columns 0,s,2s,… The colour pass must read each from its +// Dense-grid CLOSED-FORM downsample, exact color placement: a 200×1 strip pinned over a small cap +// strides in x only, so the kept lights are columns 0,s,2s,… The color pass must read each from its // dense buffer index (closed-form x for a 1-row grid) and pack them in the SAME order as the coord -// table — no forEachCoord. Painting a known colour at a kept column and finding it at the matching +// table — no forEachCoord. Painting a known color at a kept column and finding it at the matching // frame position pins the index math + the lattice order. -TEST_CASE("PreviewDriver dense downsample packs colours by closed-form index, in lattice order") { +TEST_CASE("PreviewDriver dense downsample packs colors by closed-form index, in lattice order") { const int width = 5000; // > the 4096 display cap → forces a stride mm::GridLayout g; g.width = width; g.height = 1; g.depth = 1; PreviewRig rig(&g); diff --git a/test/unit/light/unit_RainbowEffect.cpp b/test/unit/light/unit_RainbowEffect.cpp index 85de9cb0..59d753a7 100644 --- a/test/unit/light/unit_RainbowEffect.cpp +++ b/test/unit/light/unit_RainbowEffect.cpp @@ -38,7 +38,7 @@ TEST_CASE("RainbowEffect writes non-zero RGB data to buffer") { CHECK(hasNonZero); } -// Pixel (0,0) carries a lit palette colour — confirms the effect writes a real RGB there. +// Pixel (0,0) carries a lit palette color — confirms the effect writes a real RGB there. TEST_CASE("RainbowEffect pixel 0,0 produces valid RGB") { mm::Layouts layouts; mm::GridLayout grid; @@ -55,17 +55,17 @@ TEST_CASE("RainbowEffect pixel 0,0 produces valid RGB") { layer.addChild(&rainbow); layer.applyState(); - // Pin a known-colourful palette (Rainbow=0, generated at full saturation/value so no entry is + // Pin a known-colorful palette (Rainbow=0, generated at full saturation/value so no entry is // black) — Palettes::active() is a process-wide static any prior test can mutate, so this makes // the non-black assertion order-independent. mm::Palettes::setActive(0); layer.tick(); auto* data = layer.buffer().data(); - // Pixel (0,0): the effect maps the diagonal hue through the active palette, so the exact colour + // Pixel (0,0): the effect maps the diagonal hue through the active palette, so the exact color // depends on elapsed() (the phase) and the palette. The stable, time-independent contract is that - // the effect writes a LIT colour there (not black) — asserting a channel == 255 was false, since - // palette colours are not all fully saturated (the old hsvToRgb(h,255,255) assumption is stale). + // the effect writes a LIT color there (not black) — asserting a channel == 255 was false, since + // palette colors are not all fully saturated (the old hsvToRgb(h,255,255) assumption is stale). uint8_t r = data[0], g = data[1], b = data[2]; CHECK((r > 0 || g > 0 || b > 0)); } diff --git a/test/unit/light/unit_RandomEffect.cpp b/test/unit/light/unit_RandomEffect.cpp index 77c8eb25..7398bfd1 100644 --- a/test/unit/light/unit_RandomEffect.cpp +++ b/test/unit/light/unit_RandomEffect.cpp @@ -43,7 +43,7 @@ TEST_CASE("RandomEffect lights exactly one light per frame") { } // Over many frames with light fade the sparkle field fills — more than one light ends up lit. -TEST_CASE("RandomEffect scatters colour across many lights over many frames") { +TEST_CASE("RandomEffect scatters color across many lights over many frames") { mm::Layouts layouts; mm::GridLayout grid; grid.width = 8; diff --git a/test/unit/light/unit_RubiksCubeEffect.cpp b/test/unit/light/unit_RubiksCubeEffect.cpp index b7fb0a2f..86bee659 100644 --- a/test/unit/light/unit_RubiksCubeEffect.cpp +++ b/test/unit/light/unit_RubiksCubeEffect.cpp @@ -12,9 +12,9 @@ // order-dependent neighbours. namespace { struct ClockGuard { ~ClockGuard() { mm::platform::setTestNowMs(0); } }; } -// The six sticker colours drawCube() paints from (Red, DarkOrange, Blue, Green, Yellow, White) — -// the only colours a lit voxel may carry. -static bool isRubiksFaceColour(uint8_t r, uint8_t g, uint8_t b) { +// The six sticker colors drawCube() paints from (Red, DarkOrange, Blue, Green, Yellow, White) — +// the only colors a lit voxel may carry. +static bool isRubiksFaceColor(uint8_t r, uint8_t g, uint8_t b) { static const mm::RGB kMap[6] = { {255, 0, 0}, {255, 140, 0}, {0, 0, 255}, {0, 128, 0}, {255, 255, 0}, {255, 255, 255}}; for (const mm::RGB& c : kMap) @@ -55,9 +55,9 @@ TEST_CASE("RubiksCubeEffect paints the cube on the first frame") { CHECK(anyLit); } -// Every lit voxel carries exactly one of the six Rubik's face colours — the projection only ever +// Every lit voxel carries exactly one of the six Rubik's face colors — the projection only ever // writes COLOR_MAP entries, never a blended or arbitrary RGB. -TEST_CASE("RubiksCubeEffect only paints the six face colours") { +TEST_CASE("RubiksCubeEffect only paints the six face colors") { ClockGuard guard; mm::platform::setTestNowMs(1); @@ -82,7 +82,7 @@ TEST_CASE("RubiksCubeEffect only paints the six face colours") { for (size_t i = 0; i + 2 < buf.bytes(); i += 3) { uint8_t r = buf.data()[i], g = buf.data()[i + 1], b = buf.data()[i + 2]; if (r == 0 && g == 0 && b == 0) continue; // interior/background voxel — drawCube leaves it black - CHECK(isRubiksFaceColour(r, g, b)); + CHECK(isRubiksFaceColor(r, g, b)); } } diff --git a/test/unit/light/unit_SolidEffect.cpp b/test/unit/light/unit_SolidEffect.cpp index 8a52e59b..21e73675 100644 --- a/test/unit/light/unit_SolidEffect.cpp +++ b/test/unit/light/unit_SolidEffect.cpp @@ -5,8 +5,8 @@ #include "light/effects/SolidEffect.h" #include "light/layouts/GridLayout.h" -// Mode 0 (RGB(W)) fills the whole buffer with one uniform colour: every light equals red/green/blue. -TEST_CASE("SolidEffect mode 0 fills the buffer with one uniform colour") { +// Mode 0 (RGB(W)) fills the whole buffer with one uniform color: every light equals red/green/blue. +TEST_CASE("SolidEffect mode 0 fills the buffer with one uniform color") { mm::Layouts layouts; mm::GridLayout grid; grid.width = 4; @@ -23,7 +23,7 @@ TEST_CASE("SolidEffect mode 0 fills the buffer with one uniform colour") { solid.red = 100; solid.green = 50; solid.blue = 25; - solid.brightness = 255; // brightness 255 leaves the colour unscaled + solid.brightness = 255; // brightness 255 leaves the color unscaled layer.addChild(&solid); layer.applyState(); @@ -31,7 +31,7 @@ TEST_CASE("SolidEffect mode 0 fills the buffer with one uniform colour") { auto& buf = layer.buffer(); REQUIRE(buf.count() == 16); - // Every light carries exactly the configured RGB — the whole grid is one flat colour. + // Every light carries exactly the configured RGB — the whole grid is one flat color. for (size_t i = 0; i < buf.count(); i++) { CHECK(buf.data()[i * 3 + 0] == 100); CHECK(buf.data()[i * 3 + 1] == 50); @@ -39,8 +39,8 @@ TEST_CASE("SolidEffect mode 0 fills the buffer with one uniform colour") { } } -// Brightness scales the flat colour down per channel (channel * brightness / 255). -TEST_CASE("SolidEffect mode 0 scales the flat colour by brightness") { +// Brightness scales the flat color down per channel (channel * brightness / 255). +TEST_CASE("SolidEffect mode 0 scales the flat color by brightness") { mm::Layouts layouts; mm::GridLayout grid; grid.width = 2; @@ -106,8 +106,8 @@ TEST_CASE("SolidEffect mode 0 writes the white channel on an RGBW layer") { } } -// The effect runs at a degenerate 0×0×0 grid and at every colour mode without crashing. -TEST_CASE("SolidEffect survives a 0x0x0 grid across all colour modes") { +// The effect runs at a degenerate 0×0×0 grid and at every color mode without crashing. +TEST_CASE("SolidEffect survives a 0x0x0 grid across all color modes") { mm::Layouts layouts; mm::GridLayout grid; grid.width = 0; diff --git a/test/unit/light/unit_SphereLayout.cpp b/test/unit/light/unit_SphereLayout.cpp index d886cf04..96522932 100644 --- a/test/unit/light/unit_SphereLayout.cpp +++ b/test/unit/light/unit_SphereLayout.cpp @@ -3,6 +3,7 @@ #include "doctest.h" #include "light/layouts/SphereLayout.h" +#include <algorithm> #include <vector> // SphereLayout places lights on the surface of a hollow sphere — a one-light- @@ -79,8 +80,9 @@ TEST_CASE("SphereLayout shell is centre-symmetric") { auto pts = collectPoints(s); auto has = [&](mm::lengthType x, mm::lengthType y, mm::lengthType z) { - for (const auto& p : pts) if (p.x == x && p.y == y && p.z == z) return true; - return false; + return std::ranges::any_of(pts, [&](const auto& p) { + return p.x == x && p.y == y && p.z == z; + }); }; for (const auto& p : pts) { // Mirror through centre: (r - d) on each axis. diff --git a/test/unit/light/unit_SphereMoveEffect.cpp b/test/unit/light/unit_SphereMoveEffect.cpp index 2015c8f2..8fa58a52 100644 --- a/test/unit/light/unit_SphereMoveEffect.cpp +++ b/test/unit/light/unit_SphereMoveEffect.cpp @@ -45,9 +45,9 @@ TEST_CASE("SphereMoveEffect leaves most of a large volume dark (thin shell, full CHECK(lit < buf.count() / 2); } -// Every voxel the effect lights is a real palette colour (non-black) — the shell is drawn, not +// Every voxel the effect lights is a real palette color (non-black) — the shell is drawn, not // left as leftover noise. -TEST_CASE("SphereMoveEffect only writes non-black palette colours") { +TEST_CASE("SphereMoveEffect only writes non-black palette colors") { mm::Layouts layouts; mm::GridLayout grid; mm::Layer layer; @@ -59,8 +59,8 @@ TEST_CASE("SphereMoveEffect only writes non-black palette colours") { mm::Palettes::setActive(0); layer.tick(); - // Any pixel that is set has all-black or a genuine colour; because the buffer was zero-initialised - // and cleared, every non-zero pixel here is a shell voxel. Assert the shell exists and is coloured. + // Any pixel that is set has all-black or a genuine color; because the buffer was zero-initialised + // and cleared, every non-zero pixel here is a shell voxel. Assert the shell exists and is colored. auto& buf = layer.buffer(); bool anyLit = false; for (size_t p = 0; p < buf.count(); p++) { @@ -68,7 +68,7 @@ TEST_CASE("SphereMoveEffect only writes non-black palette colours") { if (px[0] || px[1] || px[2]) { anyLit = true; break; } } // A diameter ~2..3 shell on a 16³ grid with an origin inside the volume lights some voxels; the - // colour comes from the palette so it is never a stray single-channel artifact. + // color comes from the palette so it is never a stray single-channel artifact. CHECK(anyLit); } diff --git a/test/unit/light/unit_StarFieldEffect.cpp b/test/unit/light/unit_StarFieldEffect.cpp index 057574ba..3aecae41 100644 --- a/test/unit/light/unit_StarFieldEffect.cpp +++ b/test/unit/light/unit_StarFieldEffect.cpp @@ -86,7 +86,7 @@ TEST_CASE("StarFieldEffect at speed 0 leaves the buffer black") { } -// The palette variant lights on-panel stars in colour (not forced grey) — usePalette drives hue. +// The palette variant lights on-panel stars in color (not forced grey) — usePalette drives hue. TEST_CASE("StarFieldEffect with usePalette lights stars from the palette") { ClockGuard guard; mm::Layouts layouts; @@ -106,7 +106,7 @@ TEST_CASE("StarFieldEffect with usePalette lights stars from the palette") { layer.addChild(&stars); layer.applyState(); - // Rainbow palette (0), generated at full saturation/value so entries are colourful, not grey — + // Rainbow palette (0), generated at full saturation/value so entries are colorful, not grey — // Palettes::active() is a process-wide static any prior test can mutate, so pin it here. mm::Palettes::setActive(0); @@ -116,16 +116,16 @@ TEST_CASE("StarFieldEffect with usePalette lights stars from the palette") { auto* data = layer.buffer().data(); const size_t count = layer.buffer().count(); bool anyLit = false; - bool anyColoured = false; + bool anyColored = false; for (size_t i = 0; i < count; i++) { const uint8_t r = data[i * 3], g = data[i * 3 + 1], b = data[i * 3 + 2]; if (r || g || b) { anyLit = true; - if (!(r == g && g == b)) { anyColoured = true; break; } // a non-grey pixel = palette colour + if (!(r == g && g == b)) { anyColored = true; break; } // a non-grey pixel = palette color } } CHECK(anyLit); - CHECK(anyColoured); + CHECK(anyColored); } diff --git a/test/unit/light/unit_StarSkyEffect.cpp b/test/unit/light/unit_StarSkyEffect.cpp index c09c2e74..94ec7d29 100644 --- a/test/unit/light/unit_StarSkyEffect.cpp +++ b/test/unit/light/unit_StarSkyEffect.cpp @@ -61,7 +61,7 @@ TEST_CASE("StarSkyEffect white stars paint greyscale pixels") { uint8_t r = data[p * 3], g = data[p * 3 + 1], b = data[p * 3 + 2]; if (r || g || b) { litPixels++; - // A white star's colour is RGB{b,b,b}: the three channels must be equal. + // A white star's color is RGB{b,b,b}: the three channels must be equal. CHECK(r == g); CHECK(g == b); } diff --git a/test/unit/light/unit_TetrixEffect.cpp b/test/unit/light/unit_TetrixEffect.cpp index 6b4de248..5447c348 100644 --- a/test/unit/light/unit_TetrixEffect.cpp +++ b/test/unit/light/unit_TetrixEffect.cpp @@ -59,9 +59,9 @@ TEST_CASE("TetrixEffect renders black during the start delay") { } // Once virtual time advances past the start delay, columns spawn bricks that fall and render: after a -// span of frames at least one light is lit, and every lit light carries a real (non-black) RGB colour +// span of frames at least one light is lit, and every lit light carries a real (non-black) RGB color // pulled from the palette rather than partial/garbage channels. -TEST_CASE("TetrixEffect lights up with palette colour after the start delay") { +TEST_CASE("TetrixEffect lights up with palette color after the start delay") { ClockGuard guard; mm::platform::setTestNowMs(0); @@ -79,15 +79,15 @@ TEST_CASE("TetrixEffect lights up with palette colour after the start delay") { REQUIRE(lit); // Every non-black light is a full RGB triple from colorFromPalette — assert no lit light is a - // single stray channel (a lit light means at least one channel > 0; the brick colour is a palette - // entry written across all three channels, so a lit pixel is a genuine colour, not noise). + // single stray channel (a lit light means at least one channel > 0; the brick color is a palette + // entry written across all three channels, so a lit pixel is a genuine color, not noise). auto& buf = rig.layer.buffer(); - bool foundColoured = false; + bool foundColored = false; for (size_t p = 0; p + 2 < buf.bytes(); p += 3) { const uint8_t r = buf.data()[p], g = buf.data()[p + 1], b = buf.data()[p + 2]; - if (r || g || b) { foundColoured = true; break; } + if (r || g || b) { foundColored = true; break; } } - CHECK(foundColoured); + CHECK(foundColored); } // Effects must run at every grid size: a degenerate 0×0×0 grid and a 1×1 grid both survive a build + diff --git a/test/unit/light/unit_WaveEffect.cpp b/test/unit/light/unit_WaveEffect.cpp index fd561997..3c953880 100644 --- a/test/unit/light/unit_WaveEffect.cpp +++ b/test/unit/light/unit_WaveEffect.cpp @@ -1,7 +1,7 @@ // @module WaveEffect // Pins WaveEffect's pure waveform map (phase → y) for each of the six shapes — the behaviour -// that defines the effect. The animation/trail/colour need a Layer + buffer (covered by the +// that defines the effect. The animation/trail/color need a Layer + buffer (covered by the // scenario run); here we drive waveYForTest directly, no grid. #include "doctest.h" diff --git a/test/unit/light/unit_effects_render.cpp b/test/unit/light/unit_effects_render.cpp index b48790c3..163efa1e 100644 --- a/test/unit/light/unit_effects_render.cpp +++ b/test/unit/light/unit_effects_render.cpp @@ -93,7 +93,7 @@ TEST_CASE("LavaLampEffect writes non-zero RGB") { CHECK(ctx.hasNonZero()); } -// Across 10 frames at bpm=60, at least one frame shows two distinct colours somewhere in the buffer (blobs move and the field varies). +// Across 10 frames at bpm=60, at least one frame shows two distinct colors somewhere in the buffer (blobs move and the field varies). TEST_CASE("LavaLampEffect spatial variation") { // LavaLamp's blobs cluster at some t values and produce a near-uniform // saturated frame at the default slow bpm (=8). Sample several frames @@ -153,7 +153,7 @@ TEST_CASE("RipplesEffect writes non-zero RGB") { CHECK(ctx.hasNonZero()); } -// Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colours (wavefront vs background) — scan the whole buffer, corner-pair would be too strict. +// Ripples lights one pixel per column at a sine-driven height, so the surface holds at least two distinct colors (wavefront vs background) — scan the whole buffer, corner-pair would be too strict. TEST_CASE("RipplesEffect spatial variation") { Ctx ctx(32, 32); mm::RipplesEffect effect; diff --git a/web-installer/install-orchestrator.js b/web-installer/install-orchestrator.js index 6204c158..283e9482 100644 --- a/web-installer/install-orchestrator.js +++ b/web-installer/install-orchestrator.js @@ -32,16 +32,21 @@ // below MUST match the import URLs (a check script could pin this later). // // esptool-js is pinned 0.5.7 — the version ESP Web Tools (the flasher ESPHome -// and WLED embed) ships. 0.6.0 (the newest) has a DETERMINISTIC compressed-flash -// bug: a P4 web-flash aborts at the SAME block both times — "Failed to write -// compressed data to flash after seq 38, status 201" — where 0.4.7, 0.5.7, and -// the CLI (esptool.py) all flash the same P4 cleanly. Failing at a fixed seq (not -// a random one) rules out a transient USB hiccup; it's a real 0.6.0 regression in -// the deflate write path (cf. upstream esptool-js#245, per-block retry). 0.5.x +// and WLED embed) ships. 0.6.0 (the newest, tagged 2026-03-26) has a DETERMINISTIC +// compressed-flash bug: a P4 web-flash aborts at a FIXED block — "Failed to write +// compressed data to flash after seq NN failed with status 201,0" — where 0.4.7, +// 0.5.7, and the CLI (esptool.py) all flash the same P4 cleanly. Failing at a fixed +// seq (not a random one) rules out a transient USB hiccup; it's a real 0.6.0 +// regression in the deflate write path (cf. upstream esptool-js#233/#245). 0.5.x // also moved hardReset off ESPLoader into a reset-strategy class — handled by // hardResetChip() below (transport DTR/RTS), version-agnostic, so 0.6.x would // reboot fine IF its flash worked. Re-test the flash + reset path on any bump; -// 0.6.x is only viable once that deflate regression is fixed. Pinned 2026-06-28. +// 0.6.x is only viable once that deflate regression is fixed. +// Re-verified on the bench 2026-07-27: 0.6.0 is still the newest tag (no 0.6.1+), +// and a real P4 web-flash STILL aborts — "seq 50 failed with status 201,0". So the +// regression persists in 0.6.0-as-tagged; keep 0.5.7. (0.6.0 also brings no ESP32-S31 +// support — misdetection is tracked upstream in esptool-js#248 — so the bump has no +// upside for us either.) Pinned 2026-06-28, re-verified 2026-07-27. export const ESPTOOL_JS_VERSION = "0.5.7"; export const IMPROV_SDK_VERSION = "2.5.0"; import { ESPLoader, Transport } from "https://unpkg.com/esptool-js@0.5.7/bundle.js?module"; diff --git a/web-installer/install.css b/web-installer/install.css index 168c2e72..16103545 100644 --- a/web-installer/install.css +++ b/web-installer/install.css @@ -243,7 +243,7 @@ .bg-name { font-weight: 600; font-size: 12px; line-height: 1.2; } .bg-meta { color: var(--muted); font-size: 11px; } /* Capability chips: supported (green) vs planned (orange) — distinguished by - colour, not by extra text. Labels are kept short in deviceModels.json so every + color, not by extra text. Labels are kept short in deviceModels.json so every chip fits the ~150px card; the full label + state is in the chip's title tooltip. */ .bg-caps { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; } diff --git a/web-installer/install.js b/web-installer/install.js index 1225d777..2447cf37 100644 --- a/web-installer/install.js +++ b/web-installer/install.js @@ -1176,7 +1176,7 @@ document.addEventListener('DOMContentLoaded', () => { const meta = document.createElement("div"); meta.className = "bg-meta"; meta.textContent = b.chip + (ledDriver(b) ? " · " + ledDriver(b) : ""); body.appendChild(meta); - // Capability chips, three states by colour (not text): green = active + // Capability chips, three states by color (not text): green = active // (supported AND a module configured in deviceModels.json), yellow = supported // (firmware supports it, not pre-configured), orange = planned (no module // yet — the backlog seed). All chips are shown (labels kept short in @@ -1192,7 +1192,7 @@ document.addEventListener('DOMContentLoaded', () => { for (const { c, cls, label } of caps) { const chip = document.createElement("span"); chip.className = "bg-cap " + cls; - chip.textContent = c; // colour conveys active / supported / planned + chip.textContent = c; // color conveys active / supported / planned chip.title = c + " — " + label; capsEl.appendChild(chip); }