Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/assets/core/ControlModule.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
135 changes: 135 additions & 0 deletions docs/history/plans/Plan-20260731 - ControlModule and presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Plan: ControlModule and presets

## Context

There is no way to save a device's configuration and bring it back. Every change edits the live tree,
and the only persistence is the automatic one that restores exactly what was there at reboot. A user
who finds a look they like cannot keep it, and cannot switch between looks.

MoonLight solved this inside `ModuleLightsControl`, and the mechanism is the one to copy: **a preset
is a JSON file, saving is copying a file, selecting is reading one back**. MoonLight's presets cover
only effects and modifiers. We make it generic, and put it in **core** rather than the light domain,
so a preset can carry any part of the tree.

`ControlModule` is also where external control belongs later (MIDI surfaces, IR, a hardware panel):
one place that says "put the device in this state", whatever asked for it. Presets are its first
capability, not its only one.

**Naming.** `LightPresetsModule` already exists and is a different thing: named channel-role wirings
per fixture. It keeps its name here; the collision is noted in the module comment on both sides so a
reader is not misled. If the two prove confusable in use, renaming that one to a fixture profile is a
separate, PO-called change.

## Decisions taken

- **A preset captures a SELECTABLE set of top-level subtrees**, recorded in the file. A `Layers`-only
preset is hardware-portable; adding `Drivers` makes it a device snapshot that carries pin maps.
The file says which, so applying one is never a surprise.
- **Named files**: `/.config/presets/<name>.json`. Delete is a file delete; a preset uploaded through
the File Manager just appears. This is the PO's stated principle, taken literally.
- **Playlists are NOT in this branch.** The cycling hook is designed in and left unbuilt; multiple
named playlists get their own plan, informed by real presets to cycle.

## Design

### The file

```json
{
"captures": ["Layers", "Layouts"],
"Layers": { "enabled": true, "0.type": "Layer", "0.0.type": "NoiseEffect", "0.0.speed": 128 },
"Layouts": { "enabled": true, "0.type": "GridLayout", "0.width": 128 }
}
```

Each captured subtree is **exactly the bytes `FilesystemModule` already writes** for that module
(`writeNode`, `FilesystemModule.cpp:348`): a flat map of dotted positional keys, with `<idx>.type`
per child. Reusing that format means save and restore reuse the engine that already reconciles a tree
against JSON, rather than a second serializer that could drift from it.

### What has to be added to core

`FilesystemModule` can already do both halves, but neither is reachable at runtime:

- **`saveSubtreeTo(MoonModule*, JsonSink&)`** — factor the body of `saveSubtree`
(`FilesystemModule.cpp:319`) so it can write into a caller's sink instead of straight to
`/.config/<TypeName>.json`. The existing method becomes a thin caller of it.
- **`applySubtree(MoonModule*, const char* json, const char* prefix)`** — a public wrapper over the
private `applyNode` (`FilesystemModule.cpp:191`), which already creates, replaces and destroys
children by type and tolerates unknown types. **It must also drive the lifecycle `applyNode`
leaves undone**: `applyNode` calls only `defineControls()` on a created child, because at boot the
Scheduler's phases 3 and 4 follow. At runtime the caller must do what `applyAddModule` does
(`HttpServerModule.cpp:1591`): `setup()` then `applyState()`, then one `prepareTree()`.

Both go on `FilesystemModule` because that is where the format and the reconciliation live. No new
serializer, no second copy of the tree-walking rules.

### ControlModule

A top-level module, peer of Layouts/Layers/Drivers, registered in `main.cpp` alongside them. Not
under `Services`: it reaches *across* the top-level modules, so it cannot be a child of one.

Controls:

| control | what it does |
|---|---|
| `presets` | An editable `List` (`ListSource`, `Control.h:190`) — one row per file, with the captured subtrees shown per row. |
| `name` | Text: the name to save under. |
| `capture` | Which subtrees a save includes. One `addBool` per top-level module, so the set is explicit. |
| `save` | Button: write `/.config/presets/<name>.json`. |
| `status` | ReadOnly: what happened, and which preset is currently applied. |

Applying a row uses the list's existing per-row edit path (`setListRowField`), which reaches the
source with an arbitrary field name, so a row gets an "apply" affordance with no new UI primitive.
A row also carries delete and rename through the CRUD the list already provides.

**Save** flushes pending writes first (`FilesystemModule::flushPending()`, `.cpp:100`) so the file
captures the live state rather than a stale debounce, then walks the selected top-level modules and
writes one object per capture.

**Apply** reads the file, and for each key in `captures` that resolves to a live top-level module,
calls `applySubtree`. A capture naming a module this build does not have is skipped with a status
line: the same degrade-never-crash rule `applyNode` already follows for unknown child types.

### The hot path

Applying a preset rebuilds modules, and every structural mutator already quiesces the render worker
(`MoonModule::quiesceForMutation`, `MoonModule.h:510`). But mutations run inline on the render tick,
so a large restore stalls rendering for its duration. **Batch it**: mutate every captured subtree,
then one `prepareTree()` and one `requestFullResync()` at the end, rather than per subtree as the
existing add path does. `tick()` is untouched, since presets are a cold-path feature.

## Files

- `src/core/ControlModule.h` — new. The module, its controls, the preset `ListSource`.
- `src/core/FilesystemModule.h` / `.cpp` — `saveSubtreeTo` + `applySubtree`; `saveSubtree` refactored
to call the former.
- `src/main.cpp` — register the type, create it, `scheduler.addModule` it.
- `src/ui/app.js` — the row-apply affordance, **in both render paths** (`renderCards` and
`updateModuleControls`; a rule added to one only is invisible on a WebSocket update).
- `docs/moonmodules/core/control.md` + the catalog card.
- `test/unit/core/unit_ControlModule.cpp` — new.

## Verification

1. **Unit**: a preset round-trips (save a tree, mutate it, apply, the tree matches); a capture naming
an absent module is skipped without throwing; a corrupt file degrades to a status rather than a
crash; an unknown child type inside a capture is skipped and the rest still applies.
2. **Scenario**: save a preset, change effects and layout live, apply the preset, assert the pipeline
still renders non-zero, which is the wired-pipeline gate the other scenarios use.
3. **`check_footprint --module ControlModule`**: zero static RAM when not used.
4. **`clang-hotpath`**: no new blocking call on the render path.
5. **Bench, and the gate that matters**: on a real board, save a look, change it, bring it back, and
confirm the panels show what they showed before. **PO judgement.**
6. Hardware portability, deliberately: a `Layers`-only preset saved on one board applies on a board
with different pins and drives its own hardware.

## Deliberately not in this plan

- **Playlists**, per the decision above. The apply path is the hook they will need.
- **Apply-on-boot.** MoonLight explicitly does not (its preset branch is guarded against firing at
boot); WLED does. Worth deciding once presets exist and the behaviour can be felt.
- **Renaming `LightPresetsModule`.** Noted as a collision, not acted on: it is a PO call and a
separate change.
- **External control (MIDI, hardware surfaces).** This is what `ControlModule` exists to host, but
the first capability is presets; adding a control surface has its own plan.
58 changes: 29 additions & 29 deletions docs/metrics/repo-health.json
Original file line number Diff line number Diff line change
@@ -1,73 +1,73 @@
{
"commit": "cca3fe8e",
"commit": "e98379f3",
"flash": {
"esp32": 1678960,
"esp32p4-eth": 1503232,
"esp32p4-eth": 1503280,
"esp32p4-eth-wifi": 1793760,
"esp32s3-n16r8": 1667216,
"esp32s3-n16r8": 1696000,
"esp32s3-n8r8": 1666992,
"esp32s31": 1924992,
"desktop": 945752
"esp32s31": 1932624,
"desktop": 999784
},
"perf": {
"desktop": {
"tick_us": 127,
"fps": 7874
"tick_us": 337,
"fps": 2967
},
"esp32": {
"tick_us": 4164,
"fps": 240
}
},
"loc": {
"core": 14889,
"light": 20214,
"platform": 12526,
"ui": 5811,
"test": 35504,
"moondeck": 19968
"core": 15902,
"light": 20247,
"platform": 12543,
"ui": 6467,
"test": 36557,
"moondeck": 20025
},
"comments": {
"core": {
"lines": 5583,
"ratio": 0.409
"lines": 5941,
"ratio": 0.407
},
"light": {
"lines": 7815,
"ratio": 0.427
"lines": 7846,
"ratio": 0.428
},
"platform": {
"lines": 4198,
"ratio": 0.371
"lines": 4201,
"ratio": 0.37
},
"ui": {
"lines": 1518,
"ratio": 0.278
"lines": 1670,
"ratio": 0.274
},
"test": {
"lines": 6073,
"lines": 6232,
"ratio": 0.198
},
"moondeck": {
"lines": 3187,
"lines": 3193,
"ratio": 0.183
}
},
"tests": {
"cases": 1008,
"cases": 1042,
"scenarios": 22
},
"docs": {
"md_files": 170,
"md_lines": 22818,
"plans_files": 90,
"md_files": 172,
"md_lines": 23032,
"plans_files": 91,
"backlog_lines": 3114,
"lessons_lines": 418,
"claude_md_lines": 135
},
"complexity": {
"functions": 2188,
"over_threshold": 140,
"functions": 2238,
"over_threshold": 142,
"worst_ccn": 93
}
}
34 changes: 17 additions & 17 deletions docs/metrics/repo-health.md
Original file line number Diff line number Diff line change
@@ -1,61 +1,61 @@
# Repo health

Measured at `cca3fe8e`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.**
Measured at `e98379f3`. 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 | 924 KB (+0 KB) ⚠ |
| desktop | 976 KB (+17 KB) ⚠ |
| esp32 | 1,640 KB |
| esp32p4-eth | 1,468 KB |
| esp32p4-eth-wifi | 1,752 KB |
| esp32s3-n16r8 | 1,628 KB |
| esp32s3-n16r8 | 1,656 KB (+6 KB) ⚠ |
| esp32s3-n8r8 | 1,628 KB |
| esp32s31 | 1,880 KB |
| esp32s31 | 1,887 KB |

## Render performance

| Target | Tick | FPS |
|---|---:|---:|
| desktop | 127 µs | 7,874 |
| desktop | 337 µs (+205 µs) ⚠ | 2,967 (−4,608) ⚠ |
| esp32 | 4,164 µs | 240 |

## Code

| Area | Lines | Comments | Comment share |
|---|---:|---:|---:|
| core | 14,889 | 5,583 | 40.9 % |
| light | 20,214 (+16) ⚠ | 7,815 | 42.7 % |
| platform | 12,526 (+25) ⚠ | 4,198 | 37.1 % (+0.1 %) |
| ui | 5,811 | 1,518 | 27.8 % |
| test | 35,504 (+75) ⚠ | 6,073 | 19.8 % |
| moondeck | 19,968 | 3,187 | 18.3 % |
| core | 15,902 (+249) ⚠ | 5,941 | 40.7 % |
| light | 20,247 (+3) ⚠ | 7,846 | 42.8 % |
| platform | 12,543 (+17) ⚠ | 4,201 | 37.0 % (0.1 %) |
| ui | 6,467 (+10) ⚠ | 1,670 | 27.4 % (+0.1 %) ⚠ |
| test | 36,557 (+212) ⚠ | 6,232 | 19.8 % |
| moondeck | 20,025 (+19) ⚠ | 3,193 | 18.3 % |

## Tests

| Kind | Count |
|---|---:|
| unit cases | 1,008 (+1) ✓ |
| unit cases | 1,042 (+6) ✓ |
| scenarios | 22 |

## Complexity

| Metric | Value |
|---|---:|
| functions | 2,188 (+1) ✓ |
| over threshold | 140 (+1) ⚠ |
| functions | 2,238 (+11) ✓ |
| over threshold | 142 |
| worst CCN | 93 |

## Documentation

| Metric | Value |
|---|---:|
| markdown files | 170 |
| markdown lines | 22,818 (+9) ⚠ |
| plan files | 90 |
| markdown files | 172 |
| markdown lines | 23,032 (+17) ⚠ |
| plan files | 91 |
| backlog lines | 3,114 |
| lessons lines | 418 |
| CLAUDE.md lines | 135 |
Expand Down
79 changes: 79 additions & 0 deletions docs/moonmodules/core/control.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Core control

The device's control surface — the place that says "put the device into this state", whatever asked for it. A preset applied from the grid, and later a fader moved on a MIDI desk, arrive at the same code. Its first capability is presets; the surface layout exists so external controllers map onto something that already looks like them.

`ControlModule` is a top-level module, a peer of Layouts / Layers / Drivers rather than a child of Services: it reaches *across* the top-level modules, so it cannot sit inside one.

## Control modules

<a id="control"></a>

### Control

A grid of preset pads, a row of rotary encoders above them, and a bank of faders below — the layout of a Mackie-style control desk ([X-Touch](https://www.behringer.com/product.html?modelCode=0808-AAF), [QCon Pro G2](https://www.iconproaudio.com/product/qcon-pro-g2/)), so a physical surface maps onto it without a translation layer.

<img src="../../assets/core/ControlModule.png" width="300" alt="Control module surface: encoders, preset pads, faders">

- `presets` — the pad grid (8×8). One pad per preset file; click to apply, right-click (or long-press) to name it, pick which single subtree it captures, save or delete. Drag a pad to rearrange the surface.
- `enc1` … `enc8` — rotary encoders. Drag or scroll to turn; right-click shows what each drives.
- `fader1` … `fader8` — faders. `fader1` drives `Drivers.brightness`; the rest are unassigned until bound.

Detail: [technical](moxygen/ControlModule.md)

[Tests](../../tests/unit-tests.md#controlmodule)

## Presets

A preset is a file: `/.config/presets/<name>.json`. Saving writes one, applying reads one, deleting removes one. A preset uploaded through the File Manager appears on the grid; nothing else holds preset state, so there is no second copy to keep in step, and the list is rebuilt from the folder at startup rather than persisted alongside it.

The name becomes the file name, so it is restricted to printable ASCII without `/`, `\` or `.` — a validator on the control, which every write path runs. `slot` records which pad the preset occupies, so a surface arranged to match a physical desk survives a reboot.

### What a preset carries

A preset captures **exactly one** top-level subtree, recorded in the file:

```json
{
"slot": 12,
"captures": "Layers",
"Layers.enabled": true, "Layers.0.type": "Layer", "Layers.0.0.type": "NoiseEffect"
}
```

Each captured subtree is exactly the bytes the persistence engine already writes for that module, namespaced under a `<TypeName>.` key prefix. Save and restore therefore reuse the engine that reconciles a tree against JSON ([`saveSubtreeTo` / `applySubtree`](moxygen/FilesystemModule.md)) rather than a second serializer that could drift from it.

One subtree per preset is the whole model: a preset is *a look*, or *a geometry*, or *a hardware setup*, or *a service configuration. Never a combination. A `Layers` preset is a look, and applies to a board with completely different hardware; a `Drivers` preset carries pin maps and is device-specific. Choosing the role is a single radio button when saving, and the pad's color says which role it holds.

A preset naming a subtree this build does not have is refused with a reason rather than partially applied, and a file written by an older build that names several subtrees is listed but not applied, so it can be seen and deleted rather than silently vanishing. A malformed file leaves the live tree untouched.

### One active preset per role

Each subtree is a **role**: layout, layer, driver, service. A preset holds its own role and leaves the other three alone, so a layout preset and a look can be active at the same time, and applying a new look replaces only the look.

A pad is tinted by its role: layout blue, layer violet, driver green, service amber.

### Applying is a rebuild

Applying a preset creates, replaces and destroys modules to match what the file describes — it is a restore, not a value overlay: a preset carrying more than the device has adds it, and one describing less removes what it omits.

Structural mutation quiesces the render worker, and mutations run inline on the render tick, so a large restore stalls rendering for its duration. Every captured subtree is applied first and `prepareTree()` runs once at the end, rather than once per capture. Presets are a cold-path feature; the tick path is untouched.

## Home Assistant

Looks reach Home Assistant two ways, and only `Layers` presets travel either of them.

**The WLED integration** (`/presets.json`) is the native path: HA renders looks in its own preset dropdown, shows which one is applied, and applies one when it is chosen. This is what HA calls a preset.

**MQTT discovery** publishes the same looks as the light entity's **effect list**. HA has no preset concept over MQTT, so they arrive as effects — the same result from the user's side, reached through a different mechanism.

HA caches the preset list and re-fetches only when the device's presets-modified time (`info.fs.pmt`) changes, so that value moves whenever a preset is saved, renamed or deleted. A constant there leaves HA showing the list it read at setup forever.

Only looks are exposed, on both paths. A `Drivers` or `Layouts` preset rewires pins or geometry, which must not be reachable from something that believes it is choosing a color scheme — the restriction is enforced at the apply entry point, not merely by omitting them from the list.

Home Assistant's WLED integration connects on **port 80 only**: its host field rejects a port, so a desktop build (which defaults to 8080) needs `--port 80`, and that needs root:

```sh
sudo uv run moondeck/run/run_desktop.py --port 80
```

The discovery buffers are sized to the looks this device actually has, and grow or shrink as presets are added and removed. There is no cap on the number: a fixed one would either reserve memory a small setup never uses, or silently publish nothing once the list outgrew it.
Loading
Loading