Свой плагин: пошагово¶
Сценарий: вы хотите добавить trigger «дождь» — срабатывает когда любой pulse_counter (типа счётчика дождевых импульсов с гигрометра) выдаёт > N импульсов за час.
В коробке такого триггера нет. Пишем сами.
Шаг 0. Подготовка¶
Клонируйте репу прошивки:
Соберите как есть, убедитесь что зелёная:
Шаг 1. Создать папку¶
Внутри будет два файла: rain.cpp (код) и trigger.json (манифест).
Шаг 2. Манифест¶
src/triggers/rain/trigger.json:
{
"name": "rain",
"types_provided": ["rain"],
"ram_bytes_estimate": 16,
"flash_bytes_estimate": 1200,
"lib_deps": [],
"depends_on": []
}
Поля:
| Поле | Что |
|---|---|
name |
id плагина = имя папки |
types_provided |
какие type-строки этот плагин обслуживает в tasks.json |
ram_bytes_estimate |
прикидка RAM на одну инстанцию триггера |
flash_bytes_estimate |
прикидка кода в Flash |
lib_deps |
PlatformIO library deps (мерджатся в env.lib_deps) |
depends_on |
другие плагины, которые тоже должны быть enabled |
Шаг 3. Код¶
src/triggers/rain/rain.cpp:
#include <Arduino.h>
#include "../../common/devices/devices.h"
#include "../../common/tasks/tasks.h"
#include "../../common/triggers/trigger_plugin.h"
// Хранилище runtime-стейта на одну инстанцию триггера.
// Tasks.h объявляет union с фиксированным размером (16 байт под триггер),
// поэтому мы кладём данные в Trigger.rain.* (если объявлены) или в свой
// статический массив параллельно. Для простоты — массив.
struct RainState {
uint32_t window_start_ms;
uint32_t pulses_at_start;
};
static RainState g_state[TASKS_MAX] = {};
// Парсинг из JSON tasks.json при загрузке
static bool parse(JsonObject src, Trigger& t) {
t.rain.device_id = src["device_id"] | 0;
t.rain.pulses_threshold = src["pulses_threshold"] | 10;
t.rain.window_sec = src["window_sec"] | 3600;
return t.rain.device_id != 0;
}
// Сериализация обратно в JSON
static void write(const Trigger& t, JsonObject dst) {
dst["device_id"] = t.rain.device_id;
dst["pulses_threshold"] = t.rain.pulses_threshold;
dst["window_sec"] = t.rain.window_sec;
}
// Главная функция: должен ли триггер сработать СЕЙЧАС?
static bool eval(Trigger& t, const TriggerEvalCtx& ctx) {
Device* d = devicesFindById(t.rain.device_id);
if (!d) return false; // device пропал — не падать
if (strcmp(d->type, "pulse_counter") != 0) return false;
RainState& s = g_state[t.task_index];
uint32_t now = ctx.now_ms;
uint32_t cur_pulses = (uint32_t)d->last_value;
// Первый вызов — запоминаем стартовый счётчик
if (s.window_start_ms == 0) {
s.window_start_ms = now;
s.pulses_at_start = cur_pulses;
return false;
}
uint32_t elapsed = now - s.window_start_ms;
uint32_t window_ms = t.rain.window_sec * 1000UL;
if (elapsed >= window_ms) {
uint32_t delta = cur_pulses - s.pulses_at_start;
// Сдвигаем окно
s.window_start_ms = now;
s.pulses_at_start = cur_pulses;
return delta >= t.rain.pulses_threshold;
}
return false;
}
// Короткая строка для tasks-brief API (UI показывает её в списке задач)
static void build_summary(const Trigger& t, char* out, size_t cap) {
snprintf(out, cap, "rain ≥%u in %us",
(unsigned)t.rain.pulses_threshold,
(unsigned)t.rain.window_sec);
}
// UI manifest — обязательно PROGMEM на ESP8266
static const char UI_META[] PROGMEM = R"json({
"label_key": "trg.type.rain",
"fields": [
{
"name": "device_id",
"kind": "device_select",
"filter_type": "pulse_counter",
"label_key": "trg.rain_device",
"required": true
},
{
"name": "pulses_threshold",
"kind": "number",
"label_key": "trg.rain_threshold",
"min": 1, "max": 10000, "default": 10, "required": true
},
{
"name": "window_sec",
"kind": "number",
"label_key": "trg.rain_window",
"min": 60, "max": 86400, "default": 3600, "required": true
}
]
})json";
static const char* const _types[] = {"rain", nullptr};
static const TriggerPlugin _rain_plugin = {
"rain", // name
_types, // types_provided
16, // data_size
parse, write, eval, build_summary,
nullptr, // on_mesh_event
UI_META,
};
REGISTER_TRIGGER_PLUGIN(_rain_plugin);
Что важно:
- Static helpers —
parse / write / eval / build_summaryобъявленыstatic. Снаружи не видны. - State в массиве —
g_state[task_index]. Tasks.h гарантируетtask_indexуникален в[0, TASKS_MAX). - defensive checks — если device пропал между загрузкой задачи и eval'ом, не падаем — просто
return false. - UI_META в PROGMEM — критично на ESP8266.
Шаг 4. Объявить union-поля¶
Нужно прикрутить поля rain.* к union'у в tasks.h. Открываем src/common/tasks/tasks.h, находим определение Trigger:
struct Trigger {
char type[16];
uint16_t task_index;
uint32_t last_fire_ms;
union {
struct { uint32_t sec; } interval;
struct { uint8_t hour, minute; uint8_t days_mask; } cron;
struct { uint16_t offset_min; bool is_sunrise; } sun;
struct { uint16_t device_id; char compare[8]; float threshold; bool target_bool; } device;
struct { char from[33]; char event[17]; } udp;
struct { char host[64]; uint16_t port; uint32_t interval_sec; uint16_t timeout_ms; char expect[8]; } ping;
// ↓ ДОБАВЛЯЕМ
struct { uint16_t device_id; uint16_t pulses_threshold; uint32_t window_sec; } rain;
};
};
Если у вас 4 поля по 4 байта = 16 байт — это поместится в существующий union (большинство веток уже занимают 16+ байт).
Альтернатива без правки tasks.h
Если правка tasks.h нежелательна (нужно сохранить compile-compatibility с upstream'ом) — используйте свой статический массив для хранения, как с g_state выше для runtime. Тогда parse запишет в g_state, и union не нужен. Минус — состояние не сохранится через reboot, потому что tasks.json запишет только то что в union'е.
Шаг 5. Включить в сборку¶
Открываем data/config/triggers.enabled.json, добавляем "rain":
Шаг 6. Собрать¶
Если зелёная — поздравляю, плагин в бинарике. Запустите ещё smoke-test min-build (только ваш плагин):
# временно очистить *.enabled.json и оставить только rain
echo '{"enabled":["rain"]}' > data/config/triggers.enabled.json
pio run -e esp8266 -t clean
pio run -e esp8266
Должна собраться. Это базовая проверка что плагин не зависит от других неявно.
Шаг 7. Залить и проверить¶
pio run -e esp8266 -t upload
pio run -e esp8266 -t uploadfs # чтобы новый triggers.enabled.json уехал
Открываем http://<ip-платы>/, заходим в Tasks → Add → Trigger type. В dropdown'е появился новый тип «rain». Создаём задачу, выбираем pulse_counter, пороги, действие — например, реле помпы дождевой воды (выключить когда давно не было дождя).
Проверка из API:
Должен вернуть запись с manifest'ом.
Шаг 8. Локализация¶
Откройте data/public/locales/ru.json и en.json, добавьте ключи из UI_META:
// data/public/locales/ru.json
{
...
"trg.type.rain": "Дождь",
"trg.rain_device": "Датчик дождя",
"trg.rain_threshold": "Порог импульсов",
"trg.rain_window": "Окно (секунды)"
}
После uploadfs UI подхватит и тексты в dropdown'е будут на русском.
Шаг 9. PR (опционально)¶
Если плагин общеполезен — открывайте PR в ctrl-board. Чек-лист:
- Папка
src/triggers/<name>/с<name>.cpp+trigger.json -
REGISTER_TRIGGER_PLUGINв коде -
UI_METAв PROGMEM -
types_providedNULL-terminated массив -
data/config/triggers.enabled.jsonобновлён - Локализация в
data/public/locales/{ru,en}.json - Min-build (
echo '{"enabled":["<name>"]}') собирается - Описание в
RELEASES.mdв формате нового релиза
Типичные ошибки¶
| Симптом | Причина |
|---|---|
Linker error undefined reference to '_rain_plugin' |
Забыли REGISTER_TRIGGER_PLUGIN(...) |
Plugin не появляется в /api/v1/triggers |
Имя в *.enabled.json не совпадает с name в коде |
| UI пустой dropdown | UI manifest невалидный JSON — jq < UI_META для проверки |
| Heap-exhaustion на старте | UI_META без PROGMEM |
parse() возвращает true но триггер не срабатывает |
Проверьте что t.type совпадает с types_provided[0] |
Плагин собирается, но eval() не вызывается |
task_index в Trigger некорректный — посмотрите как других плагины с ним работают |