FilesystemModule
Source:
FilesystemModule.h
FilesystemModule¶
src/core/FilesystemModule.h:81Inherits:
MoonModule
Control-list-driven JSON persistence — writes control values to flash so settings survive a reboot.
Always loaded, runs first in the scheduler so its load hook fires before any other module's [setup()]
Storage: one flat JSON file per top-level MoonModule under /.config/<TypeName>.json (the filename is MoonModule::typeName()). Children are encoded positionally with an <index>. key prefix — no nested objects, no arrays, loaded with the cheap first-match key helpers (parseString/Int/Bool). A control whose value is structured (a List control's array of objects) is read back with the recursive reader in core/JsonUtil.h via the control's own restore hook: the file's top level stays flat, the structure lives inside one control's value string. ReadOnly and Progress controls are never persisted — they are derived values, not state.
Structural reconciliation. The type field per child drives reconciliation at load time: when the JSON describes a child type at position N that differs from the live tree's child at N, the loader factory-creates the JSON type, runs its defineControls(), and swaps it into place. Children present in the live tree but missing from the JSON are torn down; children in the JSON beyond the live tree's end are appended. Phases 3+4 cascade into the reconciled tree, so newly-created children are fully initialised like any other.
Boot flow (Scheduler::setup, four phases):
-
phase 1
defineControls()— every module binds its full control set (incl. hidden) -
phase 2
loadAllHook— this module reads each file and overlays bound variables -
phase 2b
rebuildControls()— re-runs defineControls so conditional hidden flags see the persisted values -
phase 3
[setup()]— modules' own init runs with persisted values in members -
phase 4
prepare()— buffers sized to final values
The Scheduler exposes setLoadAllHook() as a function pointer so it stays independent of this module's type (no circular include); the hook is wired from setScheduler().
Save flow.HttpServerModule calls markDirty() + noteDirty() on every successful mutation — control changes AND tree-shape changes (add / delete / move a module marks the parent dirty so its file is rewritten with the new child set). noteDirty() stamps lastDirtyMs_; tick1s() waits DEBOUNCE_MS (2 s) after the last dirty mark, then walks the tree and serialises any subtree with a dirty descendant to a flat JSON blob, written atomically (write to .tmp, then rename). A subtree's dirty flag clears only after its write succeeds; a failed write leaves it set so tick1s() retries. flushPending() forces all dirty subtrees through synchronously (the reboot handler calls it so an add-then-reboot doesn't lose the change); losing power before the debounce expires loses the in-flight change — the cost of debouncing for fewer flash writes.
Conditional visibility. Modules with conditional controls bind their full control set unconditionally and toggle a hidden flag per descriptor, so the persistence layer can find and overlay a value regardless of the live conditional state while the UI honours the flag. A Select change at runtime triggers rebuildControls() to re-evaluate the flags.
Platform layer. Filesystem access goes through platform::fs* (mount, mkdir, read, atomic write-then-rename, used/total). ESP32 uses LittleFS on a dedicated partition; desktop uses std::filesystem rooted at build/ (overridable via fsSetRoot for test isolation). Save and load are both cap-free: the save serializes into a growable JsonSink (heap, no ceiling) and the load reads the whole file into a heap buffer sized to it — a config of any size (many light presets, a wide fixture) round-trips in full.
First boot: no files exist → load is a no-op, modules run with default member-initialised values; after the first UI change the debounce creates the file. A missing key keeps the default, an unknown key is silently ignored (no schema migration).

Public Methods¶
FilesystemModule() = default
: Singleton is registered in setScheduler() (called by main.cpp on the real FilesystemModule), NOT in the constructor.
virtual inline bool respectsEnabled() const override
: Persistence must keep flushing dirty subtrees regardless of the enabled toggle — otherwise the user could lose changes by accidentally disabling this module via the UI before the 2s debounce expires.
virtual inline bool appearsInUi() const override
: Non-UI: a pure persistence engine with no controls — not shown as a card (its "last saved" status is displayed by FileManagerModule).
void setScheduler(Scheduler * s)
virtual void setup() override
: Default lifecycle propagates to children.
virtual void tick1s() override
inline char * lastSavedStr()
: The engine's live "last saved" buffer — "never" before the first save, else "5m ago".
void flush()
: Synchronous save of every dirty subtree, bypassing the debounce.
Public Static Attributes¶
constexprconstchar * CONFIG_DIR = "/.config"
constexprsize_t MAX_PATH = 64
constexprsize_t MAX_KEY = 48
constexpruint32_t DEBOUNCE_MS = 2000
Public Static Methods¶
static inline FilesystemModule * instance()
: The live singleton (the main-wired instance registered in setScheduler), or null before boot wiring.
static void flushPending()
: Static convenience for callers (such as the reboot handler) that need to force any debounced saves through before a release — mirrors noteDirty's call style.
static void noteDirty()
: Called by HttpServerModule on every successful control mutation so the 2s debounce starts.