Skip to content

Свой плагин: пошагово

Сценарий: вы хотите добавить trigger «дождь» — срабатывает когда любой pulse_counter (типа счётчика дождевых импульсов с гигрометра) выдаёт > N импульсов за час.

В коробке такого триггера нет. Пишем сами.

Шаг 0. Подготовка

Клонируйте репу прошивки:

git clone https://github.com/kavlevru/ctrl-board.git
cd ctrl-board

Соберите как есть, убедитесь что зелёная:

pio run -e esp8266

Шаг 1. Создать папку

mkdir src/triggers/rain
cd src/triggers/rain

Внутри будет два файла: 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 helpersparse / 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":

{
  "enabled": ["interval", "cron", "sun", "device_state", "udp_event", "ping", "rain"]
}

Шаг 6. Собрать

pio run -e esp8266

Если зелёная — поздравляю, плагин в бинарике. Запустите ещё 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:

curl http://<ip>/api/v1/triggers | jq '.plugins[] | select(.name=="rain")'

Должен вернуть запись с 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_provided NULL-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 некорректный — посмотрите как других плагины с ним работают