Skip to content

Module Design

doomke edited this page Jul 20, 2023 · 11 revisions

Due to the highly variable number of inputs/outputs that might be connected to a single ESP32, the software should reflect this variability and be able to adapt to changes in the setup. Additionally, the communication protocol must allow for the exchange of data and instructions in a flexible way.

This is achieved by organizing every function in modules, that can be dynamically added to the system. Each module encapsulates a single specific function or task, offering methods to control and reflect the current state of that function. Each module is independent and assumes as little prior information as possible about the outside world, the functioning of other modules or the state of itself and other modules.

graph TB
    hardware((hardware))

    subgraph ESP32[ESP32]
        direction LR
        manager(module manager)
        module[module]
        settings((settings))

        manager --- module --- settings
    end
    
    module -.- hardware
Loading

All module interactions are organized by a module manager that keeps track of modules and distributes events depending on their type. There are 2 types of events, internal events with limited content only intended for the modules on the same ESP32, and potentially external events that address other modules or the socket server.

graph TB
    subgraph ESP32
        manager(module manager)

        subgraph modules
            direction LR
            wifi(WiFi module)
            socket(socket.io module)
            input(input module)
            output(output module)
        end

        manager --> |distributes events| modules --> |issue events| manager
    end
Loading

The socket module plays a special role in this process as it is the endpoint for every outgoing or incoming communication with the server. For incoming messages it analyzes the event for valid status or command events before forwarding it to the module manager, which offers it to all other modules.

graph RL
    subgraph ESP32
        direction LR
        manager(module manager)

        subgraph modules
            direction TB
            wifi(WiFi module)
            socket(socket.io module)
            input(input module)
            output(output module)
        end
    end

    server[server]

    server --> |command/status| socket --> |forwards| manager --> |distributes| modules
Loading

For outgoing messages the sending module hands the message to the module manager, which first checks if the target module is located on the same hardware. If that is the case, the message only gets forwarded to that module. If not, it instead is handed to the socket module, which forwards it to the server.

graph LR
    subgraph ESP32
        direction LR
        manager(module manager)

        subgraph modules
            direction TB
            wifi(WiFi module)
            socket(socket.io module)
            input(input module)
            output(output module)
        end
    end

    server[server]

    input --> |event| manager --> |forward| socket --> |send| server
Loading

For example the following diagram shows the communication pathways of an ESP32 holding an input and an output module. The socket.io and wifi modules as well as the module manager are omitted for clarity.

graph TB
    subgraph hardware
    direction TB
        voltage((voltage))
        relay((relay))
    end 

    subgraph ESP32
    direction LR
        module1(input module)
        module2(output module)

        module1 <--> |internal events| module2
    end

    module1 .-> |reads| voltage
    module2 .-> |controls| relay

    subgraph serverGraph [server]
        server[socket.io]
    end

    module1 & module2 <-- command/status --> server
Loading

Adding New Modules

New modules should always build on the existing base class XRTLmodule, which implements most of the basic functions and handles the interaction with the module manager. Additionally, new class variables and methods can be defined. As bare minimum, the following methods should be defined:

class NewModule : public XRTLmodule
{
private:
    uint32_t exampleSetting;
public:
    NewModule(String moduleName);
    moduleType type = xrtl_newModule;

    void saveSettings(JsonObject &settings);
    void loadSettings(JsonObject &settings);
    void setViaSerial();

    void setup();
    void loop();
    void stop();

    void handleInternal(internvalEvent eventId, String &sourceId);
    bool handleCommand(String &controlId, JsonObject &command);
    bool getStatus(JsonObject &status);
};

Manage Settings

The modules ship with a parameter object that allows to handle basic settings. This is done by linking the individual settings to the parameter object in the constructor.

NewModule::NewModule(String moduleName)
{
    id = moduleName; // store controlId

    parameters.setKey(id); // relay id to parameter object
    parameters.add(exampleSetting, "name", "unit description"); // link the parameter
}

The parameter object can further handle saving and loading settings to and from a JsonObject. If further steps like updating the variables are needed, they need to be specified in corresponding functions.

void NewModule::saveSettings(JsonObject &settings)
{
    // update variables and handle other steps here
    // custom settings are also handled here

    parameters.save(settings); // let the parameter object handle it
}
void NewModule::loadSettings(JsonObject &settings)
{
    parameters.load(settings); // let the parameter object handle it

    // custom settings can be handled here
    // manually update settings here if needed

    if (debugging)
    {// check if serial output is requested and print parameters
        parameters.print();
    }
}

To edit parameters during operation, the setViaSerial function can be used. It gets called during the setup process and for most cases can be left to the parameter objects implemented function.

void setViaSerial()
{
    parameters.setViaSerial();
    // more complex settings need to be queried manually
}

Normal Module Operation

After the settings have been loaded to the module, normal module functions need to be defined. First step is the setup function that gets called once during the start of the hardware and handles everything related to the initialization.

void NewModule::setup()
{
    // initialize all hardware here
    // take care of everything that needs to be done _before_ normal operation
}

Next is the loop function that gets called continuously and as often as possible. The code should be non-blocking and have as little impact on the processor load as possible to allow other modules to function normally. Blocking code will cause other modules like the socket connection to fail.

void NewModule::loop()
{
    // put continous tasks in here
}

Furthermore, it is a good idea to define a stop function that allows the device to go into a safe state when a restart is imminent or an unexpected condition occurred. This function is for example called whenever the setup routine is started.

void NewModule::stop()
{
    // stop all normal operation
    // go into a safe mode and prepare for a possible restart of the device
}

Handle Module Interaction

Lastly the two main interaction pathways of the modules via the module manager need to be defined if the module is supposed to react on them. If internal events are expected to be relevant, handleInternal should be defined featuring the actions for the different eventIds. To notify other modules via internal events, the function notify can be used.

void NewModule::handleInternal(internalEvent eventId, String &sourceId)
{
    switch (eventId)
    {
        case debug_off:
        {
            debugging = false;
            break;
        }
        case debug_on:
        {
            debugging = true;
            break;
        }
        // add other relevant eventId
    }
}

For adding interaction via commands, the function handleCommand needs to be implemented. The method getValue is used for checking whether a command key is present and the value type matches the expected type. Only if this function returns true the corresponding code should be executed.

void NewModule::handleCommand(String &controlId, JsonObject &command)
{
    // only react if controlId matches or wildcard is used
    if (!isModule(controlId) && controlId != "*") return;

    bool tempBool = false;
    if (getValue("getStatus", command, tempBool) && tempBool)
    {
        sendStatus();
    }

    int64_t tempInt = 0;
    if (getValue<int64_t>("customCommandKey", command, tempInt))
    {
        // define action on the command key here
    }
}

If the module is supposed to communicate its current status to the server, getStatus needs to be defined to return true to tell the manager the status should actually be emitted. Since the necessary status messages depend on the application, the status fields need to be filled manually.

bool NewModule::getStatus(JsonObject &status)
{
    status["busy"] = false;
    status["foo"] = bar;

    return true;
}

Making the Module Available

Finally, to allow the manager to add the modules, it needs to be registered:

  1. in XRTLmodule.h add the module to the moduleType enumeration
  2. in XRTLmodule.h expand the modulNames[#] array and add the module description
  3. in XRTL.cpp increase the for loop size in settingsDialog
  4. in XRTL.cpp add a case for the new module in addModule

General

Guides

Principle of Operation

Modules

Software Hardware
camera camera
infoLED infoLED
input input
macro macro
output output
servo servo
socket socket
stepper stepper
WiFi wifi

Clone this wiki locally