diff --git a/docs/assets/core/ControlModule.png b/docs/assets/core/ControlModule.png new file mode 100644 index 00000000..4360d4fa Binary files /dev/null and b/docs/assets/core/ControlModule.png differ diff --git a/docs/history/plans/Plan-20260731 - ControlModule and presets.md b/docs/history/plans/Plan-20260731 - ControlModule and presets.md new file mode 100644 index 00000000..7451fce4 --- /dev/null +++ b/docs/history/plans/Plan-20260731 - ControlModule and presets.md @@ -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/.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 `.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/.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/.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. diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 2dd812ce..197dd221 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,18 +1,18 @@ { - "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, @@ -20,54 +20,54 @@ } }, "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 } } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 396c38d0..8007f0b9 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # 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. @@ -8,54 +8,54 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | 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 | diff --git a/docs/moonmodules/core/control.md b/docs/moonmodules/core/control.md new file mode 100644 index 00000000..c0c3dc84 --- /dev/null +++ b/docs/moonmodules/core/control.md @@ -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 + + + +### 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. + +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/.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 `.` 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. diff --git a/mkdocs.yml b/mkdocs.yml index fcc1fd4f..1425330f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,6 +135,7 @@ nav: - Supporting: moonmodules/light/supporting.md - Core: - System: moonmodules/core/system.md + - Control: moonmodules/core/control.md - Services: moonmodules/core/services.md - Supporting: moonmodules/core/supporting.md - Web UI: moonmodules/core/ui.md diff --git a/moondeck/run/run_desktop.py b/moondeck/run/run_desktop.py index 080d2743..2310a4ab 100644 --- a/moondeck/run/run_desktop.py +++ b/moondeck/run/run_desktop.py @@ -13,6 +13,7 @@ leaves the device running independently). """ +import argparse import os import platform import subprocess @@ -41,10 +42,14 @@ def _resolve_executable() -> Path: bdir / "projectMM.exe", bdir / "Release" / "projectMM.exe", bdir / "projectMM", + # A plain `cmake --build build` writes here rather than into the per-host dir, so this path + # is often the NEWER binary. Both are considered and the freshest wins below: picking the + # first that merely exists served a stale build whose changes appeared to be no-ops. + ROOT / "build" / "projectMM", ] - for c in candidates: - if c.exists(): - return c + existing = [c for c in candidates if c.exists()] + if existing: + return max(existing, key=lambda c: c.stat().st_mtime) # Return the most-likely candidate so the error message points somewhere # informative if the binary genuinely isn't there. return bdir / ("projectMM.exe" if sys.platform == "win32" else "projectMM") @@ -79,6 +84,17 @@ def _kill_running(): def main(): + ap = argparse.ArgumentParser(description="Run the desktop build in the background.") + # Ports below 1024 need root, so the default stays 8080. Port 80 exists for Home Assistant's + # WLED integration, which hardcodes port 80 and offers no way to specify another + # (`sudo uv run moondeck/run/run_desktop.py --port 80`). + ap.add_argument("--port", type=int, default=None, + help="HTTP port (default 8080; 80 needs root, for the Home Assistant WLED integration)") + args = ap.parse_args() + if args.port is not None and not (1 <= args.port <= 65535): + print(f"--port must be 1..65535, got {args.port}") + sys.exit(1) + if not EXECUTABLE.exists(): print(f"Executable not found: {EXECUTABLE}") print("Run build_desktop.py first.") @@ -116,7 +132,10 @@ def main(): else: popen_kwargs["start_new_session"] = True # own session, immune to our SIGTERM - proc = subprocess.Popen([str(EXECUTABLE)], **popen_kwargs) + cmd = [str(EXECUTABLE)] + if args.port is not None: + cmd += ["--port", str(args.port)] + proc = subprocess.Popen(cmd, **popen_kwargs) print(f"PID {proc.pid} — log: {log_path}") print("Press the Run button again to restart; the app keeps running otherwise.") diff --git a/src/core/Control.cpp b/src/core/Control.cpp index 07fab165..b7e6232b 100644 --- a/src/core/Control.cpp +++ b/src/core/Control.cpp @@ -42,6 +42,16 @@ const char* controlTypeName(ControlType t) { return "unknown"; } +bool isPersistable(const ControlDescriptor& c) { + // A List defers to its source: rows re-derived at setup are not worth writing (see + // ListSource::persistsList). Every other type answers from the type alone. + if (c.type == ControlType::List) { + auto* src = static_cast(c.ptr); + if (src && !src->persistsList()) return false; + } + return isPersistable(c.type); +} + bool isPersistable(ControlType t) { // Display-only / device-derived types: no point saving — the next // tick1s overwrites them. diff --git a/src/core/Control.h b/src/core/Control.h index 5e2eacdc..d5614f65 100644 --- a/src/core/Control.h +++ b/src/core/Control.h @@ -187,6 +187,13 @@ struct ListSource { // the control system stays generic. Returns true if it took. virtual bool restoreList(const char* /*json*/, const char* /*key*/) { return false; } + // Is this list's VALUE worth writing to flash? False for a list whose rows are re-derived at + // setup from a source that is itself already persistent — a folder of files, the live module + // tree, the pin map. Persisting such a list writes a large array on every save that the loader + // then discards (restoreList returns false), which is flash wear for nothing. + // Default true, so a list that genuinely owns its rows keeps persisting unchanged. + virtual bool persistsList() const { return true; } + // --- Editable list (the CRUD extension) ----------------------------------------- // A ListSource that supports adding / removing / reordering / editing rows. This is // the editable-data-grid primitive (the write half of the same data-source/adapter @@ -202,6 +209,31 @@ struct ListSource { // maps the result onto an HTTP status. virtual bool isEditableList() const { return false; } + // Render the rows as a GRID OF PADS rather than a stacked list: one uniform button per row, + // labelled with the row's `name`, clicking it fires the row's `activate` field. + // + // For rows that are TRIGGERED far more often than they are edited, a list is the wrong shape: it + // costs a click to expand before the action is even visible, and it hides which row is currently + // active. A pad grid is what a MIDI deck uses for the same job, and it is the same affordance + // whether the rows are a handful of named presets or a dense field of numbered channels. + // + // Deliberately domain-neutral, and a PRESENTATION hint only — the rows, their ids and the + // edit/delete/reorder ops are unchanged, so a pad list is still a list and still editable. A + // source that opts in should: + // - emit `"name"` per row (the pad label; short — a number or a word, not a sentence), + // - mark the current row `"active":true` so the UI can highlight it, + // - accept an `activate` field in setListRowField (the click). + // Everything else about the row stays as it was. + virtual bool listAsPads() const { return false; } + + // The pad grid's shape. Non-zero means a FIXED surface: the UI renders cols x rows cells and + // places each row at its own `slot`, so an empty cell is a real position rather than an absence. + // That is what separates a control surface from a list drawn in columns — pad 14 is pad 14 + // whether or not anything is in it, and deleting pad 3 does not slide pad 4 into its place. + // Zero (the default) keeps the flowing layout: pads in row order, wrapping to the card width. + virtual uint8_t listGridCols() const { return 0; } + virtual uint8_t listGridRows() const { return 0; } + // Append a new row with default values; write the new row's stable id into `outId`. // Returns false if the list is full or otherwise refuses (e.g. a read-only source). virtual bool addListRow(uint32_t& /*outId*/) { return false; } @@ -260,6 +292,11 @@ struct ControlDescriptor { // renders number-only for the same reason — a GPIO is an identity, not a // magnitude; this extends that to non-Pin numerics without the Pin type's // pin-ownership-map claim.) + // Appended AFTER the other flags and before `validate`: the two addText-family initializers + // below are positional, so this field's place in the order is load-bearing. + bool fader = false; // Render as a vertical fader (see ControlList::setFader). Presentation only. + bool encoder = false; // Render as a rotary encoder (see ControlList::setEncoder). + const char* faderTarget = nullptr; // What the fader/encoder drives ("Drivers.brightness"), or null. // Optional per-control input validator (Text/Password only; nullptr = accept anything // that fits the buffer). applyControlValue calls it on the incoming string BEFORE the // write and returns ApplyResult::Malformed on reject, so the check covers EVERY write @@ -361,7 +398,8 @@ class ControlList { void addText(const char* name, char* var, uint16_t bufSize = 16, bool (*validate)(const char*) = nullptr) { grow(); - controls_[count_++] = {var, name, 0, ControlType::Text, 0, bufSize, false, false, false, false, validate}; + controls_[count_++] = {.ptr = var, .name = name, .type = ControlType::Text, + .max = bufSize, .validate = validate}; } // Like addText but the UI renders a resizable multi-line