Инструкция 2 по разработке своего модуля

IoTManager · Техническая документация
Как разработать свой модуль
Пошаговое руководство для начинающих
Версия документа 1.0 · основано на анализе ветки ver4stable
github.com/IoTManagerProject/IoTManager

1. О документе

Этот документ описывает внутреннее устройство прошивки IoTManager и объясняет, как написать собственный модуль — например, драйвер нового датчика, исполнительного устройства или виртуального элемента. Материал рассчитан на начинающего разработчика: предполагается, что вы знакомы с языком C++ на базовом уровне (классы, функции) и умеете писать простые скетчи для Arduino.

Главная идея: вам не нужно понимать всю прошивку целиком. Модуль — это один самодостаточный C++ класс плюс один файл описания modinfo.json. Ядро само найдёт ваш модуль, добавит его в веб-интерфейс и будет вызывать ваш код в нужные моменты.

Что такое модуль: изолированный кусочек кода, который умеет читать один датчик, управлять одним устройством или считать одно значение. Один параметр — один класс. Если датчик выдаёт и температуру, и давление, у вас будет два класса.

1.1. Что вам понадобится

  • Установленный PlatformIO (расширение для VS Code) — среда сборки прошивки.
  • Python 3 — используется скриптом подготовки проекта PrepareProject.py.
  • Плата esp8266 или esp32, USB-кабель и сам датчик/устройство, для которого пишется модуль.
  • Готовый шаблон ExampleModule — он уже лежит в проекте и подробно прокомментирован. Это ваша стартовая точка.

2. Как устроена прошивка (кратко)

Чтобы писать модули уверенно, достаточно понять несколько ключевых понятий. Не нужно разбираться в сетевом стеке, MQTT или веб-сервере — всё это ядро берёт на себя.

2.1. Ядро и модули

Прошивка состоит из ядра (папки src/, include/) и модулей (папка src/modules/). Ядро отвечает за Wi-Fi, MQTT, веб-интерфейс, хранение настроек, выполнение сценариев и периодический опрос модулей. Модули отвечают только за конкретное «железо».

Модули разложены по категориям (это же деление вы увидите в меню веб-интерфейса):

ПапкаРаздел менюНазначениеКол-во
sensorsСенсорыЧтение датчиков (температура, давление, ток…)49
execИсполнительныеУправление устройствами (реле, ШИМ, серво, Telegram…)30
displayЭкраныВывод на дисплеи и адресные ленты12
virtualВиртуальныеРасчёты без железа (математика, таймеры, логирование…)18

2.2. Базовый класс IoTItem

Любой модуль — это класс, унаследованный от IoTItem (объявлен в include/classes/IoTItem.h). Этот базовый класс даёт вашему модулю всё необходимое: хранение значения, интервал опроса, регистрацию событий и связь с ядром.

Самые важные части, которые вы будете переопределять или использовать:

ЭлементЧто делает
Конструктор(String parameters)Аналог setup(): читает настройки из веб-интерфейса, инициализирует железо.
doByInterval()Вызывается каждые int секунд. Здесь читаете датчик и регистрируете значение.
loop()Полный аналог loop() Arduino. Переопределять только при необходимости.
regEvent(...)Сообщает ядру новое значение. Обязательный вызов.
valueСтруктура IoTValue с полями valD (число) и valS (строка) — текущее значение.
execute(cmd, param)Обрабатывает команды, вызванные из сценариев.
onModuleOrder(key, value)Обрабатывает нажатия кнопок с панели модуля.
~Деструктор()Освобождает ресурсы при переконфигурации.

2.3. Структура значения IoTValue

Всё, что модуль «отдаёт» ядру, хранится в простой структуре:

struct IoTValue {
    float  valD = 0;      // числовое значение (температура, ток...)
    String valS = "";     // строковое значение (текст, статус...)
    bool   isDecimal = true;  // true = используется valD, false = valS
};
Правило: если ваш датчик выдаёт число — пишите в value.valD. Если текст — в value.valS. Ядро само разберётся, что показывать пользователю.

3. Жизненный цикл модуля

Понимание того, когда и в каком порядке ядро вызывает ваш код, — ключ к написанию рабочего модуля. Вот полный путь от старта прошивки до получения значения:

  1. Пользователь добавляет элемент в веб-интерфейсе и заполняет поля (пин, интервал и т. д.). Настройки сохраняются как JSON.
  2. Ядро вызывает функцию getAPI_ИмяМодуля(), передавая ей subtype и строку параметров.
  3. Функция создаёт объект вашего класса — срабатывает конструктор, в котором вы читаете параметры и инициализируете железо.
  4. Каждые int секунд ядро вызывает doByInterval() — вы читаете датчик и вызываете regEvent().
  5. Ядро распространяет значение: показывает в веб-интерфейсе, шлёт в приложение через MQTT, делает доступным сценариям.
  6. При переконфигурации объект удаляется — срабатывает деструктор.
Важно про delay(): никогда не используйте delay() и долгие циклы. loop() и doByInterval() общие для всех модулей. Если задержаться — «зависнут» все остальные датчики. Длинные операции разбивайте на порции по тактам.

4. Анатомия модуля: три файла

Каждый модуль живёт в своей папке внутри нужной категории, например src/modules/sensors/МойДатчик/. Внутри — обычно три файла:

ФайлОбязателенНазначение
МойДатчик.cppДаКод модуля: классы, логика чтения, функция getAPI.
modinfo.jsonДаОписание модуля: поля настроек, автор, библиотеки, меню.
Библиотека.h / .cppНетСвоя или сторонняя библиотека, если нужна.

4.1. Файл modinfo.json — паспорт модуля

Этот файл ядро читает до компиляции. Из него строятся: пункт меню, форма настроек в веб-интерфейсе, список подключаемых библиотек и справка. Разберём его по частям на примере из ExampleModule.

Блок menuSection

"menuSection": "sensors",

Определяет, в какой раздел меню попадёт модуль. Допустимые значения: sensors, executive_devices, screens, virtual_elments.

Блок configItem — поля настроек

Массив описывает элементы, которые пользователь сможет добавить. Каждый объект — это один параметр (один класс в .cpp):

"configItem": [
  {
    "name":    "Пример датчика А",     // название в списке
    "type":    "Reading",              // тип элемента
    "subtype": "ExampleModule_A",      // связь с классом в .cpp !!!
    "id":      "Tmp",                  // идентификатор по умолчанию
    "widget":  "anydataTmp",           // виджет отображения
    "page":    "Сенсоры",              // вкладка в интерфейсе
    "descr":   "Температура",          // подпись
    "int":     15,                     // интервал опроса, сек
    "pin":     "32",                   // пользовательский параметр
    "round":   1
  }
]
Ключевая связь: поле subtype в JSON должно ТОЧНО совпадать с именем класса в .cpp и с проверкой внутри функции getAPI. Это «клей», по которому ядро находит нужный класс.

Любое поле, добавленное в configItem (например "pin": "32"), автоматически появится как поле ввода в веб-форме, и его можно будет прочитать в конструкторе через jsonReadInt(parameters, "pin").

Блок about — метаданные и справка

"about": {
  "authorName":    "Иван Иванов",
  "authorContact": "https://t.me/ivan",
  "moduleName":    "ExampleModule",   // = имя в getAPI_ !!!
  "moduleVersion": "1.0",
  "usedRam": { "esp32_4mb": 15, "esp8266_4mb": 15 },
  "title":       "Название модуля",
  "moduleDesc":  "Что делает модуль и его особенности",
  "propInfo": {                        // всплывающие подсказки к полям
    "pin": "GPIO, к которому подключён датчик",
    "int": "Секунд между опросами"
  },
  "funcInfo": [ ... ]                   // описание функций для сценариев
}
Вторая ключевая связь: moduleName в блоке about должно совпадать с именем функции getAPI_… в вашем .cpp. Скрипт сборки использует это имя, чтобы «прописать» ваш модуль в ядро.

Блоки defActive и usedLibs

"defActive": false,          // включён ли модуль по умолчанию
"usedLibs": {                // сторонние библиотеки для каждой платы
    "esp32*":  [ "milesburton/DallasTemperature @ ^4.0.4" ],
    "esp82*":  [ "milesburton/DallasTemperature @ ^4.0.4" ]
}

В usedLibs перечисляются зависимости PlatformIO. Ключи esp32* и esp82* — маски для семейств плат. Если библиотека не нужна — оставьте пустые массивы, как в шаблоне.

5. Файл .cpp — код модуля

Разберём структуру ExampleModule.cpp сверху вниз. Это готовый рабочий шаблон — вы копируете его и заменяете имена на свои.

5.1. Подключения в начале файла

#include "Global.h"              // ядро IoTM (обязательно)
#include "classes/IoTItem.h"      // базовый класс (обязательно)

#include <DallasTemperature.h>    // сторонняя библиотека (если нужна)

extern IoTGpio IoTgpio;           // объект для работы с GPIO

Первые два include — обязательны всегда. Сторонние библиотеки подключаются в угловых скобках < > и должны быть перечислены в usedLibs. Объект IoTgpio даёт кроссплатформенные методы analogRead(), digitalWrite() и т. п.

5.2. Класс модуля

Класс наследуется от IoTItem. Имя класса = subtype из JSON. Минимальный рабочий сенсор выглядит так:

class ExampleModule_A : public IoTItem {
private:
    unsigned int _pin;              // храним номер пина

public:
    // --- КОНСТРУКТОР (аналог setup) ---
    ExampleModule_A(String parameters) : IoTItem(parameters) {
        _pin = jsonReadInt(parameters, "pin");   // читаем поле "pin"
        jsonRead(parameters, "int", _interval, false);
        // здесь же: sensor.begin(), pinMode() и т. п.
    }

    // --- ЧТЕНИЕ ПО ИНТЕРВАЛУ (аналог loop) ---
    void doByInterval() {
        value.valD = analogRead(_pin) * 0.1;     // считаем значение
        regEvent(value.valD, "ExampleModule");   // отдаём ядру
    }

    // --- ДЕСТРУКТОР ---
    ~ExampleModule_A() { /* освобождаем ресурсы */ };
};

Чтение параметров из настроек

Все поля из configItem приходят в конструктор одной JSON-строкой parameters. Для чтения есть готовые функции:

ФункцияВозвращаетПример
jsonReadInt(p, "pin")intномер пина 32
jsonReadStr(p, "id")Stringидентификатор
jsonReadBool(p, "flag")booltrue / false
jsonRead(p, "int", var, false)в переменнуюинтервал, множитель…

5.3. Функция-мост getAPI

В самом конце файла — функция, через которую ядро создаёт объекты вашего класса. Её имя должно быть getAPI_ + moduleName из JSON:

void *getAPI_ExampleModule(String subtype, String param) {
    if (subtype == "ExampleModule_A") {
        return new ExampleModule_A(param);
    }
    else if (subtype == "ExampleModule_B") {
        return new ExampleModule_B(param);
    }
    else {
        return nullptr;   // этот subtype не наш
    }
}

Если у модуля несколько параметров (несколько классов), перечислите их все через else if. Каждый subtype в проверке должен совпадать с subtype в modinfo.json.

6. Дополнительные возможности

Базового сенсора достаточно для чтения данных. Но ядро позволяет модулю делать больше — реагировать на кнопки, участвовать в сценариях, читать значения других модулей.

6.1. Кнопки на панели модуля (onModuleOrder)

Чтобы у модуля появилась кнопка, добавьте в configItem поле "btn-ИмяКнопки", а в классе переопределите:

void onModuleOrder(String &key, String &value) {
    if (key == "Example") {                 // имя из "btn-Example"
        SerialPrint("i", "MyModule", "Нажата кнопка: " + value);
        // выполняем калибровку, сброс и т. п.
    }
}

6.2. Команды из сценариев (execute)

Сценарии IoTManager могут вызывать функции вашего модуля. Для этого переопределите execute() и опишите функции в блоке funcInfo файла JSON:

IoTValue execute(String command, std::vector<IoTValue> &param) {
    if (command == "myFunc") {
        if (param.size() >= 1) {
            String otherId = param[0].valS;
            String v = getItemValue(otherId);   // значение другого модуля
            // делаем что-то полезное
        }
    }
    return {};   // возвращаем пустое значение или результат
}

Аргументы приходят в векторе param; каждый — это IoTValue с полями valS и valD. Функция getItemValue(id) читает текущее значение любого другого элемента по его идентификатору.

6.3. Свой loop() и общие библиотеки

Если нужен непрерывный опрос (не по интервалу), переопределите loop(). Обязательно вызовите в конце IoTItem::loop(), иначе перестанет работать doByInterval():

void loop() {
    adc = IoTgpio.analogRead(_pin);   // ваша непрерывная логика
    IoTItem::loop();                  // ОБЯЗАТЕЛЬНО в конце
}

Когда несколько экземпляров используют один физический интерфейс (I2C, шину), заведите общий объект библиотеки через функцию-инициализатор, как показано в шаблоне (instanceLibXX(pin)). В деструкторе такой объект удаляют, чтобы при переконфигурации обновились параметры.

7. Практика: создаём модуль с нуля

Соберём всё вместе. Создадим простой аналоговый датчик освещённости на фоторезисторе, подключённом к аналоговому пину.

Шаг 1. Скопировать шаблон

Скопируйте папку src/modules/sensors/ExampleModule в новую src/modules/sensors/LightSensor. Переименуйте файл .cpp в LightSensor.cpp.

Шаг 2. Заполнить modinfo.json

{
  "menuSection": "sensors",
  "configItem": [{
    "name": "Датчик освещённости",
    "type": "Reading",
    "subtype": "LightSensor",
    "id": "Light",
    "widget": "anydataLux",
    "page": "Сенсоры",
    "descr": "Освещённость",
    "int": 10,
    "pin": "34",
    "round": 1
  }],
  "about": {
    "authorName": "Ваше имя",
    "moduleName": "LightSensor",
    "moduleVersion": "1.0",
    "usedRam": { "esp32_4mb": 5, "esp8266_4mb": 5 },
    "title": "Датчик света",
    "moduleDesc": "Читает фоторезистор с аналогового пина",
    "propInfo": {
      "pin": "Аналоговый GPIO",
      "int": "Секунд между опросами"
    }
  },
  "defActive": false,
  "usedLibs": { "esp32*": [], "esp82*": [] }
}

Шаг 3. Написать LightSensor.cpp

#include "Global.h"
#include "classes/IoTItem.h"

extern IoTGpio IoTgpio;

class LightSensor : public IoTItem {
private:
    unsigned int _pin;
public:
    LightSensor(String parameters) : IoTItem(parameters) {
        _pin = jsonReadInt(parameters, "pin");
    }
    void doByInterval() {
        int raw = IoTgpio.analogRead(_pin);   // 0..4095
        value.valD = raw / 40.95;             // грубо в проценты
        regEvent(value.valD, "LightSensor");
    }
    ~LightSensor() {};
};

void *getAPI_LightSensor(String subtype, String param) {
    if (subtype == "LightSensor") {
        return new LightSensor(param);
    }
    return nullptr;
}

Шаг 4. Подготовить проект

Запустите скрипт подготовки — он найдёт новый модуль и обновит списки:

python PrepareProject.py --update

Затем включите модуль в вашем профиле (myProfile.json) или через веб-конфигуратор и снова выполните PrepareProject.py для нужной платы, например:

python PrepareProject.py -b esp32_4mb

Шаг 5. Скомпилировать и прошить

Соберите и загрузите прошивку через PlatformIO (кнопка Upload или pio run -t upload). После перезагрузки новый датчик появится в списке элементов веб-интерфейса.

7.1. Что делает PrepareProject.py «под капотом»

Понимание этого шага помогает отлаживать проблемы «модуль не появился»:

  1. Сканирует src/modules/ в поисках всех modinfo.json.
  2. Для активных модулей собирает список библиотек и пути и записывает их в platformio.ini.
  3. Генерирует src/modules/API.cpp — файл, который вызывает все getAPI_… функции по очереди.
  4. Формирует data_svelte/items.json — список элементов для меню веб-интерфейса.
Отсюда правило: после добавления нового модуля обязательно запустите PrepareProject.py. Без этого шага ядро не узнает о вашей функции getAPI, и модуль не соберётся.

8. Чек-лист и типичные ошибки

8.1. Контрольный список перед сборкой

  • Имя папки, файла .cpp и moduleName — согласованы.
  • Имя функции getAPI_X = getAPI_ + moduleName.
  • Каждый subtype в modinfo.json имеет свой класс и ветку в getAPI.
  • В doByInterval() есть хотя бы один regEvent().
  • Все сторонние библиотеки перечислены в usedLibs.
  • Нет delay() и долгих циклов.
  • Запущен PrepareProject.py после изменений.

8.2. Частые проблемы

СимптомВероятная причинаРешение
Модуль не появился в менюне запущен PrepareProject / defActive=falseзапустить скрипт, включить модуль
Ошибка линковки getAPImoduleName ≠ имя функциипривести имена к единому виду
Значение всегда 0 / пустоенет regEvent или не тот пинпроверить doByInterval и параметр pin
Виснут другие датчикиdelay или долгий цикл в модулеубрать задержки, разбить на такты
Библиотека не найденане указана в usedLibsдобавить зависимость для нужной платы
Старые параметры после смены пинаобщий объект не пересозданудалять объект библиотеки в деструкторе

9. Справочник

9.1. Полезные методы IoTItem

МетодНазначение
regEvent(val, info)зарегистрировать новое значение (число или строка)
getItemValue(id)получить значение другого элемента по id
findIoTItem(name)найти объект другого модуля
isItemExist(name)проверить, существует ли элемент
getID()получить идентификатор текущего элемента
getSubtype()получить subtype текущего элемента
setInterval(ms)изменить интервал опроса
SerialPrint("i", tag, msg)вывод в лог/консоль

9.2. Поддерживаемые платы (профили сборки)

Задаются параметром -b при запуске PrepareProject.py. Основные:

ПрофильПлатформа
esp8266_4mb / esp8266_16mbESP8266 с 4/16 МБ флеш
esp8266_1mb / esp8285_1mbкомпактные ESP8266/8285
esp32_4mb / esp32_16mbESP32 классический
esp32cam_4mbESP32-CAM (с камерой)
esp32s2_4mb / esp32c3m_4mbESP32-S2 / C3
esp32s3_16mb / esp32c6_4mbESP32-S3 / C6

9.3. Полезные ресурсы

  • Репозиторий проекта: github.com/IoTManagerProject/IoTManager
  • Готовые модули как примеры: папки src/modules/sensors/ — изучайте Ntc, Ds18b20, AnalogAdc.
  • Шаблон-заготовка: src/modules/sensors/ExampleModule/ — подробно прокомментирован.
  • Учебные примеры очередей и JSON: папка training/.
Главный совет: лучший способ научиться — открыть готовый простой модуль (например Ntc) рядом с ExampleModule и сравнить. Почти любой новый датчик — это вариация уже написанного.
IoTManager — Руководство по разработке модуля · основано на анализе ветки ver4stable
Исходный код: github.com/IoTManagerProject/IoTManager

Поддержал проект — спас молодого самодельщика! А мы принимаем подарки...

X