3rd Party Plugins - xoseperez/espurna GitHub Wiki

ESPurna includes integration hooks for custom code and 3rd party modules integration.

The main concept is to allow adding new functionality to ESPurna with zero to minimal code changes to ESPurna core system (just adding the new files and integrating by setting a build flag only).

Also, please take a look at existing .cpp files in the ESPurna repository.

Files

  • code/espurna/plugin.ino - The main plugin file.

Optionally:

  • code/espurna/config/custom.h - This header file is the first one to be included in the program. This allows to override any default ESPurna flags definitions. See the comment at the top of the file for instructions on how to use it.

Status

In development phase, working (?), limited testing, new functionality is in progress. Please report any issues with [Plugins] in the subject.

Limitations

  • Due to current frontend structure we cannot easily add HTML/JS/CSS files and we must modify the existing index.html and custom.css. But, you can include a .js file if it is placed in the code/html/ directory and WebUI is rebuilt.
  • A more generic hook system that will enable a tightly coupled plugins (that will get callbacks from different ESPurna internal hook points) is under development.
  • Not all callback functions have the same execution context. For example, default configuration with async-mqtt-client will cause the callback to be called in SYS context, not the usual CONT. If such callback tries 'sleep' by using either yield() or delay(...), it will cause a crash.

Minimal configuration

Like any other ESPurna module, plugin code starts in setup() and can be registered to be periodically called in a the loop(). Main entrypoint is extraSetup() and you must place all the setup code there. When building the resulting firmware, you must add USE_EXTRA definition (either in custom.h as #define USE_EXTRA or by modifying command line flags of compiler).

Available features

setup() and loop()

  • Main entrypoint is extraSetup(). It is called at an early boot, when device is not yet connected to WiFi. For example, to detect the WiFi connection:
void extraSetup() {
    wifiRegister([](justwifi_messages_t message, char * parameter) {
        if (MESSAGE_CONNECTED == message) {
            DEBUG_MSG_P(PSTR("[PLUGIN] wifi is connected\n"));
        }
    });
}
  • To be called periodically, function can be registered by using espurnaRegisterLoop(). You can use any void function() without any paratemers.
void extraSetup() {
    espurnaRegisterLoop([]() {
        static auto last = millis();
        if (millis() - last > 1000) {
            last = millis();
            DEBUG_MSG_P(PSTR("- called every second!"));
        }
    });
}
void _someFunction() {
    DEBUG_MSG_P(PSTR("hello world"));
}
espurnaRegisterLoop(_someFunction);
  • loop() is called as soon as possible, so make sure to implement some kind of timing checks to avoid calling things too quickly.
  • espurnaRegisterLoop() should not be called multiple times with the same function. Underlying container does not check for duplicated entries and will happily add them again (and very slowly leak memory, if loop function is registered after some event)
  • When using C++ lambda, it should not have any any captured variables:
// Will not compile!
void extraSetup() {
    int somevar = getSetting("somevar").toInt();
    espurnaRegisterLoop([somevar]() {
        DEBUG_MSG_P(PSTR("[FAIL] %d\n"), somevar);
    });
}

But you can use global variables freely.

  • To register a one-shot loop function at any point of the program, use Core's schedule_function() (declared in <Schedule.h>)

Settings

  • ESPurna maintains persistent key-value storage that can be used to save and retrieve string data:
#ifndef MY_PLUGIN_ENABLED
#define MY_PLUGIN_ENABLED 1
#endif

void _pluginSetup() {
    DEBUG_MSG_P(PSTR("[PLUGIN] setup was successfully called!"));
}
void extraSetup() {
    // allow to disable the plugin by using runtime settings
    // also notice that the 2nd argument (the 'default') is a type hint for the getSetting()'s return value
    if (getSetting("myPluginIsEnabled", 1 == MY_PLUGIN_ENABLED)) {
        _pluginSetup();
    }
}
void extraSetup() {
    bool is_enabled = getSetting("myPluginIsEnabled", false); // no need to convert string to bool, getSetting does this automatically with default present
    DEBUG_MSG_P(PSTR("- my plugin is %s\n"), is_enabled ? "enabled" : "disabled");
    setSetting("myPluginIsEnabled", !is_enabled); // on the next boot plugin would toggle
}

First parameter to get or set function is the key and second one is default value.

  • To simplify access to indexed keys we also accept index as a second member of the 'key' structure:
setSetting("myIndexedKey0", "hello");
const String value_1 = getSetting({"myIndexedKey", 0});
const String value_2 = getSetting("myIndexedKey0");
if ((value_1 != value_2) || (value_1 != "hello")) {
    DEBUG_MSG("- something gone wrong! values above must be equal");
}
  • To be notifed when /config HTTP endpoint receives a new configuration or user had used reload Terminal command:
String one;
String two;

void _updateVariables() {
    one = getSetting("one");
    two = getSetting("two");
}

void extraSetup() {
    espurnaRegisterReload(_reloadVariables);
}

Terminal

  • We support a very simple Terminal interface when TERMINAL_SUPPORT == 1. terminal::CommandContext object contains argc and argv properties similar to a main() function of a C / C++ program:
#if TERMINAL_SUPPORT
    terminalRegisterCommand(F("MYIDBSEND"), [](const terminal::CommandContext& ctx) {
        if (ctx.argc != 3) {
            terminalError(F("MYIDBSEND [topic] [payload]");
            return;
        }
        ctx.output.printf_P(PSTR("[PLUGIN] called \"%s %s %s\"\n"),
            ctx.argv[0].c_str(),
            ctx.argv[1].c_str(),
            ctx.argv[1].c_str()
        );
        idbSend(ctx.argv[1].c_str(), ctx.argv[2].c_str()); // argv[0] contains "myidbsend"
    });
#endif // TERMINAL_SUPPORT == 1

Timers

  • Generic ESP8266 timers are available through the Ticker class:
#include <Ticker.h>

Ticker _timer_once;
Ticker _timer_loop;

void extraSetup() {
    _timer_once.once_ms(1000, []() {
        DEBUG_MSG_P(PSTR("[timer] tick once after 1000 ms\n"));
    });
    _timer_loop.attach_ms(1000, []() {
        DEBUG_MSG_P(PSTR("[timer] tick every 1000 ms\n"));
    });
}

ESP8266 Arduino Core uses SDK timers as a base for Ticker, meaning they will be triggered when loop() is finished or we called yield() or delay(<time>) and switched into a SYS context. Most of Arduino classes should be used while inside the callback, because they can issue another call to yield(), which will cause a crash. If you must use Core's networking libraries or call a code that uses yield() / delay() or any other generic Arduino code, consider using attach_scheduled_ms() and once_scheduled_ms().

#include <Ticker.h>
#include <ESP8266HTTPClient.h>

Ticker _timer;

void extraSetup() {
    // called every ~10 seconds
    _timer.attach_scheduled(10, []() {
        HTTPClient client;
        ... connect, write, read from the client ...
    });
}

Which is equivalent to this:

#include <Ticker.h>
#include <Schedule.h>
#include <ESP8266HTTPClient.h>

Ticker _timer;
bool _want_client = false;

void scheduled_callback() {
    _want_client = false;
    HTTPClient client;
    ... connect, write, read from the client ...
}

void timer_callback() {
    if (!_want_client) {
        _want_client = true;
        schedule_function(scheduled_callback);
    }
}

void extraSetup() {
    _timer.attach(10, scheduled);
}
  • When using millis() - last_action >= action_timeout approach, expect that the time between loop() executions can sometimes take a relatively long time (e.g., when WiFi is connecting / disconnecting / scanning, some other loop callback takes a long time to complete. At this time we do not impose time limits for any loop functions).

  • For example, to avoid running two functions at the same time when such skip occurs:

constexpr const int TICK_MS = 100;

constexpr const int FIRST_TICK_TRIGGER = 5;
constexpr const int SECOND_TICK_TRIGGER = 10;
...
espurnaRegisterLoop([]() {
    const auto ticks_max = SECOND_TICK_TRIGGER;

    static unsigned long ticks = 0;
    static auto last = millis();

    if (millis() - last >= TICK_MS) {
        last = millis();
        ++ticks;
    }

    if (ticks >= FIRST_TICK_TRIGGER) {
        doSomething();
    }

    if (ticks >= SECOND_TICK_TRIGGER) {
        doOtherWork();
    }
});

TODO: adjust for jitter caused by loop execution time

WiFi

  • WiFi is managed through JustWifi library that is working though yet another loop callback

  • As an alternative for JustWifi's event system, we can check connectivity at any point by using wifiConnected():

bool _done = false;

void extraSetup() {
    espurnaRegisterLoop([]() {
        if (_done) return;
        if (!wifiConnected()) return;
        DEBUG_MSG_P(PSTR("Job's done"));
        _done = true;
    });
}
  • To change current WiFi mode, use wifiStartSTA() and wifiStartAP().

Relays

  • Every relay function accepts relay ID as the first argument. Relay IDs start from 0.
#include <PolledTimeout.h>
#include "relay.h"

void extraSetup() {
    espurnaRegisterLoop()[]() {
        // toggle first relay every 5 seconds
        static esp8266::polledTimeout::periodicMs timeout(5000);
        if (timeout) {
            const bool status = relayStatus(0);
            DEBUG_MSG_P(PSTR("- current status of relay #0 is %s\n"),
                status ? "ON" : "OFF"
            );
            DEBUG_MSG_P(PSTR("- toggling status from %s to %s"),
                status ? "ON" : "OFF",
                status ? "OFF" : "ON"
            );
            relayToggle(0);
        }
    });
}
  • Various C preprocessor flags that are used to configure multiple entities are numbered starting from 1. However, relays (and also internal logging, MQTT & HTTP API, Terminal commands, Settings, etc.), reference each entity using zero-based numbering.

Sensors

  • Requires BROKER_SUPPORT == 1

  • Sensor readings are not supposed to be queried directly. Instead, sensor module will call the user function instead when data is available.

#include "broker.h"

void extraSetup() {
    #if BROKER_SUPPORT
        // called every `snsRead` / `SENSOR_READ_INTERVAL` - default is every 6 seconds
        SensorReportBroker::Register([](const String& topic, unsigned char id, double, const char* value) {
            DEBUG_MSG_P(PSTR("- report from %s%u => %s"), topic.c_str(), id, value);
        });
        // called every `snsReport` / `SENSOR_REPORT_EVERY` - default is every 10 readings / every 60 seconds
        SensorReadBroker::Register([](const String& topic, unsigned char id, double, const char* value) {
            DEBUG_MSG_P(PSTR("- new reading from %s%u => %s"), topic.c_str(), id, value);
        });
    #endif // BROKER_SUPPORT == 1
}
#include "broker.h"

void extraSetup() {
    SensorReadBroker::Register([](const String& topic, unsigned char id, double value, const char*) {
        if (topic.equals(FPSTR(magnitude_temperature_topic))) {
            if (value <= 25.0) {
                relayStatus(0, false);
            } else {
                relayStatus(0, true);
            }
        }
    });
}

Note: This specific action is already supported by the RPN-Rules module

MQTT

  • Requres MQTT_SUPPORT == 1 and a properly configured MQTT broker.

  • To handle incoming MQTT messages, first we need to subscribe to MQTT events by using mqttRegister() function. For example, to receive data from <root>/my_topic/set and send some data to the <root>/my_topic when device connects to the broker:

const char MY_TOPIC[] = "my_topic";

void extraSetup() {
    mqttRegister([](unsigned int type, const char* topic, char* payload) {
        switch (type) {
            case MQTT_CONNECT_EVENT:
                DEBUG_MSG_P(PSTR("- mqtt connected"));
                mqttSubscribe(MY_TOPIC);
                mqttSend(MY_TOPIC, "hello");
                break;
            case MQTT_DISCONNECT_EVENT:
                DEBUG_MSG_P(PSTR("- mqtt disconnected"));
                break;
            case MQTT_MESSAGE_EVENT:
                const String _topic(mqttMagnitude(topic));
                if (_topic.equals(MY_TOPIC)) {
                    DEBUG_MSG_P(PSTR("- incoming mqtt message for topic %s => %s\n"), topic, payload);
                }
            default:
                break;
        }
    });
}
  • When using mqttSubscribe, topic is automatically prefixed with the mqttTopic / <root> contents and suffixed with /set (to avoid filtering incoming messages from the same topic). To avoid that, we can use mqttSubscribeRaw():
mqttRegister([](unsigned int type, const char* topic, char* payload) {
    static const char raw_topic[] = "this/topic/is/not/prefixed";
    switch (type) {
        case MQTT_CONNECT_EVENT:
            mqttSubscribeRaw(raw_topic);
            break;
        case MQTT_MESSAGE_EVENT:
            const String _topic(topic);
            if (_topic.equals(raw_topic)) {
                DEBUG_MSG_P(PSTR("- incoming mqtt message for topic %s => %s\n"), _topic.c_str(), payload);
            }
        default:
            break;
    }
});

DOMOTICZ

  • Requres MQTT_SUPPORT == 1, DOMOTICZ_SUPPORT == 1 and a properly configured MQTT broker.

  • TODO: Currently, we only support sending domoticz data based on internal idx <-> module-id mappings. When using domoticzSend() on must have a previously configured settings key-value pair that is used to determine the idx value:

const char DOMOTICZ_IDX_KEY[] = "myDczIdx";

// before running this, `set myDczIdx 123` in the terminal to send data to idx=123
void _sendDomoticzData() {
    static int nvalue = 0;
    domoticzSend(DOMOTICZ_IDX_KEY, ++nvalue, "");
}
  • TODO: we can avoid public API use and directly send the raw data:
void _myDomoticzSend(unsigned int idx, unsigned int nvalue, const char* svalue) {
    char payload[128];
    snprintf_P(payload, sizeof(payload), PSTR("{\"idx\": %u, \"nvalue\": %s, \"svalue\": \"%s\"}"), idx, String(nvalue).c_str(), svalue);
    mqttSendRaw(getSetting("dczTopicIn", DOMOTICZ_IN_TOPIC).c_str(), payload); // note to be careful with c_str() usage like this, as the string object is temporary
}

Note: dczTopicIn / DOMOTICZ_IN_TOPIC are domoticz/in by default

Note: mqttSend(topic, payload) prefixes the topic parameter with the configured <root>

INFLUXDB

  • Requres INFLUXDB_SUPPORT == 1

  • Similarly to Domoticz, we use a simple topic + payload function to send the data:

bool idbSend(const char * topic, const char * payload); // will send the topic as-is
bool idbSend(const char * topic, unsigned char id, const char * payload); // would append ",id=%id" to the measument topic

void _sendIdbData() {
    idbSend("first", "12345");
    idbSend("second", 123, "67890");
}

THINGSPEAK

  • Requres THINGSPEAK_SUPPORT == 1

  • TODO: there is no public API for Thingspeak at this time

// TODO implement something similar to send some data to index 1

void tspkEnqueue(unsigned int index, const char* payload);
void _sendTspkData() {
    tspkEnqueue(1, "12345");
}

WEBUI

  • Requres WEB_SUPPORT == 1

  • TODO: there is no public API for HTML page creation

  • To send some JSON data to connected clients:

bool _some_flag = false;
void _someWebSocketFunction(JsonObject& root) {
    root["some_flag"] = _some_flag;
}

void extraSetup() {
    #if WEB_SUPPORT
        espurnaRegisterLoop([]() {
            static esp8266::polledTimeout::periodicMs timeout(5000);
            if (timeout && wsConnected()) {
                wsPost(_someWebSocketFunction);
            }
        });
    #endif // WEB_SUPPORT == 1
}
  • To process "action" request:
void _someWebSocketAction(uint32_t client_id, const char* action, JsonObject& data) {
    if (strcmp(action, "my_action") != 0) return;
    if (data.containsKey("value") && data.is<int>("value")) {
        const int value = data["value"];
        if (value) {
            DEBUG_MSG_P(PSTR("[PLUGIN] triggering something for %s => %d\n"), action, value);
            relayStatus(0, value);
        }
    }
}
void extraSetup() {
    #if WEB_SUPPORT
        wsRegister()
            .onAction(_someWebSocketAction);
    #endif
}

HTTP API

  • Requres WEB_SUPPORT == 1, API_SUPPORT == 1, and a non-empty API key.

  • To process GET and PUT HTTP API requests at /api/endpoint:

#if API_SUPPORT
    apiRegister("endpoint",
        [](ApiRequest& request) {
            DEBUG_MSG_P(PSTR("[PLUGIN] received GET request at "/api/endpoint"\n"));
            request.send(someFunction()); // String'ified contents will be sent back
        },
        [](ApiRequest& request) {
            DEBUG_MSG_P(PSTR("[PLUGIN] PUT request received form-data - value=%s)\n"), request.param(F("value")).c_str());
        }
    );
#endif // API_SUPPORT == 1

Troubleshooting:

  • Make sure to allow plugin to be disabled (see Settings example).
  • Do not abuse loop function, ensure plugin code at least somewhat throttled.
  • In case of crashes, you can decode the stack trace to find the culprit by using EspArduinoExceptionDecoder tool.

Notice: folder structures may depend on your framework and development environment, if you get compile/link error regarding existence of these files, please refer to your specific build settings documentation)


Please feel free to give any feedback / comments / suggestions!

⚠️ **GitHub.com Fallback** ⚠️