Skip to content

StatefulModule

Base class for all application modules. Extends Module with four virtual methods that the ModuleManager calls to inject configuration, wire data-flow inputs, and persist state. Introduced in phase 7.


What it does

StatefulModule adds no instance data to Modulesizeof(StatefulModule) == sizeof(Module). It defines the contract the ModuleManager uses to drive the full module lifecycle without any module knowing about the ModuleManager.

All four methods have default no-op implementations, so a module only overrides the ones it needs.


Interface

Method Called by When Purpose
setProps(JsonObjectConst) ModuleManager After construction, before setup() Inject construction-time parameters from the "props" field in state/modulemanager.json
setInput(const char* key, Module*) ModuleManager After setProps(), before setup() Wire a named data-flow input to another module
loadState(JsonObjectConst) ModuleManager After setInput(), before setup() Restore persisted controls from state/<id>.json
saveState(JsonObject) const ModuleManager On teardown() and end of setup() Serialise current controls to state/<id>.json

The full lifecycle order is: construction → setProps()setInput()loadState()setup()loop() × N → teardown() / saveState().


Writing a module

A module that has one prop, one data-flow input, and one persisted control looks like:

class MyModule : public StatefulModule {
public:
    MyModule() = default;  // required: default-constructible for ModuleManager

    void setProps(JsonObjectConst props) override {
        if (props["width"].is<uint16_t>()) width_ = props["width"];
    }

    void setInput(const char* key, Module* src) override {
        if (strcmp(key, "layer") == 0)
            layer_ = static_cast<EffectsLayer*>(src);
    }

    void loadState(JsonObjectConst state) override {
        if (state["speed"].is<float>()) speed_ = state["speed"];
    }

    void saveState(JsonObject state) const override {
        state["speed"] = speed_;
    }

    // ... setup() / loop() / teardown() as usual
private:
    uint16_t        width_ = 0;
    EffectsLayer*  layer_ = nullptr;
    float           speed_ = 1.0f;
};

Then register it so the ModuleManager can instantiate it by name:

// src/modules/ModuleRegistrations.cpp
REGISTER_MODULE(MyModule)

Add it at runtime via the REST API (persists automatically to state/modulemanager.json):

curl -X POST http://localhost:80/api/modules \
  -H 'Content-Type: application/json' \
  -d '{"type":"MyModule","id":"my1","props":{"width":16},"inputs":{"layer":"producer1"}}'

Or use the frontend UI's + add child button on any parent card.


Rules

  • Default-constructible is mandatory. The ModuleManager calls the zero-argument constructor; configuration comes through setProps() and setInput(), not the constructor.
  • setProps and setInput are called once per setup() cycle, before setup(). Do not allocate resources here — that belongs in setup().
  • loadState may override values set by setProps. Both can set the same field; loadState wins (persisted user choice beats config default).
  • saveState must be const. It serialises the current in-memory state; it does not write any file directly.

Source

Test coverage

Stateful Module — progress/button/string control types, sensitive fields, disableStatePersistence, integer clamping, meta() store.

Controls and KV Store — addControl, setControl (all types), onUpdate, getSchema, sensitive value exclusion.