-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathMoonModule.h
More file actions
378 lines (338 loc) · 19.3 KB
/
Copy pathMoonModule.h
File metadata and controls
378 lines (338 loc) · 19.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
#pragma once
#include "core/Control.h"
#include "platform/platform.h"
#include <cstring>
namespace mm {
// Peripheral: a module attached to SystemModule that bridges to the outside
// world — hardware or network — and is user-add/deletable (the firmware is the
// same whether or not the device has the peripheral wired). Covers both readers
// and writers: gyro/IMU + mic/line-in (in), relay/GPIO + Home Assistant push
// (out), and modules that do both. Read-vs-write is NOT a role distinction —
// direction and core affinity are per-module decisions (a peripheral may read,
// write, or both), so one role spans the category. Justified by that named
// roster, not one member (core grows slower than the domain — see CLAUDE.md).
enum class ModuleRole : uint8_t { Generic, Effect, Modifier, Driver, Layout, Layer, Peripheral };
// Lowercase role name for JSON/API output. Single source of truth so the role
// string can't drift between /api/state and /api/types.
inline const char* roleName(ModuleRole role) {
switch (role) {
case ModuleRole::Effect: return "effect";
case ModuleRole::Modifier: return "modifier";
case ModuleRole::Driver: return "driver";
case ModuleRole::Layout: return "layout";
case ModuleRole::Layer: return "layer";
case ModuleRole::Peripheral: return "peripheral";
default: return "generic";
}
}
class MoonModule {
public:
// Allocate modules in PSRAM when available (ESP32)
void* operator new(size_t size) { return platform::alloc(size); }
void operator delete(void* ptr) noexcept { platform::free(ptr); }
MoonModule() = default;
virtual ~MoonModule() { delete[] children_; }
MoonModule(const MoonModule&) = delete;
MoonModule& operator=(const MoonModule&) = delete;
MoonModule(MoonModule&&) = delete;
MoonModule& operator=(MoonModule&&) = delete;
// Default lifecycle propagates to children. Override to add container-specific logic.
//
// For loop / loop20ms / loop1s, the default ticks every child that passes the same
// enabled gate the Scheduler applies to top-level modules (!respectsEnabled() ||
// enabled() — i.e. tick when the module opted out of the gate, otherwise honour
// enabled()), and accumulates per-child timing the same way Scheduler does. Leaf
// modules (childCount_ == 0) pay one predicted-not-taken branch — sub-nanosecond.
//
// Override + chain convention for loop callbacks: parent work runs first, then
// chain to base to tick children (option A — parent prepares, children consume).
// Override + chain for setup runs the other way (chain to base first so children
// are initialised before the parent depends on them). teardown's base default
// reverse-iterates children; override and chain late so the parent shuts down its
// own state first.
virtual void setup() { for (uint8_t i = 0; i < childCount_; i++) children_[i]->setup(); }
virtual void loop() { tickChildren(&MoonModule::loop); }
virtual void loop20ms() { tickChildren(&MoonModule::loop20ms); }
virtual void loop1s() { tickChildren(&MoonModule::loop1s); }
virtual void teardown() { for (uint8_t i = childCount_; i > 0; i--) children_[i-1]->teardown(); }
// Called when enabled flips. Default no-op; override to start/stop sockets, free
// buffers, etc. The scheduler always invokes loop()/loop20ms()/loop1s() regardless
// of `enabled` — modules decide what disabled means by checking enabled() inside
// their loop fns or by stopping/starting their work in onEnabled().
virtual void onEnabled(bool /*newEnabled*/) {}
// Control-change reactions form a three-tier split (mirrors MoonLight's
// onUpdate / requestMappings / onSizeChanged; see architecture.md § Rebuild
// propagation):
//
// 1. onUpdate(name) — cheap per-control reaction, runs on EVERY change.
// Recompute a small LUT, re-bind a socket, etc.
// 2. controlChangeTriggersBuildState — gate for the pipeline-wide onBuildState() sweep.
// Only true for controls that change physical
// dimensions or mapping shape (Layout, Modifier).
// 3. onBuildState() — build the module's derived state (buffers, LUTs)
// to match current control values, reached via
// Scheduler::buildState() when tier 2 returns true.
//
// Called after a control's value is written from the UI/API. `controlName` is the
// changed control's name (stable; points into the descriptor). Default no-op.
virtual void onUpdate(const char* /*controlName*/) {}
// Whether a value change to one of this module's controls triggers the pipeline-wide
// onBuildState() sweep. Default false — most controls are values read in the hot
// path that need no realloc. Layout and Modifier override to return true (their
// controls change physical dimensions / LUT shape). Most overriders ignore the name
// and return true for every control they expose.
virtual bool controlChangeTriggersBuildState(const char* /*controlName*/) const { return false; }
// onBuildControls MUST be idempotent and pure: only `controls_.clear()` + `controls_.addX()`.
// No platform queries, no I/O, no allocations. HttpServerModule calls it again whenever a
// Select control changes the visible control set, so a second invocation must produce
// exactly the same result for unchanged inputs. Conditional branches may depend on any
// member variable.
virtual void onBuildControls() { for (uint8_t i = 0; i < childCount_; i++) children_[i]->onBuildControls(); }
// Non-virtual helper: clear-and-rebuild for this module AND its descendants. The default
// onBuildControls cascades into children, so we must also clear their control lists first;
// otherwise the recursive append would duplicate every child's controls. Used after Select
// changes (in HttpServerModule) and anywhere else the conditional control set needs
// re-evaluation.
void rebuildControls() {
clearControlsRecursive();
onBuildControls();
}
void clearControlsRecursive() {
controls_.clear();
for (uint8_t i = 0; i < childCount_; i++) children_[i]->clearControlsRecursive();
}
// Tier-3 of the control-change split (see onUpdate above): the module (re)allocates
// / recomputes whatever derived state it owns — an effect's heap, a Layer's mapping
// LUT, the Drivers output buffer. Default propagates to children. Reached via
// Scheduler::buildState() (whole-tree) when a tier-2 gate returns true.
//
// Same role as JUCE's `prepareToPlay` or UIKit's `layoutSubviews` — a framework-driven
// "set up your derived state for the current config" hook with a no-op default. The verb
// is "build" (not "rebuild") on purpose: the operation is idempotent and history-agnostic
// — it builds the correct state from current values whether or not it ran before, so boot
// and a later control change are the same call, not "build" then "rebuild". The whole
// chain shares the verb: controlChangeTriggersBuildState → Scheduler::buildState() →
// onBuildState(). Mirrors the onBuildControls precedent (build the surface vs build the
// state) and the canonical hooks (prepareToPlay/layoutSubviews never say "re" either).
//
// Intentionally coarse: each module builds its whole derived state, and the Scheduler
// sweeps the whole tree. That's fine because structural changes are rare and the builds
// are idempotent (e.g. FireEffect only reallocs when count != heatCount_). If a module
// ever grows two independently-buildable aspects where one control touches only one of
// them (e.g. `width` reshapes a LUT but `gamma` only re-tints a cache, both expensive),
// the cheapest upgrade is to forward the changed control name —
// `onBuildState(const char* changedControl)` — and branch inside. The tier-2 gate
// (controlChangeTriggersBuildState) already carries the name, so it's a one-parameter change.
// Don't add it pre-emptively; no module needs the distinction today.
virtual void onBuildState() { for (uint8_t i = 0; i < childCount_; i++) children_[i]->onBuildState(); }
const char* name() const { return name_; }
void setName(const char* n) {
if (!n) { name_[0] = 0; return; }
size_t len = std::strlen(n);
if (len >= sizeof(name_)) len = sizeof(name_) - 1;
std::memcpy(name_, n, len);
name_[len] = 0;
}
// typeName is the stable factory key (e.g. "NoiseEffect"), set once by ModuleFactory.
// Stored as `const char*` pointing at the factory's string literal — zero per-instance
// copy, lives in flash. Caller must pass a string with static lifetime (string literal
// or factory-owned storage); do not pass stack-local or temporary buffers.
// Distinct from name() which is a per-instance human label and may be overridden
// ("Noise" instead of "NoiseEffect"); typeName() stays the factory key.
const char* typeName() const { return typeName_; }
void setTypeName(const char* tn) { typeName_ = tn ? tn : ""; }
bool enabled() const { return enabled_; }
void setEnabled(bool e) {
if (enabled_ == e) return;
enabled_ = e;
onEnabled(e);
}
// Whether the Scheduler should honor `enabled()` for this module's loop callbacks.
// Default true — disabled modules don't have their loop fns called. Override to
// return false for system modules that must keep running regardless (HttpServer,
// Network, Filesystem) so the user can re-enable other modules through them.
virtual bool respectsEnabled() const { return true; }
// Dirty flag — set by HttpServerModule when a control changes. A future persistence layer
// (or any consumer interested in "this module's state has been touched") can observe it
// and clear it after handling.
bool dirty() const { return dirty_; }
void markDirty() { dirty_ = true; }
void clearDirty() { dirty_ = false; }
MoonModule* parent() const { return parent_; }
void setParent(MoonModule* p) { parent_ = p; }
// Marks this module as wired-by-code rather than wired-by-persistence. The
// FilesystemModule's applyNode trim loop preserves code-wired children even
// when the on-disk file doesn't describe them — the upgrade-day case where
// a new firmware revision adds a code-created child (e.g. ImprovProvisioning
// as a child of NetworkModule) whose existence the device's saved Network.json
// predates. Without this flag the child would get trimmed on every boot.
//
// Convention: only main.cpp's boot wiring calls markWiredByCode(). Children
// added via the HTTP add-module API or recreated by applyNode's factory call
// stay unmarked — those are user/persistence-driven and should follow the
// file's tree shape exactly.
void markWiredByCode() { wiredByCode_ = true; }
bool isWiredByCode() const { return wiredByCode_; }
ControlList& controls() { return controls_; }
const ControlList& controls() const { return controls_; }
// Role for type identification (no RTTI needed)
virtual ModuleRole role() const { return ModuleRole::Generic; }
// Curated emoji tags for the module picker's chip filter — extras beyond the
// role chip (which the UI derives from role() on its own). A short string of
// emoji, e.g. "🔥" or "🌊💧". Default "" — most modules add nothing. The
// return value is a flash string literal; no per-instance RAM cost.
virtual const char* tags() const { return ""; }
// Comma-separated role names this module accepts as user-added children
// (e.g. "effect,modifier"). "" = accepts none — the default, covering
// leaf modules and fixed-shape containers. A container overrides this to
// tell the UI's "+ add child" picker what to offer. Comma-separated
// string (not a bitmask) so it serialises straight into /api/types and a
// multi-role parent (Layer → "effect,modifier") needs no enum-set type.
// This is what makes the UI domain-neutral: it reads the accepted roles
// from here instead of hardcoding which module types are containers.
// Like tags(), overrides MUST return static-lifetime storage (a string
// literal or static const char[]): ModuleFactory::registerType<T>() probes
// the type once and stores this pointer in the static type registry, so a
// pointer to a temporary or member buffer would dangle.
virtual const char* acceptsChildRoles() const { return ""; }
// Whether the user may delete or replace this module from the UI. Default
// true — most user-added modules are freely editable. A load-bearing or
// code-wired child overrides to false (e.g. PreviewDriver, whose deletion
// would kill the live 3D preview). The flag lives on the CHILD because the
// child knows whether it's safe to remove; the parent only decides what
// can be added (acceptsChildRoles). Surfaced per-instance in /api/state.
virtual bool userEditable() const { return true; }
// Generic children — grows on demand, only allocates during setup
bool addChild(MoonModule* child) {
if (!child) return false;
if (childCount_ == childCapacity_) {
uint8_t newCap = childCapacity_ == 0 ? 4 : childCapacity_ * 2;
auto** newArr = new MoonModule*[newCap];
for (uint8_t i = 0; i < childCount_; i++) newArr[i] = children_[i];
delete[] children_;
children_ = newArr;
childCapacity_ = newCap;
}
child->setParent(this);
children_[childCount_++] = child;
return true;
}
bool removeChild(MoonModule* child) {
for (uint8_t i = 0; i < childCount_; i++) {
if (children_[i] == child) {
child->setParent(nullptr);
for (uint8_t j = i; j + 1 < childCount_; j++) children_[j] = children_[j + 1];
childCount_--;
return true;
}
}
return false;
}
// Replace child at position i with fresh. Caller owns lifecycle of the removed
// (returned) child — teardown + delete. Returns nullptr if i is out of range.
MoonModule* replaceChildAt(uint8_t i, MoonModule* fresh) {
if (i >= childCount_ || !fresh) return nullptr;
MoonModule* old = children_[i];
if (old) old->setParent(nullptr);
fresh->setParent(this);
children_[i] = fresh;
return old;
}
// Move child to absolute position newIndex (0..childCount-1). Intermediate siblings
// shift toward the vacated slot. Returns false if child isn't found, newIndex is out
// of range, or the move is a no-op (already at newIndex).
bool moveChildTo(MoonModule* child, uint8_t newIndex) {
if (newIndex >= childCount_) return false;
for (uint8_t i = 0; i < childCount_; i++) {
if (children_[i] != child) continue;
if (i == newIndex) return false; // no-op
if (newIndex > i) {
// Shift left to fill the gap
for (uint8_t j = i; j < newIndex; j++) children_[j] = children_[j + 1];
} else {
// Shift right to make room
for (uint8_t j = i; j > newIndex; j--) children_[j] = children_[j - 1];
}
children_[newIndex] = child;
return true;
}
return false;
}
uint8_t childCount() const { return childCount_; }
MoonModule* child(uint8_t i) const { return i < childCount_ ? children_[i] : nullptr; }
// Per-module memory reporting
size_t classSize() const { return classSize_ > 0 ? classSize_ : sizeof(MoonModule); }
void setClassSize(size_t s) { classSize_ = s; }
size_t dynamicBytes() const { return dynamicBytes_; }
void setDynamicBytes(size_t b) { dynamicBytes_ = b; }
// Per-module status slot. A short user-facing message the module wants the
// user to see right now — NetworkModule writes "Eth: 192.168.1.210", Layer
// writes "buffer reduced — not enough memory". The pointer is owned by the
// caller (flash literal or a module-owned char buffer); the slot doesn't
// copy. `nullptr` = nothing to show.
//
// `severity` qualifies the message so the UI can pick the right emoji:
// Status ℹ️ — neutral info, current state ("connected").
// Warning ⚠️ — silent degradation ("buffer reduced").
// Error ❌ — something failed ("WiFi auth failed").
enum class Severity : uint8_t { Status, Warning, Error };
const char* status() const { return status_; }
Severity severity() const { return severity_; }
void setStatus(const char* msg, Severity sev = Severity::Status) {
status_ = msg;
severity_ = sev;
}
void clearStatus() { status_ = nullptr; severity_ = Severity::Status; }
// Per-module timing: parents time children, Scheduler times top-level
uint32_t loopTimeUs() const { return loopTimeUs_; }
void addAccumUs(uint32_t us) { accumUs_ += us; }
// Called by Scheduler every ~1 second. Recurses into children.
void publishTiming(uint32_t frameCount) {
loopTimeUs_ = frameCount > 0 ? accumUs_ / frameCount : 0;
accumUs_ = 0;
for (uint8_t i = 0; i < childCount_; i++) {
children_[i]->publishTiming(frameCount);
}
}
protected:
ControlList controls_;
// Shared body for the loop / loop20ms / loop1s base defaults. Iterates children,
// gates each by the same rule the Scheduler applies to top-level modules
// (!respectsEnabled() || enabled() — children that opted out of the enabled
// gate keep ticking; the rest tick only when enabled), dispatches the same
// callback, and accumulates per-child timing. Pulled out so the three base
// defaults stay one-liners and the gating + timing rule lives in one place.
void tickChildren(void (MoonModule::*fn)()) {
for (uint8_t i = 0; i < childCount_; i++) {
MoonModule* c = children_[i];
if (!c->respectsEnabled() || c->enabled()) {
uint32_t start = platform::micros();
(c->*fn)();
c->addAccumUs(platform::micros() - start);
}
}
}
private:
// Display name buffer. Sized to fit the longest stripped name with headroom:
// ModuleFactory's displayNameFor strips the role-noun suffix so the longest
// names today are 13 chars ("GlowParticles", "PlasmaPalette") + null. char[16]
// leaves a few bytes of room for future modules. Names longer than this are
// truncated by setName(). 8 bytes saved per module vs the previous char[24]
// (~240 bytes total RAM on a typical tree).
char name_[16] = {};
const char* typeName_ = ""; // points into flash (factory string literal); see setTypeName comment
bool enabled_ = true;
bool dirty_ = false;
bool wiredByCode_ = false;
MoonModule* parent_ = nullptr;
MoonModule** children_ = nullptr;
uint8_t childCount_ = 0;
uint8_t childCapacity_ = 0;
size_t classSize_ = 0;
size_t dynamicBytes_ = 0;
const char* status_ = nullptr; // see status() / setStatus()
Severity severity_ = Severity::Status;
uint32_t loopTimeUs_ = 0;
uint32_t accumUs_ = 0;
};
} // namespace mm