-
Notifications
You must be signed in to change notification settings - Fork 636
3rd Party Plugins
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.
- 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.
In development phase, working (?), limited testing, new functionality is in progress.
Please report any issues with [Plugins]
in the subject.
- 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()
ordelay(...)
, it will cause a crash.
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).
- When building with PlatformIO, you can use
src_build_flags = -DUSE_EXTRA
in the[env:...]
section. Or, when included in the global[env]
section, build flag would apply to every environment.PLATFORMIO_BUILD_FLAGS
andPLATFORMIO_SRC_BUILD_FLAGS
environment variables can also be used. - Just like any other configuration option, adding
#define USE_EXTRA
in thecustom.h
and having-DUSE_CUSTOM_H
in build flags would also enable the plugin code.
- 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 anyvoid 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>
)
- 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 usedreload
Terminal command:
String one;
String two;
void _updateVariables() {
one = getSetting("one");
two = getSetting("two");
}
void extraSetup() {
espurnaRegisterReload(_reloadVariables);
}
- We support a very simple Terminal interface when
TERMINAL_SUPPORT == 1
.terminal::CommandContext
object containsargc
andargv
properties similar to amain()
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
- 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 betweenloop()
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 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()
andwifiStartAP()
.
- 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.
-
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
}
-
topic
contains the magnitude name. Check which magnitude topics are reported by the device through MQTT or seemagnitudes
output in the Terminal. -
At the time of writing this, each magnitude topic is stored as globally accessible
PROGMEM
variable: https://github.com/xoseperez/espurna/blob/37763f1ad42b77008c0a13aa460db87ceb81345c/code/espurna/config/progmem.h#L305-L336 -
For example, to turn off the Relay0 when temperature value is less than 25.0C:
#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
-
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 themqttTopic
/<root>
contents and suffixed with/set
(to avoid filtering incoming messages from the same topic). To avoid that, we can usemqttSubscribeRaw()
:
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;
}
});
-
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 theidx
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
aredomoticz/in
by default
Note:
mqttSend(topic, payload)
prefixes the topic parameter with the configured<root>
-
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");
}
-
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");
}
-
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
}
-
Requres
WEB_SUPPORT == 1
,API_SUPPORT == 1
, and a non-empty API key. -
To process
GET
andPUT
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
- 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!
If you're looking for support:
- Issues: this is the most dynamic channel at the moment, you might find an answer to your question by searching open or closed issues.
- Wiki pages: might not be as up-to-date as we all would like (hey, you can also contribute in the documentation!).
- Gitter channel: you have better chances to get fast answers from project contributors or other ESPurna users. (also available with any Matrix client!)
- Issue a question: as a last resort, you can open new question issue on GitHub. Just remember: the more info you provide the more chances you'll have to get an accurate answer.
- Backup the stock firmware
- Flash a pre-built binary image
- Flash a virgin Itead Sonoff device without opening
- Flash TUYA-based device without opening
- Flash Shelly device without opening
- Using PlatformIO
- from Visual Studio Code
- Using Arduino IDE
- Build the Web Interface
- Over-the-air updates
- Two-step updates
- ESPurna OTA Manager
- NoFUSS
- Troubleshooting
- MQTT
- REST API
- Domoticz
- Home Assistant
- InfluxDB
- Prometheus metrics
- Thingspeak
- Alexa
- Google Home
- Architecture
- 3rd Party Plugins
- Coding style
- Pull Requests