Skip to content

Add ControlModule: presets on a control surface - #62

Open
ewowi wants to merge 3 commits into
mainfrom
next-iteration
Open

Add ControlModule: presets on a control surface#62
ewowi wants to merge 3 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Save the device's state as a named preset and bring it back with one click.

A preset is a file, so it can be uploaded, downloaded and shared. Each one records which parts of the setup it carries, so a look saved on one board applies to a board with completely different hardware. The presets sit on an 8x8 pad grid with rotary encoders above and faders below, laid out like a Mackie control desk (X-Touch, QCon Pro G2) so a MIDI surface maps onto it later without a translation layer.

Why a core module

ControlModule is a top-level module, a peer of Layouts/Layers/Drivers rather than a child of Services, because it reaches across the top-level modules and cannot sit inside one. Presets are its first capability; external control (MIDI, IR, a hardware panel) is what it exists to host next.

MoonLight solved presets inside ModuleLightsControl, effects-only. This generalises it: a preset can carry any part of the tree, and the file records which.

What a preset carries

{
  "slot": 12,
  "captures": "Layouts,Layers",
  "Layouts.enabled": true, "Layouts.0.type": "GridLayout", "Layouts.0.width": 128,
  "Layers.enabled":  true, "Layers.0.type":  "Layer",      "Layers.0.0.type": "NoiseEffect"
}

Each captured subtree is exactly the bytes the persistence engine already writes, namespaced under a <TypeName>. prefix. Save and restore reuse the engine that already reconciles a tree against JSON rather than a second serializer that could drift from it.

Layers alone is a portable look. Adding Drivers makes it a device snapshot carrying pin maps. Because the file records the set, applying a preset is never a surprise.

One active preset per role

Each capturable subtree holds a role: layout, layer, driver, service. Applying a preset claims every role it carries and leaves the others alone, so a layout preset and a layer preset are both lit at once, and a new layer preset replaces only the layer.

Pads are tinted by their roles, mixing hues when a preset carries several. This is why mixed presets need no special case: a mixed preset owns several roles rather than being a different kind of pad, and one rule both colours it and decides when it is superseded.

Core changes

  • FilesystemModule::saveSubtreeTo / applySubtree — two new seams. saveSubtree now calls the former, so there is exactly one serializer.
  • applySubtree guards on the prefix being present: without it applyNode reads "no children in JSON" as "delete every child", so a truncated preset file would wipe the live look. Pinned by two tests.
  • ListSource::persistsList — a list whose rows are re-derived at setup is no longer written to flash. The preset list was being serialized on every save and discarded on load. Added at the core seam so Pins/Tasks can use it too.
  • Preset names are validated as printable ASCII without /, \ or .. The name becomes a file name, and ESP32's fsTranslate does no path normalization (the desktop one does), so an unguarded name could escape the preset folder on device through save, delete or rename.

UI

The pad grid, encoders and faders share one column track, so the three banks line up and still follow the pane as it is resized.

Two real bugs fixed along the way:

  • The seven-segment readouts and knob dials never redrew on a WebSocket patch (which fires neither input nor change), and were built before the input had a value or bounds. One redrawRangeDecorations call now owns that seam, so any decorated control added later stays in sync.
  • The power-on demo sweep snapshotted values before they were assigned and then restored the browser's default over every real value. It now replays device state instead. The sweep is marked as one removable block plus a single call site, and is view-only: it never sends a value to the device.

Testing

  • 21 ControlModule tests, 7 FilesystemModule subtree tests, full suite green.
  • Mutation-tested: removing the per-role rule fails 3 assertions; removing the path-traversal guard fails 7.
  • All pre-commit gates green (8 passed, 0 failed).
  • Running on an ESP32-S3 bench board.

Gap, stated plainly: no scenario test for the preset round trip. The scenario runner has no op that can apply a preset — it speaks /api/control, and applying a preset needs /api/list/. Extending the runner is separate work, so the round trip is covered by unit tests only.

Review

Findings fixed: path traversal via preset name; the persisted-then-discarded list; a redundant whole-folder rewrite on save that could displace an unrelated preset (fixing it exposed a real bug where an unaimed save landed on pad 1); a duplicated fader binding; stale order naming in three comments.

One finding not applied: "applying a preset runs on the HTTP thread, not the render tick". HttpServerModule::tick20ms is MM_NONBLOCKING and drains synchronously inside Scheduler::tick, so the docstring as written is correct.

Deferred to the dynamic-presets rework: the 192-byte header read, the insertion-sort struct copies, and kMaxPresets 36 -> 64 (accepted for now).

Not in this PR

Playlists, apply-on-boot, and the external-control bindings the encoders and faders 2-8 are waiting for. Each gets its own plan.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a control surface for saving, applying, renaming, deleting, and organizing device presets.
    • Added encoder, fader, pad-grid, drag-and-drop, role-based styling, and interactive control-surface feedback.
    • Added WLED and Home Assistant support for browsing and applying presets.
    • Added desktop startup support for selecting a custom server port.
  • Bug Fixes

    • Improved handling of invalid preset data and duplicate module registrations.
  • Documentation

    • Added control-surface and preset documentation, plus updated project guidance and metrics.

Save the device's state as a named preset and bring it back with one click. A
preset is a file, so it can be uploaded, downloaded and shared; each one records
which parts of the setup it carries, so a look saved on one board applies to a
board with different hardware. The presets sit on an 8x8 pad grid with encoders
above and faders below, laid out like a Mackie control desk so a MIDI surface
maps onto it later without a translation layer.

Flash esp32s3-n16r8 1,651 KB (+22 KB), esp32s31 1,887 KB (+7 KB), desktop 960 KB
(+36 KB); desktop tick 132 us (+5 us).

Core:
- ControlModule: a new top-level module, peer of Layouts/Layers/Drivers rather
  than a child of Services, since it reaches across them. Hosts presets now and
  external control (MIDI, IR) later.
- FilesystemModule gains two seams: saveSubtreeTo writes a subtree into a
  caller's sink, applySubtree puts one back onto a LIVE tree. saveSubtree now
  calls the former, so there is exactly one serializer.
- applySubtree guards on the prefix being present: without it applyNode reads
  "no children in JSON" as "delete every child", so a truncated preset would
  wipe the live look.
- ListSource::persistsList: a list whose rows are re-derived at setup is no
  longer written to flash. The preset list was serialized on every save and
  discarded on load, since nothing restores it.
- Preset names are validated as printable ASCII without / \ or . -- the name
  becomes a file name, and ESP32's fsTranslate does no path normalization
  (desktop's does), so an unguarded name could escape the preset folder on
  device via delete, rename or save.
- Control.h: fader/encoder/faderTarget descriptor flags and the pad-grid
  ListSource hooks, all presentation-only and domain-neutral.

Light domain:
- Unchanged. ControlModule resolves subtrees generically through typeName(), so
  core carries no light-specific knowledge.

UI:
- Pad grid, rotary encoders and faders share one column track, so the three
  banks line up and still follow the pane as it is resized.
- Pads are tinted by the roles they carry (layout/layer/driver/service), mixing
  hues when a preset carries several. Applying a preset claims only the roles it
  carries, so a layout preset and a layer preset stay lit at once.
- Fixed: the seven-segment readouts and knob dials never redrew on a WebSocket
  patch (which fires neither input nor change), and were built before the input
  had a value or bounds. One redrawRangeDecorations call now owns the seam.
- Power-on demo sweep, marked as one removable block plus a single call site.
  View-only: it never sends a value to the device.

Tests:
- 21 ControlModule tests, 7 FilesystemModule subtree tests.
- Mutation-tested: removing the per-role rule fails 3 assertions, removing the
  path-traversal guard fails 7.
- No scenario test for the preset round trip: the scenario runner has no op that
  can apply a preset (it speaks /api/control, applying needs /api/list/).
  Extending the runner is separate work.

Docs/CI:
- docs/moonmodules/core/control.md: catalog card plus the rules no header owns
  (what a preset carries, one-active-preset-per-role, applying is a rebuild).

Reviews:
- Path traversal via preset name -> fixed, validator on the control so every
  write path runs it; pinned and mutation-tested.
- Preset list persisted then discarded -> fixed at the core seam
  (ListSource::persistsList) rather than locally, so Pins/Tasks can use it.
- Save wrote the file without its slot then moved it, a second whole-folder
  rewrite that could displace an unrelated preset -> fixed by writing the slot
  up front. Exposed a real bug: an unaimed save landed on pad 1; added kNoSlot.
- Duplicated fader binding -> driveFader now parses faderTarget, so the popup
  and the action cannot disagree.
- Stale `order` naming in three comments -> renamed to `slot`.
- "Apply runs on the HTTP thread, not the render tick" -> not applied.
  HttpServerModule::tick20ms is MM_NONBLOCKING and drains inside Scheduler::tick,
  so the docstring is correct.
- 192-byte header read, insertion-sort struct copies -> deferred to the dynamic
  presets rework, along with kMaxPresets 36->64 (accepted by the PO for now).

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

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 3c563b3d-5c10-4689-9fda-656b75dfd74f

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

Use the checkbox below for a quick retry:

  • ✅ Review completed - (🔄 Check again to review again)

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

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 23

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

Inline comments:
In `@src/core/Control.h`:
- Around line 398-409: Update addText and addTextArea to use designated
initializers for the Control descriptors, explicitly naming the relevant members
such as var, name, type, bufSize, and validate. Remove the positional
false/nullptr values and rely on default initialization for unused aggregate
fields, while preserving each method’s existing behavior.

In `@src/core/ControlModule.h`:
- Around line 522-534: Update applyPreset around applySubtree and prepareTree to
persist the applied preset by marking each mutated module dirty and calling
FilesystemModule::noteDirty(). After the batch completes, trigger the existing
MoonModule schema-changed signal/hook so HttpServerModule performs
requestFullResync(), without coupling ControlModule directly to
HttpServerModule; preserve the current status reporting and return behavior.
- Around line 63-69: Update the grid-rendering comment above kGridCols,
kGridRows, and kMaxPresets to describe all kMaxPresets cells, or otherwise
reference kGridCols * kGridRows instead of the stale literal 36.
- Around line 109-110: Make the “slot” control non-persistable so saveSlot_
remains transient and kNoSlot is never written to or restored from flash, using
the existing transient-control mechanism. In savePreset, treat any value outside
the valid preset range as kNoSlot before selecting the target slot, preserving
assignFreeSlots for no-pad selections and preventing invalid API values from
becoming a real slot.
- Around line 537-555: Update renamePreset to detect whether the destination
preset already exists before calling fsWriteAtomic, and reject the operation
with the existing collision-reporting behavior instead of overwriting it.
Preserve the current rename flow for unused destination names, and follow the
collision handling principle already used by moveListRow.
- Around line 345-357: Update ControlModule::onEntry to skip preset files whose
stem length is at least sizeof(p.name), rather than truncating the name into
p.name. Only create a preset row when the complete filename stem fits,
preserving pathFor compatibility for all discovered entries.
- Around line 397-427: Update the slot persistence flow so a reorder writes only
presets whose slot values changed, rather than calling writeSlots for every
preset. In moveListRow, identify the moved preset and swapped occupant, then
persist each affected preset individually using the existing serialization and
atomic-write behavior; leave unchanged preset files untouched. Refactor
writeSlots or extract a single-preset helper as needed, preserving slot metadata
rewriting and cleanup.

In `@src/core/FilesystemModule.cpp`:
- Around line 350-357: The namespaced branch of FilesystemModule::saveSubtreeTo
must pass firstField=true to writeNode, matching the bare branch, because
savePreset already emits the sole separator before each subtree. In
src/core/ControlModule.h lines 467-475, retain the existing sink.append(",")
separator and add a strict JSON parsing test for saved preset output; no
separator change is needed there.

In `@src/ui/app.js`:
- Around line 2316-2338: Update the knob drag handlers around the pointerdown
listener so the existing up teardown also runs for pointercancel and
lostpointercapture. Ensure all termination paths remove the pointermove
listener, clear knob-turning, release capture when applicable, unregister the
end listeners, and dispatch the final change event only once.
- Around line 1505-1535: Extract the duplicated target-popup construction and
contextmenu/long-press listener setup from the encoder and fader branches into a
shared helper near this control-building logic. Have the helper accept the input
and control name/target context, then call it from both branches while
preserving the existing popup text and event behavior.
- Around line 2436-2459: Update the empty-cell creation logic in the
fixed/item-null branch to use a button element instead of a div, preserving its
existing drop-target behavior and styling. Add a primary click handler plus
keyboard activation for Enter and Space that call openPadEditor with the same
moduleName, ctrlName, null item, and slot index; retain context-menu and
long-press behavior as appropriate.
- Around line 2121-2141: Update the popup teardown around close, away, and the
document listeners so close() removes the popup and detaches both mousedown and
keydown listeners, matching away’s cleanup behavior. Ensure openPadEditor’s
save, overwrite, and delete paths use this shared teardown without leaving
listeners attached.
- Around line 2487-2493: The action payloads used by the pad click handler and
generic list button in src/ui/app.js (lines 2487-2493 and 2705-2720) must match
the corresponding test expectations in test/unit/core/unit_ControlModule.cpp.
Update both UI handlers and the tests to use the same payload, using "{}" if
that is the intended activate/apply body, while preserving the existing
listSetField flow.
- Around line 689-747: Add an early prefers-reduced-motion check at the
beginning of startSurfaceDemo, before checking or mutating surfaceDemoShownFor
or starting the animation, using the existing matchMedia browser API to return
immediately when reduced motion is requested. Preserve the current sweep
behavior for users who do not request reduced motion.

In `@src/ui/style.css`:
- Around line 940-947: Fix the Stylelint declaration-empty-line-before errors by
inserting a blank line before the padding declaration in .list-pad and before
the background declaration in .list-pad-active. Preserve all existing CSS values
and formatting otherwise.
- Around line 852-857: Update the .encoder-input styling to expose focus
feedback on its associated knob, using the existing :has() pattern because the
input follows the knob in the DOM. Add a visible focus-ring rule that activates
when the hidden encoder input is focused, while preserving its focusability and
current layout behavior.
- Around line 1665-1668: Consolidate the cursor declarations for the .knob
selector with its existing rules, removing the later duplicate cursor: grab
declaration so the intended ns-resize cursor is preserved. Keep the
.knob.knob-turning grabbing state, and group the drag/hover cursor styles with
the other .knob rules.
- Around line 1034-1055: Ensure the empty-cell hover styling in
.list-pad-empty:hover overrides the later generic .list-pad:hover rule by moving
it after the generic rule or increasing its specificity to
.list-pad.list-pad-empty:hover; preserve the quieter empty-cell background and
border colors.

In `@test/scenarios/light/scenario_peripheral_switch.json`:
- Line 257: Resolve the unsupported performance claim for the measure-i80-double
step by rerunning it alongside measure-i80-single on identical targets and runs,
then either add the appropriate supported performance or relative-bound
assertion or update the description near the measure-i80 scenario to state only
the non-freeze/normal-tick regression guard. Ensure the recorded values and
expectation are consistent.

In `@test/unit/core/unit_ControlModule.cpp`:
- Around line 37-62: Both fixtures derive temporary roots from
mm::platform::millis() and lack cleanup. In
test/unit/core/unit_ControlModule.cpp:37-62, update Device to use a monotonic
counter for unique roots and add a destructor that deletes the module tree and
removes the root directory; apply the same changes to Tree in
test/unit/core/unit_FilesystemModule_subtree.cpp:40-62, or use a shared helper
for both fixtures.
- Around line 73-82: Update the comment above setText to state that it sets the
named control's text value only, without claiming that it fires the change hook;
preserve the implementation and note that hook invocation is handled separately
by the tests.
- Around line 214-236: Update the fixture in “ControlModule skips a capture this
build does not have” so the capture names a module type that no build registers,
rather than “Drivers,” which the fixture/device provides. Keep the existing
assertions and valid Layers subtree, ensuring applyPreset reaches the
missing-module !m branch while still applying NoiseEffect and reporting the
skipped capture.

In `@test/unit/core/unit_FilesystemModule_subtree.cpp`:
- Around line 189-194: Update the applySubtree calls in
unit_FilesystemModule_subtree.cpp, including the cases at lines 121, 142, 158,
175, 189, 194, and 219, to assert their returned bool according to whether each
body is expected to be accepted or rejected. Preserve the existing tree-state
assertions while making corrupt and empty-body cases explicitly verify the
rejection result, following the corresponding ControlModule test.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a5f03ff1-34f5-469d-9dd5-0c0ae42db909

📥 Commits

Reviewing files that changed from the base of the PR and between b423df8 and e98379f.

⛔ Files ignored due to path filters (1)
  • docs/assets/core/ControlModule.png is excluded by !**/*.png
📒 Files selected for processing (28)
  • docs/history/plans/Plan-20260731 - ControlModule and presets.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/core/control.md
  • mkdocs.yml
  • src/core/Control.cpp
  • src/core/Control.h
  • src/core/ControlModule.h
  • src/core/FilesystemModule.cpp
  • src/core/FilesystemModule.h
  • src/core/HttpServerModule.cpp
  • src/main.cpp
  • src/ui/app.js
  • src/ui/style.css
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_Audio_mutation.json
  • test/scenarios/light/scenario_Driver_mutation.json
  • test/scenarios/light/scenario_GridBlacks_blackpixel.json
  • test/scenarios/light/scenario_Layers_composition.json
  • test/scenarios/light/scenario_Layouts_mutation.json
  • test/scenarios/light/scenario_MoonLiveEffect_livescript.json
  • test/scenarios/light/scenario_modifier_chain.json
  • test/scenarios/light/scenario_modifier_swap.json
  • test/scenarios/light/scenario_perf_full.json
  • test/scenarios/light/scenario_peripheral_grid_sweep.json
  • test/scenarios/light/scenario_peripheral_switch.json
  • test/unit/core/unit_ControlModule.cpp
  • test/unit/core/unit_FilesystemModule_subtree.cpp

Comment thread src/core/Control.h Outdated
Comment thread src/core/ControlModule.h
Comment thread src/core/ControlModule.h Outdated
Comment thread src/core/ControlModule.h
Comment thread src/core/ControlModule.h Outdated
"tick_us": [
4,
15
31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | rg '(^|/)scenario_peripheral_switch\.json$|test/scenarios|docs/moonmodules' || true

echo
echo "== file stats =="
wc -l test/scenarios/light/scenario_peripheral_switch.json

echo
echo "== relevant lines =="
sed -n '220,275p' test/scenarios/light/scenario_peripheral_switch.json | cat -n -v

echo
echo "== lines around 150-180 =="
sed -n '150,180p' test/scenarios/light/scenario_peripheral_switch.json | cat -n -v

echo
echo "== timing references =="
rg -n "tick_us|double-buffer|single|baseline|peripheral_switch\\)|observed\\.|at" test/scenarios/light/scenario_peripheral_switch.json

Repository: MoonModules/projectMM

Length of output: 8932


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all measure steps names =="
python3 - <<'PY'
import json
from pathlib import Path
p = Path("test/scenarios/light/scenario_peripheral_switch.json")
tree = json.loads(p.read_text())
for i, step in enumerate(tree.get("steps", []), 1):
    if step.get("op") == "measure" and "name" in step:
        print(f"{i}: {step['name']}: {step.get('description', '')[:120]}")
PY

echo
echo "== measure-i80-single and measure-i80-double observed values =="
python3 - <<'PY'
import json
p = Path("test/scenarios/light/scenario_peripheral_switch.json")
tree = json.loads(p.read_text())
for idx, step in enumerate(tree.get("steps", []), 1):
    if step.get("op") == "measure" and step.get("name") in ("measure-i80-single", "measure-i80-double"):
        print(f"step {idx}: {step['name']}")
        for platform, obs in step.get("observed", {}).items():
            ticks.obs = obs.get("tick_us")
            print(f"  {platform}: tick_us={obs.get('tick_us')}, at={obs.get('at')}")
PY

echo
echo "== scenario runner handling for measure expectations =="
rg -n '"description": "i80 with double-buffer: the encode overlaps the wire, so the tick should be at or below the single-buffer baseline\."|tick should be at or below|baseline|measure": true|observed|run scenario|scenario runner|scenarios' test scenario test/scenarios --glob '*.ts' --glob '*.js' --glob '*.json' --glob '*.md' | head -200

echo
echo "== nearby moonmodules docs =="
sed -n '1,120p' docs/moonmodules/core/system.md | cat -n -v || true

Repository: MoonModules/projectMM

Length of output: 956


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== measure-i80-single and measure-i80-double observed values =="
python3 - <<'PY'
import json
from pathlib import Path
p = Path("test/scenarios/light/scenario_peripheral_switch.json")
tree = json.loads(p.read_text())
for idx, step in enumerate(tree.get("steps", []), 1):
    if step.get("op") == "measure" and step.get("name") in ("measure-i80-single", "measure-i80-double"):
        print(f"step {idx}: {step['name']}")
        print(f"  observed tick_us:")
        for platform, obs in step.get("observed", {}).items():
            print(f"    {platform}: {obs.get('tick_us')}, at={obs.get('at')}")
PY

echo
echo "== benchmark invariant wording and nearby tests =="
rg -n "i80 with double-buffer: the encode overlaps the wire|tick should be at or below the single-buffer baseline|measure-i80-double|measure-i80-single" test docs --glob '*.ts' --glob '*.js' --glob '*.json' --glob '*.md' || true

echo
echo "== scenario runner handling for measure expectations =="
rg -n "scenario_peripheral_switch|measure-i80-double|baseline|tick should be at or below|run scenario|scenarios" scenario test test/scenarios docs --glob '*.ts' --glob '*.js' --glob '*.json' --glob '*.md' || true

Repository: MoonModules/projectMM

Length of output: 50377


Resolve the i80 double-buffer performance claim before publication.

measure-i80-double currently records 8,925 µs for esp32s3-n16r8 and 31 µs for desktop-macos, which are above the measure-i80-single baselines in this JSON. Rerun both steps on the same target and run; if this bound is required, add a supported performance contract or relative bound. If not, update the description at line 231 to match the non-freeze/normal-tick regression guard.

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

In `@test/scenarios/light/scenario_peripheral_switch.json` at line 257, Resolve
the unsupported performance claim for the measure-i80-double step by rerunning
it alongside measure-i80-single on identical targets and runs, then either add
the appropriate supported performance or relative-bound assertion or update the
description near the measure-i80 scenario to state only the
non-freeze/normal-tick regression guard. Ensure the recorded values and
expectation are consistent.

Sources: Coding guidelines, Learnings

Comment thread test/unit/core/unit_ControlModule.cpp
Comment thread test/unit/core/unit_ControlModule.cpp Outdated
Comment thread test/unit/core/unit_ControlModule.cpp Outdated
Comment thread test/unit/core/unit_FilesystemModule_subtree.cpp Outdated
A preset now captures exactly one thing: a look, or a geometry, or a hardware
setup, or a service configuration. Never a combination. Looks also reach Home
Assistant, where they appear in its own preset dropdown and can be applied from
the UI, a voice assistant or an automation.

Flash esp32s3-n16r8 1,656 KB (+6 KB), desktop 976 KB (+17 KB). Desktop tick reads
337 us but the sample was taken with an HTTP client attached; ControlModule has
no tick method and the hot-path gate passes.

Core:
- ControlModule: the four capture toggles become one `captures` Select, so the 16
  representable combinations become 4 and the invalid ones are unrepresentable.
  Defaults to Layers. Applying claims one role and leaves the other three, so a
  layout preset and a look stay lit together.
- A preset file naming several subtrees (written by the previous build) is listed
  but refused with a reason, so it can be seen and deleted rather than silently
  vanishing.
- ControlModule stamps a revision whenever the preset set changes, exposed as the
  WLED shim's `info.fs.pmt`. Home Assistant caches the preset list and re-fetches
  only when that value moves; a constant left HA showing the list it read at setup
  forever.
- Preset names are validated (printable ASCII, no / \ or .) — the name becomes a
  file name, and ESP32's fsTranslate does no path normalization, so an unguarded
  name could escape the preset folder via save, delete or rename.
- applySubtree marks the tree dirty: an applied preset rendered correctly and was
  then lost on reboot, because the boot loader restored the config the apply never
  updated.
- saveSubtreeTo passed firstField=false for a namespaced subtree while the caller
  also emitted a separator, so every preset carrying more than one capture was
  written as invalid JSON (",,"). Our own first-match reader tolerated it; a real
  parser would not.
- ListSource::persistsList: a list whose rows are re-derived at setup is no longer
  written to flash. The preset list was serialized on every save and discarded on
  load.
- A preset filename longer than the name buffer was truncated, so pathFor then
  addressed a different file — reachable by uploading through the File Manager.
  Renaming onto an existing preset overwrote it and deleted the source.
- Save writes its slot into the file rather than fixing it up afterwards, which
  removed a second whole-folder rewrite that could displace an unrelated preset.

Light domain:
- Drivers: `multicore` and `renderWait` are expert-only. Tuning knobs, not
  settings.

UI:
- The capture checkboxes become a radio group; pad tint is a single role hue.
- Popup teardown detached only on click-away, so every save/delete leaked a
  mousedown+keydown pair. A pointercancel left the knob turning after the gesture
  ended. Empty pads were divs, so a keyboard user could not create a preset.
- The demo sweep respects prefers-reduced-motion and runs 1s rather than 3s.

Scripts/MoonDeck:
- run_desktop.py takes --port. Home Assistant's WLED integration connects on port
  80 only (its host field rejects a port), so testing that path on desktop needs
  `sudo uv run moondeck/run/run_desktop.py --port 80`.
- run_desktop.py picked the first executable path that existed, which served a
  build a day older than `cmake --build build` produces; it now picks the newest.

Tests:
- 28 ControlModule tests. Three mutation-tested this session: the per-role rule,
  the path-traversal guard, and the presets revision stamp.
- No scenario test for the preset round trip: the scenario runner speaks
  /api/control only, and applying a preset needs /api/list/.

Docs/CI:
- control.md covers one-role presets and both Home Assistant paths, including
  that the WLED integration is HA's native preset support while MQTT publishes the
  same looks as effects (HA has no MQTT preset concept).

Reviews:
- CodeRabbit, 24 findings: fixed the invalid-JSON separator, the lost-on-reboot
  apply, the filename truncation, the rename collision, the persisted-then-
  discarded list, the slot clamp, the popup and pointer leaks, the keyboard
  accessibility, four CSS ordering/duplication issues, and the designated
  initializers. Declined one: the activate payload already matches (the UI passes
  the value, the tests pass the body). Deferred the 192-byte header read and the
  insertion-sort copies to the dynamic-presets rework.
- One finding exposed a vacuous test: "skips a capture this build does not have"
  named a module the fixture provides, so it never reached the missing-module
  branch.

SKIPPED GATE: "ESP32 firmware up to date" fails — no boards connected this
session, so the ESP32-affecting changes (HttpServerModule, MqttModule,
ControlModule, Drivers) are verified on desktop only. Needs a hardware check
before merge.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 7

Caution

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

⚠️ Outside diff range comments (4)
src/core/ControlModule.h (2)

500-534: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

A new save can land on an already-occupied pad, putting two presets on one grid cell.

savePreset() writes saveSlot_ into the new preset's file (Line 521) without checking whether another preset already holds that slot. moveListRow explicitly swaps to avoid this class of collision (Line 337: "swap rather than overwrite"), but savePreset() has no equivalent guard.

This is reachable without any UI action: slot, name, captures, and save are ordinary hidden controls, and the class doc states they are settable "from the popup, the API and persistence." A client that sets slot to an occupied value and then triggers save creates a second file claiming the same grid cell. assignFreeSlots() only reassigns presets with hasSlot == false (Line 393), so it does not detect or resolve two presets that both already declare the same slot.

🐛 Proposed fix — refuse a save onto an occupied slot held by a different preset
+        if (saveSlot_ < kMaxPresets) {
+            for (uint8_t i = 0; i < presetCount_; i++) {
+                if (presets_[i].slot == saveSlot_ && std::strcmp(presets_[i].name, name_) != 0) {
+                    setStatusf(Severity::Warning, "slot %u is occupied by %s",
+                               static_cast<unsigned>(saveSlot_), presets_[i].name);
+                    return;
+                }
+            }
+        }
         if (captureRole_ >= kCaptureCount) { setStatusf(Severity::Warning, "choose what to capture"); return; }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/ControlModule.h` around lines 500 - 534, Update savePreset() to
check whether saveSlot_ is already assigned to a different existing preset
before writing the new file. If the slot is occupied, refuse the save and report
an appropriate status instead of persisting a duplicate slot; preserve the
current behavior for unassigned slots and slots that are not selected.

73-78: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard kCaptureCount against drifting from kCapturable/kCaptureRole.

kCaptureCount is a hand-maintained constant separate from the kCapturable and kCaptureRole array literals. If either array ever grows without updating kCaptureCount, every loop bounded by kCaptureCount (role lookup, capture serialization, writeListRow's role list) silently ignores the extra entries instead of failing to compile. The file already uses a static_assert for this exact class of risk at Lines 81-82 (kLayersRole).

♻️ Proposed fix
     static constexpr uint8_t kCaptureCount = 4;
+    static_assert(sizeof(kCapturable) / sizeof(kCapturable[0]) == kCaptureCount &&
+                  sizeof(kCaptureRole) / sizeof(kCaptureRole[0]) == kCaptureCount,
+                  "kCaptureCount must match kCapturable/kCaptureRole length");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/ControlModule.h` around lines 73 - 78, Replace the hand-maintained
kCaptureCount value with a compile-time size derived from kCapturable, and add a
static_assert alongside the existing kLayersRole check to verify kCapturable and
kCaptureRole have equal lengths. Keep the resulting count usable by the existing
loops and role lookup code.
src/core/FilesystemModule.cpp (2)

350-368: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the block comment to match the split between saveSubtreeTo and saveSubtree.

Lines 351-352 state "Returns true only when the file was written," but saveSubtreeTo (Line 356) never touches a file — it writes into the caller's JsonSink and returns false only on an allocation failure (Line 366). The file-write contract belongs to saveSubtree (Line 369). Leaving the two comments merged risks a future reader assuming saveSubtreeTo's return value reflects a completed write.

📝 Proposed fix
 // ---- Save ----
-// Returns true only when the file was written. On failure (path/overflow/write
-// error) the caller must keep the subtree dirty so the change isn't lost.
 // Serialize a subtree into a caller's sink. The write half of saveSubtree, split out so a caller
 // storing the bytes elsewhere (a named preset file) produces the SAME format the loader reads,
 // rather than a second serializer that could drift from this one. See the header.
+// Returns false only on an allocation failure (sink.overflowed()); this function never touches a file.
 bool FilesystemModule::saveSubtreeTo(MoonModule* m, JsonSink& sink, const char* prefix) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/FilesystemModule.cpp` around lines 350 - 368, Update the comment
immediately before saveSubtreeTo to describe serializing into the
caller-provided JsonSink and returning false only when the sink overflows; move
the file-written/dirty-subtree contract to the saveSubtree comment near that
method. Keep the existing format and loader-compatibility documentation attached
to saveSubtreeTo.

196-223: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Persistence is now fixed; the WS resync gap from the same past comment remains open.

applySubtree now calls m->markDirty() and noteDirty() (Lines 220-221), so an applied preset survives a reboot. This resolves the persistence half of the earlier "An applied preset is never persisted" finding.

The other half of that same finding is not addressed here. applyNode (called at Line 209) creates, replaces, and removes children to match the JSON — a structural mutation of the live tree. The past comment noted that every other structural mutator in HttpServerModule (applyAddModule, handleDeleteModule, handleReplaceModule, handleMoveModule) ends by calling requestFullResync() so connected WS clients do not patch against a stale leaf-hash baseline. applySubtree performs the same class of mutation but has no equivalent signal here, and none of ControlModule.h's callers (applyPreset) add one either.

#!/bin/bash
# Confirm whether applySubtree's structural changes reach HttpServerModule's resync signal.
set -euo pipefail
rg -n 'requestFullResync|setSchemaChangedHook|onSchemaChanged' -C4 src/core
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/FilesystemModule.cpp` around lines 196 - 223, Update the
applySubtree flow in FilesystemModule::applySubtree to notify HttpServerModule
after applyNode performs structural subtree changes, using the existing
requestFullResync or schema-change hook mechanism rather than adding a separate
signaling path. Ensure preset callers such as ControlModule::applyPreset result
in a full WS resync while preserving the existing markDirty and noteDirty
persistence behavior.
♻️ Duplicate comments (2)
src/core/ControlModule.h (2)

464-494: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

A single pad drag still rewrites every preset file.

moveListRow (Line 338) still calls writeSlots(), which loops over all presetCount_ presets and performs a read, a heap allocation, and an fsWriteAtomic for each one. This code is unchanged from the prior review round: only the moved preset and the swapped occupant actually changed slot, so a full-grid drag still costs up to 64 file rewrites (128 flash operations via fsWriteAtomic's temp-file-and-rename) on a cold path that already blocks the render tick.

♻️ Proposed fix — write only the presets whose slot changed
-    void writeSlots() {
-        for (uint8_t i = 0; i < presetCount_; i++) {
-            char path[128];
-            pathFor(presets_[i].name, path, sizeof(path));
+    void writeSlot(const Preset& p) {
+        char path[128];
+        pathFor(p.name, path, sizeof(path));
         const long size = platform::fsSize(path);
-            if (size <= 0) continue;
+        if (size <= 0) return;
         char* body = static_cast<char*>(platform::alloc(static_cast<size_t>(size) + 1));
-            if (!body) continue;
+        if (!body) return;
         const int n = platform::fsRead(path, body, static_cast<size_t>(size) + 1);
         if (n > 0) {
             body[n] = '\0';
             JsonSink sink;
-                sink.appendf("{\"slot\":%u,", static_cast<unsigned>(presets_[i].slot));
+            sink.appendf("{\"slot\":%u,", static_cast<unsigned>(p.slot));
             // … unchanged …
         }
         platform::free(body);
-        }
     }

Then in moveListRow, replace the whole-folder rewrite:

         const uint8_t from = moving->slot;
         moving->slot = to;
         if (occupant) occupant->slot = from;    // swap rather than overwrite
-        writeSlots();
+        writeSlot(*moving);
+        if (occupant) writeSlot(*occupant);
         sortBySlot();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/ControlModule.h` around lines 464 - 494, Update the moveListRow flow
and writeSlots implementation so a reorder persists only presets whose slot
value changed, rather than rewriting every preset file. Track the moved preset
and swapped occupant, then invoke the existing file-writing logic only for those
affected presets while preserving slot metadata cleanup and atomic writes.

598-610: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

A rename can still overwrite an existing zero-byte preset file.

The collision check uses platform::fsSize(dst) > 0 (Line 607). If a preset file at dst exists but is empty (0 bytes), this check does not detect it as "already exists," and the subsequent write silently replaces it. The intent stated in the adjacent comment ("Never overwrite another preset") calls for detecting existence, not just non-empty content.

🐛 Proposed fix
-        if (platform::fsSize(dst) > 0) {
+        if (platform::fsSize(dst) >= 0) {
             setStatusf(Severity::Warning, "%s already exists", to);
             return false;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/ControlModule.h` around lines 598 - 610, Update the collision check
in renamePreset to detect whether the destination preset file exists, including
zero-byte files, instead of relying on platform::fsSize(dst) > 0. Preserve the
existing warning status and early return for any existing destination, so
renaming never overwrites another preset.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@moondeck/run/run_desktop.py`:
- Around line 45-52: Update the root-build candidate list in the executable
selection logic to include the Windows-suffixed path ROOT / "build" /
"projectMM.exe" alongside the existing unsuffixed candidate, so Windows builds
are considered when selecting the freshest executable.

In `@src/core/HttpServerModule.cpp`:
- Around line 1495-1497: Add a monotonically increasing preset-set revision to
ControlModule, incrementing it after every successful preset-set mutation,
including saves, deletes, and renames. Update the pmt assignment in the
HttpServerModule handler to report this revision instead of presetsModifiedS(),
while preserving the nonzero behavior. Add a regression test covering two
mutations performed within the same second.

In `@src/core/MqttModule.cpp`:
- Around line 77-90: Expose a monotonic preset revision from ControlModule and
increment it whenever presets are saved, renamed, or deleted, including the
existing rescan flow. In MqttModule::tick1s(), retain the last observed revision
and, when discovery is enabled, connected, and the revision changes, invoke
publishDiscovery(true) so buffers and retained discovery are refreshed. Add
coverage for live preset save, rename, and delete updates.
- Around line 826-834: Update publishState(false) to include a currentLook()
signature in its change-detection state alongside lastOn_, lastBri_, and
lastPalette_, so look-only changes publish updated ha/state effects. Capture the
look signature before the early-return check, and update it only after all MQTT
state publishes succeed; add a regression test applying two look-only presets
while Drivers values remain unchanged.
- Around line 186-204: Move the effect-list scratch storage out of
discoveryPayload_ and into non-overlapping storage such as discoveryBuf_ before
the final snprintf. Update the fxScratch capacity and writeHaEffectList call
accordingly, while keeping buildMqttPublish’s use of discoveryBuf_ safe by
ensuring the temporary effect data is consumed before that call.

In `@src/platform/desktop/main_desktop.cpp`:
- Around line 75-92: Update the argument parsing in main to use strtol’s end
pointer and reject any non-numeric trailing characters, while preserving
validation for ports outside 1..65535 and missing values. Reject unknown
arguments with an error instead of ignoring them, and add regression coverage
for valid, missing, non-numeric, trailing-character, and out-of-range --port
values.

In `@test/unit/core/unit_ControlModule.cpp`:
- Around line 806-826: Update the test case “ControlModule stamps a new revision
whenever the preset set changes” to replace both delayMs(1100) calls with
deterministic mm::platform::setTestNowMs() advances before each save/delete
expectation. Restore the test clock with setTestNowMs(0) after the case,
including on failure if the test framework supports cleanup.

---

Outside diff comments:
In `@src/core/ControlModule.h`:
- Around line 500-534: Update savePreset() to check whether saveSlot_ is already
assigned to a different existing preset before writing the new file. If the slot
is occupied, refuse the save and report an appropriate status instead of
persisting a duplicate slot; preserve the current behavior for unassigned slots
and slots that are not selected.
- Around line 73-78: Replace the hand-maintained kCaptureCount value with a
compile-time size derived from kCapturable, and add a static_assert alongside
the existing kLayersRole check to verify kCapturable and kCaptureRole have equal
lengths. Keep the resulting count usable by the existing loops and role lookup
code.

In `@src/core/FilesystemModule.cpp`:
- Around line 350-368: Update the comment immediately before saveSubtreeTo to
describe serializing into the caller-provided JsonSink and returning false only
when the sink overflows; move the file-written/dirty-subtree contract to the
saveSubtree comment near that method. Keep the existing format and
loader-compatibility documentation attached to saveSubtreeTo.
- Around line 196-223: Update the applySubtree flow in
FilesystemModule::applySubtree to notify HttpServerModule after applyNode
performs structural subtree changes, using the existing requestFullResync or
schema-change hook mechanism rather than adding a separate signaling path.
Ensure preset callers such as ControlModule::applyPreset result in a full WS
resync while preserving the existing markDirty and noteDirty persistence
behavior.

---

Duplicate comments:
In `@src/core/ControlModule.h`:
- Around line 464-494: Update the moveListRow flow and writeSlots implementation
so a reorder persists only presets whose slot value changed, rather than
rewriting every preset file. Track the moved preset and swapped occupant, then
invoke the existing file-writing logic only for those affected presets while
preserving slot metadata cleanup and atomic writes.
- Around line 598-610: Update the collision check in renamePreset to detect
whether the destination preset file exists, including zero-byte files, instead
of relying on platform::fsSize(dst) > 0. Preserve the existing warning status
and early return for any existing destination, so renaming never overwrites
another preset.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 71613d6f-210b-42bc-a616-20576761a37a

📥 Commits

Reviewing files that changed from the base of the PR and between e98379f and 395000d.

📒 Files selected for processing (18)
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/core/control.md
  • moondeck/run/run_desktop.py
  • src/core/Control.h
  • src/core/ControlModule.h
  • src/core/FilesystemModule.cpp
  • src/core/HttpServerModule.cpp
  • src/core/HttpServerModule.h
  • src/core/MqttModule.cpp
  • src/core/MqttModule.h
  • src/light/drivers/Drivers.h
  • src/main.cpp
  • src/platform/desktop/main_desktop.cpp
  • src/ui/app.js
  • src/ui/style.css
  • test/unit/core/unit_ControlModule.cpp
  • test/unit/core/unit_FilesystemModule_subtree.cpp

Comment thread moondeck/run/run_desktop.py
Comment thread src/core/HttpServerModule.cpp Outdated
Comment on lines +1495 to +1497
unsigned pmt = 1;
if (auto* control = static_cast<ControlModule*>(findModuleByName("Control")))
pmt = static_cast<unsigned>(control->presetsModifiedS()) + 1; // +1: never report 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use a monotonic preset revision for pmt.

ControlModule::rescan() stores platform::millis() / 1000u. Two saves, deletes, or renames in the same second produce the same value. Home Assistant then keeps its previous /presets.json result.

Add a monotonically increasing preset-set revision in ControlModule. Increment it after each successful preset-set mutation. Report that revision here instead of the second-resolution timestamp. Add a regression test with two mutations in one second.

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

In `@src/core/HttpServerModule.cpp` around lines 1495 - 1497, Add a monotonically
increasing preset-set revision to ControlModule, incrementing it after every
successful preset-set mutation, including saves, deletes, and renames. Update
the pmt assignment in the HttpServerModule handler to report this revision
instead of presetsModifiedS(), while preserving the nonzero behavior. Add a
regression test covering two mutations performed within the same second.

Comment thread src/core/MqttModule.cpp
Comment thread src/core/MqttModule.cpp Outdated
Comment thread src/core/MqttModule.cpp
Comment thread src/platform/desktop/main_desktop.cpp
Comment thread test/unit/core/unit_ControlModule.cpp Outdated
Home Assistant now sees a preset saved, renamed or deleted while it is
connected, instead of keeping the list it read at setup. Preset pads refuse to
overwrite each other, and a preset applied from HA reaches every open browser.
Separately, the first power-function groundwork lands: a 16-bit math tier and a
golden-frame harness that pins what the effects render today, so the coming
migration can prove it changes nothing.

Flash desktop 982 KB (+6 KB); desktop tick 125 us (the 337 us in the previous
commit was sampled with an HTTP client attached, not a regression).

Core:
- ControlModule reports a monotonic preset revision instead of a
  seconds-resolution stamp: two changes inside one second were indistinguishable,
  so a consumer caching on it missed the second one. Drives both the WLED shim's
  info.fs.pmt and MQTT's re-announce.
- MqttModule re-announces discovery when that revision moves, so a mid-session
  preset reaches Home Assistant without a reconnect; and the ha/state change gate
  now includes the applied look, which alters neither on, brightness nor palette
  and so never published.
- The HA effect-list scratch moved out of discoveryPayload_: it sat at a fixed
  offset inside the buffer snprintf was writing, and the fixed prefix can grow
  past that offset and trample the list mid-format.
- Saving a different preset onto an occupied pad is refused with the holder's
  name; saving over the same name is unchanged. Rename now treats a zero-byte
  destination as a collision (fsSize returns 0 for an existing empty file, -1 for
  a missing one).
- A reorder rewrites only the presets whose slot changed, not every file.
- applySubtree fires the existing schema-changed hook, so a preset applied with
  no HTTP request in flight (HA over MQTT or the WLED shim) still reaches open
  browsers.
- ModuleFactory::registerType is idempotent by name. It never deduped, so each
  test fixture construction re-registered its types until the uint8_t capacity
  saturated and every later registration in the run failed.
- New core/math16.h: the 16-bit contract tier for the power functions --
  sin16/cos16, map32, and BeatPhase (the BPM accumulator nine effects hand-roll,
  which freezes when the frame time rounds to zero). sin16 uses a 130-byte
  quarter-wave table: interpolating the existing 8-bit LUT was implemented first
  and rejected on measurement at 1.1% error, worse than the 0.69% it was meant to
  beat; the table measures 0.031%.

Light domain:
- Drivers: multicore and renderWait are expert-only. Tuning knobs, not settings.

Scripts/MoonDeck:
- run_desktop.py takes --port (Home Assistant's WLED integration hardcodes port
  80 and its host field rejects a port, so testing that path on desktop needs
  `sudo ... --port 80`), and now picks the newest executable rather than the
  first path that exists -- it was serving a build a day older than
  `cmake --build build` produces.
- The desktop binary rejects a non-numeric or trailing-garbage --port and
  unknown arguments, instead of silently running on the default.

Tests:
- Golden-frame harness: renders an effect at a fixed clock and hashes the frame,
  so "renders exactly the same" is proved rather than asserted. Ten baselines
  captured from the current code and verified reproducible across runs; hashes,
  not frame blobs, so repo size stays flat.
- unit_math16: sin16 smoothness between LUT entries (the property large fixtures
  need), the 0.5% accuracy bound, map32's fencepost, and BeatPhase under
  sub-millisecond frames and the millis wrap.
- New MQTT rig covering live preset save and look-only state publishes.

Docs/CI:
- Power-function analysis, bottom-up and top-down: the primitive catalog with its
  prior art, and the build spec (homes, types, migration order, resource
  accounting, eleven showcase effects). A canon survey found one structural gap
  -- no way to read the framebuffer as a texture at a transformed coordinate,
  which is about a third of the classic effect canon.
- architecture.md drops "concrete first, abstract later" (removed from CLAUDE.md
  earlier); the four backlog files citing it now stand on their own rationale.
- ADRs are documented as immutable except the status line, so a superseded
  decision gets a dated pointer instead of the convention being folklore.

Reviews:
- CodeRabbit, 11 findings: fixed the scratch-buffer overlap, the revision
  resolution, the MQTT re-announce and state gate, the slot and rename
  collisions, the per-preset slot write, the resync hook, --port validation and
  the Windows path. The delayMs-based revision test was replaced by a counter,
  which made the suggested test-clock fix unnecessary.
- Skipped: regression tests for --port parsing -- main() is not linkable into the
  unit binary, and the parsing is ten lines validated by inspection.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

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

⚠️ Outside diff range comments (5)
src/core/HttpServerModule.cpp (1)

1557-1567: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Accept standalone ps commands on the WLED WebSocket path.

applyWledState now supports ps, but pollWledStateFromWebSockets() calls it only when the frame contains on or bri. A WLED client that sends {"ps":N} alone drops the preset request.

Include ps in the WebSocket ingress predicate. Add a regression test for a masked WebSocket frame that contains only ps.

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

In `@src/core/HttpServerModule.cpp` around lines 1557 - 1567, The WebSocket
ingress predicate in pollWledStateFromWebSockets must invoke applyWledState for
frames containing only ps, not just on or bri. Extend that predicate to
recognize ps and add a regression test covering a masked WebSocket frame with a
standalone ps command.
src/core/ControlModule.h (3)

639-644: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not report a rename after source deletion fails.

platform::fsRemove(src) is ignored. If it fails after fsWriteAtomic(dst, ...) succeeds, both preset files remain but this method returns true. The HTTP list operation then reports success for a rename that created a copy.

Check the source removal result. If it fails, remove the new destination when possible and return failure.

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

In `@src/core/ControlModule.h` around lines 639 - 644, Update the rename flow
around the source-removal call in ControlModule so it checks the result of
platform::fsRemove(src) before setting ok to true. If source deletion fails
after fsWriteAtomic succeeds, attempt to remove the newly created destination
when possible, keep the operation failed, and preserve the existing cleanup and
rescan behavior.

382-391: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Refresh the preset list after File Manager changes.

The preset grid is rebuilt only at setup and after ControlModule operations. A preset uploaded through the File Manager does not appear until another ControlModule operation or a reboot. This conflicts with the documented upload behavior.

  • src/core/ControlModule.h#L382-L391: add a core-neutral filesystem-change notification or an explicit live refresh path that calls rescan() after preset-folder uploads, deletes, and renames.
  • docs/moonmodules/core/control.md#L27-L29: retain this statement only after the live refresh behavior exists. Otherwise document the required refresh step.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/ControlModule.h` around lines 382 - 391, The preset list must
refresh immediately after File Manager uploads, deletes, and renames. Add a
core-neutral filesystem-change notification or explicit live refresh path
connected to ControlModule::rescan() for changes in the preset folder; update
docs/moonmodules/core/control.md lines 27-29 to retain the documented behavior
only if live refresh is implemented, otherwise document the required manual
refresh step.

278-287: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep active-role state consistent with preset file mutations.

current_ stores preset names. Deleting or renaming an active preset leaves its old name active. MQTT can then publish an effect that no longer exists, and WLED cannot resolve the active preset slot.

  • src/core/ControlModule.h#L278-L287: after a successful delete, clear every current_ entry equal to the deleted name.
  • src/core/ControlModule.h#L619-L645: after a successful rename, replace every current_ entry equal to from with to.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/ControlModule.h` around lines 278 - 287, Keep active-role state
synchronized with preset mutations in ControlModule: in deleteListRow, after a
successful fsRemove, clear every current_ entry matching the deleted preset
name; in the rename flow around lines 619-645, after a successful rename,
replace every current_ entry matching from with to. Apply the required changes
at both listed sites in src/core/ControlModule.h (278-287 and 619-645).
moondeck/run/run_desktop.py (1)

49-53: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restrict executable candidates to the current host.

The resolver now selects the newest path from both .exe and unsuffixed candidates. If a stale artifact from another host remains in build, the launcher can select an incompatible binary and fail to start or run the wrong build. Filter candidates by the host-specific suffix before calling max(...).

Proposed fix
-    existing = [c for c in candidates if c.exists()]
+    suffix = ".exe" if sys.platform == "win32" else ""
+    existing = [
+        c for c in candidates
+        if c.suffix == suffix and c.is_file()
+    ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/run/run_desktop.py` around lines 49 - 53, Update the executable
candidate selection in the resolver to retain only paths matching the current
host’s executable suffix before evaluating existence and calling max. Preserve
the newest-existing-candidate behavior while excluding incompatible .exe or
unsuffixed artifacts from other hosts.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/backlog/power-functions-analysis-bottom-up.md`:
- Line 3: Update the introductory paragraph in the power-functions analysis
document to remove the stale “to be written” wording and state that the top-down
companion already exists as the implementation specification, preserving the
surrounding description and link context.
- Line 138: Clarify the Stage 2 scope for fillTriangle across the candidate
table, gather-gap entry, filled-polygons cut line, and VectorBallsEffect
showcase reference. If triangles are included, list fillTriangle explicitly and
distinguish it from deferred general polygon fill; otherwise remove the showcase
reference and keep the deferred-scope statements consistent.
- Line 16: Reconcile the documented scope counts in the TL;DR, synthesis, and
repeated summary: update the stale “~30-function” and “eight families”
references to consistently reflect the defined ~34 functions across families
1–9, including Projection. Ensure all affected statements use the same totals.

In `@docs/backlog/power-functions-analysis-top-down.md`:
- Line 44: Update the particle API example’s code fence in the documentation to
specify the cpp language tag, changing the untyped opening fence to a cpp-tagged
fence so Markdownlint MD040 passes.

In `@src/core/ControlModule.h`:
- Around line 341-345: Update the reorder method surrounding writeSlot(*moving)
and writeSlot(*occupant) so writeSlot returns whether each file write persisted
successfully. Only increment presetsRevision_ and report success after every
affected write succeeds; on failure, restore the original in-memory slot
assignments and use a recoverable two-file swap strategy that avoids duplicate
slot claims after a partial write.

In `@src/core/math16.h`:
- Around line 82-88: Update map32 to widen operands before subtracting, avoiding
int32_t overflow, and replace the intermediate int64_t multiplication with an
overflow-safe multiply-divide approach that handles full-width 32-bit input and
output spans. Preserve clamping and zero-span behavior, and add regression
coverage for INT32_MIN/INT32_MAX combinations across input and output ranges;
invalid or unrepresentable cases must degrade visibly rather than crash.
- Around line 52-88: Mark the `sin16` and `map32` function declarations as
`constexpr` so their existing integer-only implementations can be evaluated at
compile time under C++20. Leave `cos16` unchanged since it already delegates to
`sin16`.

In `@test/unit/core/unit_math16.cpp`:
- Around line 13-14: Update unit_math16.cpp to include <algorithm> for std::max
and replace the implementation-defined M_PI usage with the portable C++20
std::numbers::pi_v<double> from <numbers> (or an equivalent local constexpr
value), including the repeated usage around the referenced lines.

In `@test/unit/core/unit_MqttModule.cpp`:
- Around line 311-333: Update PresetRig’s destructor to reset the platform
filesystem-root override before or while tearing down the fixture, then remove
root_. Ensure later tests no longer retain platform::fsSetRoot(root_) after
PresetRig ends.

---

Outside diff comments:
In `@moondeck/run/run_desktop.py`:
- Around line 49-53: Update the executable candidate selection in the resolver
to retain only paths matching the current host’s executable suffix before
evaluating existence and calling max. Preserve the newest-existing-candidate
behavior while excluding incompatible .exe or unsuffixed artifacts from other
hosts.

In `@src/core/ControlModule.h`:
- Around line 639-644: Update the rename flow around the source-removal call in
ControlModule so it checks the result of platform::fsRemove(src) before setting
ok to true. If source deletion fails after fsWriteAtomic succeeds, attempt to
remove the newly created destination when possible, keep the operation failed,
and preserve the existing cleanup and rescan behavior.
- Around line 382-391: The preset list must refresh immediately after File
Manager uploads, deletes, and renames. Add a core-neutral filesystem-change
notification or explicit live refresh path connected to ControlModule::rescan()
for changes in the preset folder; update docs/moonmodules/core/control.md lines
27-29 to retain the documented behavior only if live refresh is implemented,
otherwise document the required manual refresh step.
- Around line 278-287: Keep active-role state synchronized with preset mutations
in ControlModule: in deleteListRow, after a successful fsRemove, clear every
current_ entry matching the deleted preset name; in the rename flow around lines
619-645, after a successful rename, replace every current_ entry matching from
with to. Apply the required changes at both listed sites in
src/core/ControlModule.h (278-287 and 619-645).

In `@src/core/HttpServerModule.cpp`:
- Around line 1557-1567: The WebSocket ingress predicate in
pollWledStateFromWebSockets must invoke applyWledState for frames containing
only ps, not just on or bri. Extend that predicate to recognize ps and add a
regression test covering a masked WebSocket frame with a standalone ps command.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 22131fa5-8b3c-4797-8ac3-85d896892434

📥 Commits

Reviewing files that changed from the base of the PR and between 395000d and 16bbeb5.

📒 Files selected for processing (28)
  • CLAUDE.md
  • docs/adr/README.md
  • docs/architecture.md
  • docs/backlog/backlog-core.md
  • docs/backlog/power-functions-analysis-bottom-up.md
  • docs/backlog/power-functions-analysis-top-down.md
  • docs/backlog/rename-to-moonlight.md
  • docs/backlog/system-modules.md
  • docs/backlog/ui-extensibility-analysis-bottom-up.md
  • docs/metrics/repo-health.json
  • docs/metrics/repo-health.md
  • docs/moonmodules/core/control.md
  • moondeck/run/run_desktop.py
  • src/core/ControlModule.h
  • src/core/FilesystemModule.cpp
  • src/core/HttpServerModule.cpp
  • src/core/ModuleFactory.h
  • src/core/MqttModule.cpp
  • src/core/MqttModule.h
  • src/core/math16.h
  • src/platform/desktop/main_desktop.cpp
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_peripheral_grid_sweep.json
  • test/unit/core/unit_ControlModule.cpp
  • test/unit/core/unit_MqttModule.cpp
  • test/unit/core/unit_math16.cpp
  • test/unit/light/golden_frame.h
  • test/unit/light/unit_Effects_golden.cpp

@@ -0,0 +1,190 @@
# Power functions — bottom-up analysis

> **Forward-looking research document — exception to CLAUDE.md present-tense rule.** This is a Stage-1 bottom-up survey of *power functions*: the shared primitives (drawing, fields, physics, color, time) that LED effects are really made of, which MoonLive should expose as built-ins so scripts stay compact. It inventories three sources read on **2026-08-05**: (a) our own 39 compiled effects and the helpers they share, (b) WLED / the WLED Particle System / FastLED as prior art, (c) the industry-standard algorithm canon with originators. The **top-down** companion (to be written) turns the catalog into an implementation spec. Modelled on [livescripts-analysis-bottom-up.md](livescripts-analysis-bottom-up.md). Source citations are `file:line` against this repo, or repo-relative paths for external sources; usage counts come from reading every effect header and grepping the cloned externals.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Remove the stale “to be written” reference.

Line 3 says the top-down companion is still to be written, but docs/backlog/power-functions-analysis-top-down.md is included in this change and already provides that specification. Update the sentence to describe the current repository state.

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

In `@docs/backlog/power-functions-analysis-bottom-up.md` at line 3, Update the
introductory paragraph in the power-functions analysis document to remove the
stale “to be written” wording and state that the top-down companion already
exists as the implementation specification, preserving the surrounding
description and link context.

- **Modern additions the classic canon lacks.** Two primitives from the shader world earn a place on CPU: **2D signed distance functions** (circle/box/segment + smooth-min; Quilez) — anti-aliased shapes, outlines, glow and metaball-morphing from a few fixed-point ops — and **cosine gradient palettes** (12 constants = a whole palette, bakeable to a LUT). Plus the **Wu sub-pixel splat**, which WLED treats as the difference between 8-bit-console and modern motion on a coarse matrix.
- **Fixed-point is settled policy, and the conventions already exist in-repo.** Coding standards mandate integer-first ([coding-standards § numeric types](../coding-standards.md)); effects document the working idioms: uint8 angle (256 = full turn), palette index mod-256, noise coords 16.0 fixed, the uint64 BPM phase numerator divided late, 12.4 particle positions. Power functions adopt these, not float. The known trap to design around: naive signed right-shift rounds asymmetrically (−1>>1 = −1) — WLED-PS documents the sign-corrected form.
- **Prior art is cataloged, credited, and not ported.** Standing rule ([no-WLED-MM-derivation](../../CLAUDE.md)) plus license reality: WLED and the PS are EUPL v1.2. This document takes *concepts, measurements and API shapes*; implementations come fresh from the textbook sources named per primitive (Bresenham 1965, Wu 1991, Blinn 1982/1996, Reynolds 1987, Penner 2002, Kriegsman's fire2012, Elias's ripple, Quilez's articles).
- **Recommendation for the top-down doc: a ~30-function core in eight families** (§ The candidate set), dimension-generic per the PO decision, each function: one canonical algorithm, integer form, one home in core or light. The three MoonLive-side constraints that must be lifted for scripts to use any of this: the 16-entry builtin table, the one-arg-in/one-out host-call ABI, and a grammar with no variables, loops, or coordinate/time symbols ([MoonLiveBuiltins.h:40-54](../../src/core/moonlive/MoonLiveBuiltins.h), [MoonLiveCompiler.h:11-15](../../src/core/moonlive/MoonLiveCompiler.h)).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Reconcile the family and function counts.

The TL;DR says “~30-function core in eight families”. The synthesis defines “~34 functions” and families 1–9, including Projection at Line 144. Line 181 repeats “all eight families”. Update the stale counts so the scope is consistent.

Also applies to: 132-145, 181-181

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

In `@docs/backlog/power-functions-analysis-bottom-up.md` at line 16, Reconcile the
documented scope counts in the TL;DR, synthesis, and repeated summary: update
the stale “~30-function” and “eight families” references to consistently reflect
the defined ~34 functions across families 1–9, including Projection. Ensure all
affected statements use the same totals.

|---|---|---|---|
| 1 | **Frame ops** | `fill` *(have)*, `fade` *(have)*, `blur` *(have — already dimension-generic)*, `scroll(axis, delta, wrap)` | WLED #6/#8/#9; FreqMatrix's hand-rolled shift |
| 2 | **Pixel ops** | `pixel`/`get`/`addPixel`/`blendPixel` *(have)*, **`splat(fx, fy, c)`** — the Wu sub-pixel writer, 12.4 or 16.16 coords | WLED-PS renderer; ParticlesEffect's private 12.4 math; "modern motion" on coarse matrices |
| 3 | **Geometry** | `line` *(have)*, `lineAA` (Wu 1991), `circle`/`fillCircle` (midpoint), `rect`/`fillRect`/`bar` (the audio-meter staple), `text` *(have)*; **SDF trio** `sdCircle/sdBox/sdSegment` + `smin` + coverage-AA | 4 effects hand-roll bars; SDFs subsume metaballs/glow/outline |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Clarify whether fillTriangle is in scope.

The candidate table at Line 138 omits fillTriangle, the gather gap adds it at Line 162, and Line 172 places filled polygons below the cut. The companion top-down spec uses fillTriangle in VectorBallsEffect at Line 102. If triangles are in Stage 2, list them in the candidate set and distinguish them from deferred general polygon fill. Otherwise, remove the top-down showcase reference.

Also applies to: 162-162, 172-172

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

In `@docs/backlog/power-functions-analysis-bottom-up.md` at line 138, Clarify the
Stage 2 scope for fillTriangle across the candidate table, gather-gap entry,
filled-polygons cut line, and VectorBallsEffect showcase reference. If triangles
are included, list fillTriangle explicitly and distinguish it from deferred
general polygon fill; otherwise remove the showcase reference and keep the
deferred-scope statements consistent.


## 3. The particle kernel

```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Add a language tag to the code fence.

The particle API example begins with an untyped fence at Line 44. Change it to ```cpp so Markdownlint MD040 passes.

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 44-44: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

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

In `@docs/backlog/power-functions-analysis-top-down.md` at line 44, Update the
particle API example’s code fence in the documentation to specify the cpp
language tag, changing the untyped opening fence to a cpp-tagged fence so
Markdownlint MD040 passes.

Source: Linters/SAST tools

Comment thread src/core/ControlModule.h
Comment on lines +341 to +345
// Only the one or two presets whose slot changed get rewritten: a reorder used to rewrite
// EVERY file on the device, which is flash wear proportional to the collection for a
// two-preset change.
writeSlot(*moving);
if (occupant) writeSlot(*occupant);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make slot moves durable before reporting success.

writeSlot does not report write failure. This method changes the in-memory order and returns success even if a slot rewrite fails. A reboot can restore the old order or a half-written swap.

A successful reorder also changes the WLED preset-slot mapping. Increment presetsRevision_ only after all affected files persist, so Home Assistant refreshes its cached /presets.json mapping.

Make writeSlot return success. Roll back the in-memory swap and return failure when either write fails. Use a recoverable strategy for the two-file swap so one failed write does not leave duplicate slot claims.

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

In `@src/core/ControlModule.h` around lines 341 - 345, Update the reorder method
surrounding writeSlot(*moving) and writeSlot(*occupant) so writeSlot returns
whether each file write persisted successfully. Only increment presetsRevision_
and report success after every affected write succeeds; on failure, restore the
original in-memory slot assignments and use a recoverable two-file swap strategy
that avoids duplicate slot claims after a partial write.

Comment thread src/core/math16.h
Comment on lines +52 to +88
inline uint16_t sin16(angle16 theta) {
const uint8_t quadrant = static_cast<uint8_t>(theta >> 14); // 0..3
const uint16_t pos = static_cast<uint16_t>(theta & 0x3FFF); // position within the quadrant
// Odd quadrants run the quarter wave backwards (sin descends from the peak).
const uint16_t walk = (quadrant & 1) ? static_cast<uint16_t>(0x4000 - pos) : pos;
// uint16 for the index, NOT uint8: walk reaches 0x4000 exactly at a quadrant boundary in an odd
// quadrant, and >>8 is then 64 — which a uint8 holds fine but only by luck of the cast order.
// Keeping it wide makes the "table has 65 entries so idx==64 is valid" invariant explicit.
const uint16_t idx = static_cast<uint16_t>(walk >> 8); // 0..64
const uint8_t frac = static_cast<uint8_t>(walk & 0xFF);
const int32_t a = sin16_quarter[idx];
// At idx==64 the wave is exactly at the peak and frac is 0, so the b term contributes nothing;
// clamping keeps the read inside the 65-entry table without a branch on the hot path.
const int32_t b = sin16_quarter[idx < 64 ? idx + 1 : 64];
int32_t v = a + (((b - a) * frac) >> 8);
if (quadrant >= 2) v = -v; // lower half of the circle
return static_cast<uint16_t>(v + 32768);
}

/// Cosine: a quarter turn ahead of sine.
inline uint16_t cos16(angle16 theta) { return sin16(static_cast<angle16>(theta + 16384)); }

// ---- Range mapping ---------------------------------------------------------

/// Map `v` from [inLo,inHi] to [outLo,outHi] in 64-bit intermediate, clamped to the input range.
///
/// The fencepost lives HERE, once: six effects hand-rolled this and each carried its own comment
/// about the off-by-one (mapping to `n-1` vs `n` when the output is a grid extent). Callers map to
/// the extent (`0..w`) and the result is clamped to `w-1` by the pixel writer, which is the form
/// that does not lose the last column.
inline int32_t map32(int32_t v, int32_t inLo, int32_t inHi, int32_t outLo, int32_t outHi) {
if (inHi == inLo) return outLo; // zero span: no meaningful ratio
if (inHi > inLo) { if (v <= inLo) return outLo; if (v >= inHi) return outHi; }
else { if (v >= inLo) return outLo; if (v <= inHi) return outHi; }
const int64_t num = static_cast<int64_t>(v - inLo) * (outHi - outLo);
return static_cast<int32_t>(outLo + num / (inHi - inLo));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a '^CMakeLists\.txt$' -x rg -n 'CXX_STANDARD|cxx_std_' {}
rg -n -C 2 'inline (constexpr )?uint16_t (sin16|cos16)|inline (constexpr )?int32_t map32' src/core/math16.h

Repository: MoonModules/projectMM

Length of output: 226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '--- math16 constants ---\n'
fd -a '^math16\.h$' -x sh -c 'echo "### $1"; sed -n "1,140p" "$1"' sh {}

printf '\n--- C++ standard references ---\n'
rg -n -C 3 'set\(CMAKE_CXX_STANDARD|CMAKE_CXX_STANDARD|cxx_std_|CXX_STANDARD_REQUIRED' --glob 'CMakeLists.txt' .

printf '\n--- uses of sin16/cos16/map32 ---\n'
rg -n -C 2 '\b(sin16|cos16|map32)\b' src/core src || true

printf '\n--- constexpr-like definitions near math16 ---\n'
rg -n -C 2 'constexpr|consteval|sin16_quarter|angle16' src/core/math16.h src/core || true

Repository: MoonModules/projectMM

Length of output: 250


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- math16.h excerpt ---'
fd -a '^math16\.h$' -x sh -c 'echo "### $1"; sed -n "1,160p" "$1"' sh {}

echo
echo '--- C++ standard references ---'
rg -n -C 3 'set\(CMAKE_CXX_STANDARD|CMAKE_CXX_STANDARD|cxx_std_|CXX_STANDARD_REQUIRED' --glob 'CMakeLists.txt' . || true

echo
echo '--- core references to sin16/cos16/map32 ---'
rg -n -C 2 '\b(sin16|cos16|map32)\b' src/core src || true

echo
echo '--- constexpr/angle16/sin16_quarter references ---'
rg -n -C 2 'constexpr|consteval|sin16_quarter|angle16' src/core/math16.h src/core || true

Repository: MoonModules/projectMM

Length of output: 50377


Mark sin16 and map32 constexpr.

src/core/math16.h has C++20 enabled, so make these pure integer fixed-point helpers usable at compile time. Keep cos16 as is because it delegates to sin16.

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

In `@src/core/math16.h` around lines 52 - 88, Mark the `sin16` and `map32`
function declarations as `constexpr` so their existing integer-only
implementations can be evaluated at compile time under C++20. Leave `cos16`
unchanged since it already delegates to `sin16`.

Source: Path instructions

Comment thread src/core/math16.h
Comment on lines +82 to +88
inline int32_t map32(int32_t v, int32_t inLo, int32_t inHi, int32_t outLo, int32_t outHi) {
if (inHi == inLo) return outLo; // zero span: no meaningful ratio
if (inHi > inLo) { if (v <= inLo) return outLo; if (v >= inHi) return outHi; }
else { if (v >= inLo) return outLo; if (v <= inHi) return outHi; }
const int64_t num = static_cast<int64_t>(v - inLo) * (outHi - outLo);
return static_cast<int32_t>(outLo + num / (inHi - inLo));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Prevent signed overflow in map32.

Line 86 subtracts int32_t values before the cast to int64_t. For example, v == INT32_MAX and inLo == INT32_MIN invokes signed overflow.

Widen operands before subtraction. Also use a safe multiply-divide implementation for full-width ranges, because two valid 32-bit spans can exceed int64_t when multiplied. Add INT32_MIN and INT32_MAX regression cases.

As per path instructions, “For any input, order, or size, degrade visibly rather than crash.”

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

In `@src/core/math16.h` around lines 82 - 88, Update map32 to widen operands
before subtracting, avoiding int32_t overflow, and replace the intermediate
int64_t multiplication with an overflow-safe multiply-divide approach that
handles full-width 32-bit input and output spans. Preserve clamping and
zero-span behavior, and add regression coverage for INT32_MIN/INT32_MAX
combinations across input and output ranges; invalid or unrepresentable cases
must degrade visibly rather than crash.

Source: Path instructions

Comment on lines +13 to +14
#include <cmath>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

fd -a '^CMakeLists\.txt$' -x rg -n 'CXX_STANDARD|cxx_std_' {}
rg -n -C 2 '`#include` <algorithm>|std::max|M_PI|std::numbers' test/unit/core/unit_math16.cpp

Repository: MoonModules/projectMM

Length of output: 226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

cat -n test/unit/core/unit_math16.cpp | sed -n '1,70p'
printf '\nProject standard references:\n'
grep -nE 'CMAKE_CXX_STANDARD|cxx_std_|set\(.*CXX|target_compile_features|std::numbers|M_PI|std::max|algorithm' -R --exclude-dir=.git .

Repository: MoonModules/projectMM

Length of output: 23389


Make unit_math16.cpp self-contained and portable.

std::max requires <algorithm>, and M_PI is implementation-defined. This target is C++20, so use <numbers> and std::numbers::pi_v<double> instead, or use a local constexpr pi value.

Also applies to: 41-44

🧰 Tools
🪛 Cppcheck (2.21.0)

[style] 13-13: The function 'hsvToRgb' is never used.

(unusedFunction)

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

In `@test/unit/core/unit_math16.cpp` around lines 13 - 14, Update unit_math16.cpp
to include <algorithm> for std::max and replace the implementation-defined M_PI
usage with the portable C++20 std::numbers::pi_v<double> from <numbers> (or an
equivalent local constexpr value), including the repeated usage around the
referenced lines.

Comment on lines +311 to +333
PresetRig() {
static unsigned seq = 0;
std::snprintf(root_, sizeof(root_), "/tmp/mm_mqtt_preset_%u", ++seq);
std::filesystem::remove_all(root_);
platform::fsSetRoot(root_);

ModuleFactory::registerType<Layers>("Layers");
ModuleFactory::registerType<Layer>("Layer");
ModuleFactory::registerType<NoiseEffect>("NoiseEffect");
ModuleFactory::registerType<ControlModule>("ControlModule");

fs = new FilesystemModule();
fs->setTypeName("FilesystemModule");
fs->setScheduler(&scheduler);
layers = ModuleFactory::create("Layers");
control = static_cast<ControlModule*>(ModuleFactory::create("ControlModule"));
scheduler.addModule(fs);
scheduler.addModule(layers);
scheduler.addModule(control);
fs->setup(); layers->setup(); control->defineControls(); control->setup();
mqtt->setControlModule(control);
}
~PresetRig() { std::filesystem::remove_all(root_); }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reset the filesystem-root override during fixture teardown.

PresetRig removes root_ but leaves platform::fsSetRoot(root_) active. A later test can use a deleted filesystem root and depend on this fixture's state.

Reset the test-only filesystem override before the fixture ends.

As per path instructions, “Tests must reset test-only platform overrides and isolate filesystem roots between cases.”

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

In `@test/unit/core/unit_MqttModule.cpp` around lines 311 - 333, Update
PresetRig’s destructor to reset the platform filesystem-root override before or
while tearing down the fixture, then remove root_. Ensure later tests no longer
retain platform::fsSetRoot(root_) after PresetRig ends.

Source: Path instructions

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant