Архитектура ядра

IoTManager · Техническая документация
Архитектура ядра
MQTT · Веб-сервер · Движок сценариев
Версия документа 1.0 · основано на анализе ветки ver4stable
github.com/IoTManagerProject/IoTManager

1. Обзор: из чего состоит ядро

Ядро IoTManager — это неизменяемая часть прошивки, поверх которой работают модули. Оно решает четыре задачи: связь с внешним миром (Wi-Fi, MQTT), локальное управление (веб-сервер и WebSocket), исполнение пользовательской логики (движок сценариев) и оркестрацию всего этого через единый набор объектов-элементов.

Ключевая архитектурная идея: всё в системе — это «элемент» (IoTItem). Датчик, реле, таймер, переменная, лог — каждый из них является объектом с идентификатором и значением. MQTT, веб-сокеты и сценарии не общаются с модулями напрямую — они работают с элементами через общую шину событий.

Приложение iOS / Android Брокер MQTT облако / локальный Браузер веб-интерфейс ЯДРО (ESP8266 / ESP32) MQTT-клиент Веб-сервер Шина событий и команд eventBuf · orderBuf Движок сценариев Модули (IoTItems) Файловая система · Настройки · NTP

Внешний мир (зелёный) общается с ядром через MQTT-клиент и веб-сервер. Внутри всё связывает шина событий, которую питают модули и разбирает движок сценариев.

ПодсистемаОтветственностьОсновные файлы
Главный цикл coreИнициализация и поочередный вызов всех подсистемsrc/Main.cpp
ЭлементыБазовый класс всех модулей, значения, регистрация событийclasses/IoTItem.*
События/командыБуферы eventBuf / orderBuf, их обработкаEventsAndOrders.cpp
MQTT netСвязь с брокером, публикация, разбор входящих топиковMqttClient.cpp
Веб-сервер netHTTP-роуты, отдача веб-интерфейса, файлы, OTAStandWebServer.cpp, AsyncWebServer.cpp
WebSocket netДвусторонний обмен с браузером в реальном времениWsServer.cpp
СценарииЛексер, парсер и интерпретатор пользовательской логикиclasses/IoTScenario.*
ПланировщикПериодические задачи по таймеруTickerScheduler, PeriodicTasks.cpp

2. Главный цикл и планировщик

Прошивка построена на классической паре Arduino setup() / loop() (файл src/Main.cpp). Всё ядро — кооперативно-многозадачное: нет вытесняющих потоков, подсистемы просто вызываются по очереди в каждой итерации loop(). Именно поэтому в модулях нельзя использовать delay() — он остановит всех.

2.1. Порядок инициализации (setup)

При старте ядро выполняет шаги в строгом порядке; каждый помечается «маркером ошибки», чтобы в случае зависания было видно, на каком этапе это произошло:

  1. Инициализация файловой системы и вывод версии прошивки.
  2. Настройка шины I2C по сохранённым пинам.
  3. configure("/config.json")создание всех элементов из конфигурации (см. раздел 3).
  4. iotScen.loadScenario("/scenario.txt") — подготовка движка сценариев.
  5. Событие onInit — можно зацепить блок кода на самый ранний старт.
  6. Подключение к Wi-Fi роутеру, запуск веб-сервера и веб-сокетов.
  7. Первый прогон elementsLoop(), инициализация NTP и периодических задач.
  8. Событие onStart — блок кода на завершение конфигурирования.

2.2. Тело главного цикла (loop)

Каждая итерация loop() — это фиксированная последовательность вызовов подсистем:

void loop() {
    ts.update();              // 1. планировщик периодических задач
    HTTP.handleClient();      // 2. обработка HTTP-запросов
    standWebSocket.loop();    // 3. обработка WebSocket
    mqttLoop();               // 4. обработка MQTT
    elementsLoop();           // 5. опрос всех элементов + шина событий
}

Функция elementsLoop() проходит по списку всех элементов, вызывает у каждого loop() (который при наступлении интервала вызывает doByInterval()), а затем разбирает накопленные команды и события:

void elementsLoop() {
    for (auto it = IoTItems.begin(); it != IoTItems.end(); ++it)
        (*it)->loop();        // такт каждого модуля
    handleOrder();            // выполнить одну команду из orderBuf
    handleEvent();            // обработать одно событие из eventBuf
}
Защита от зависаний: на ESP32 аппаратный таймер отслеживает «ленивые» участки. Если какая-то секция loop() выполняется дольше ~400 мс, в лог выводится предупреждение с ID зависшего блока. Это диагностический инструмент против неправильно написанных модулей.

2.3. Планировщик TickerScheduler

Задачи, которые должны выполняться по времени (а не каждый такт), регистрируются в планировщике ts. Примеры из ядра: ежесекундное сохранение изменённых значений на флеш, проверка «свежести» сетевых элементов, контроль пингов WebSocket, переподключение к MQTT. Планировщик неблокирующий — он лишь проверяет, наступило ли время очередной задачи.

3. Элементы конфигурации как центр системы

Список IoTItems (стандартный std::list<IoTItem*>) — это «живая» модель устройства в оперативной памяти. Всё, что настроил пользователь, превращается в объекты этого списка.

3.1. Как рождается элемент

Настройки хранятся в /config.json как массив объектов. Функция configure() перебирает их и для каждого вызывает фабрику getAPI(subtype, params). Эта фабрика (сгенерированный файл src/modules/API.cpp) по очереди опрашивает все модули, пока один из них не опознает свой subtype и не вернёт готовый объект:

// внутри configure():
myIoTItem = (IoTItem*) getAPI(subtype, jsonArrayElement);
if (myIoTItem) IoTItems.push_back(myIoTItem);

Так конфигурация (данные) превращается в объекты (поведение). Подробно фабрика getAPI описана в отдельном документе по разработке модулей.

3.2. Значение и его распространение (regEvent)

Когда модуль получил новые данные, он вызывает regEvent(). Это единственная точка, через которую значение попадает во внешний мир. Внутри regEvent() происходит сразу несколько действий:

void IoTItem::regEvent(const String& value, ...) {
    if (_needSave) { /* сохранить на флеш */ }
    publishStatusMqtt(_id, value);   // → в приложение через MQTT
    publishStatusWs(_id, value);     // → в браузер через WebSocket
    if (genEvent) {
        generateEvent(_id, value);   // → в шину событий (для сценариев)
        if (_global) publishEvent(...); // → другим устройствам в сети
    }
}
Ключевой вывод: модуль не знает и не должен знать про MQTT, веб-сокеты или сценарии. Он просто вызывает regEvent — а ядро само доставляет значение во все каналы. Это и есть «клей» архитектуры.

3.3. Поиск и взаимодействие элементов

Элементы находят друг друга по строковому идентификатору через глобальные функции: findIoTItem(id) возвращает объект, getItemValue(id) — его значение, isItemExist(id) проверяет наличие. На этих функциях строятся межмодульные связи и сценарии.

4. Шина событий и команд

Сердце связности ядра — два строковых буфера в EventsAndOrders.cpp. Они разделяют два принципиально разных потока.

orderBuf (команды)eventBuf (события)
Смысл«Сделай прямо сейчас»«Вот что произошло, проверь в сценариях»
Источниккнопка в приложении, прямой MQTT-order, сценарийлюбой regEvent() модуля
Действиеменяет значение элемента (setValue)прогоняется через условия сценариев
Функция записиgenerateOrder(id, value)generateEvent(id, value)
Функция разбораhandleOrder()handleEvent()

Оба буфера работают как конвейер: производители дописывают в конец строки записи вида id значение,, а обработчики в каждом такте elementsLoop() снимают по одной записи с начала. Такая развязка во времени защищает от рекурсии: событие, порождённое внутри обработки другого события, просто встанет в очередь, а не вызовется немедленно.

Что делает handleEvent()

Обработка одного события состоит из двух частей: сначала событие рассылается всем модулям через хуки (onRegEvent, а также onTrackingValue для тех, кто отслеживает данный элемент), затем оно передаётся движку сценариев:

void handleEvent() {
    String eventIdName = /* id из начала eventBuf */;
    IoTItem* item = findIoTItem(eventIdName);
    if (item)
        for (auto it : IoTItems) {
            it->onRegEvent(item);                        // хук всем модулям
            if (it->isTracking(item)) it->onTrackingValue(item);
        }
    iotScen.exec(eventIdName);   // прогнать событие через сценарии
}

5. Подсистема MQTT

MQTT — основной канал связи с мобильным приложением и с другими устройствами. Реализация в MqttClient.cpp использует классический (синхронный) MQTT-клиент, который «прокачивается» вызовом mqttLoop() в каждой итерации главного цикла.

5.1. Структура топиков

Корень всех топиков устройства строится как mqttPrefix + "/" + chipId (переменная mqttRootDevice), где chipId уникален для каждой платы. От этого корня расходятся под-топики:

ТопикНаправлениеНазначение
{root}/{id}/statusустройство → мирПубликация текущего значения элемента
{root}/{id}/eventустройство → мирСобытие для сценариев других устройств (у глобальных элементов)
{root}/{id}/controlмир → устройствоКоманда от приложения (нажатие кнопки, ползунок)
{root}/{id}/orderмир → устройствоПрямая команда из внешнего MQTT
{root}/stateLWT«Завещание» — брокер публикует offline при обрыве связи

5.2. Разбор входящих сообщений

Все входящие сообщения приходят в единый колбэк mqttCallback(), который маршрутизирует их по содержимому топика:

mqttCallback() «HELLO» полное обновление «control» → generateOrder «event» → analyzeMsgFromNet «order» → generateOrder хук onMqttRecive() у всех модулей — вызывается для КАЖДОГО входящего сообщения
  • HELLO — приложение только что подключилось. Ядро публикует все виджеты и текущие значения всех локальных элементов (полная синхронизация).
  • control — команда от приложения. Извлекается id элемента и значение, вызывается generateOrder() → элемент меняет значение.
  • event — событие от другого устройства. Если разрешён приём (mqttin), оно передаётся в analyzeMsgFromNet() и может создать «сетевой» элемент для проверки в местных сценариях.
  • order — прямая команда из внешнего MQTT для интеграций.
Точка расширения для модулей: перед маршрутизацией колбэк вызывает onMqttRecive(topic, msg) у каждого модуля. Так модуль может слушать произвольные топики (например, внешний датчик по MQTT), не трогая ядро.

5.3. Публикация

Исходящие данные идут через семейство функций publish…: publishStatusMqtt() для значений, publishEvent() для межустройственных событий, publishWidgets() для описания интерфейса. Переподключение к брокеру и контроль связи вынесены в периодическую задачу планировщика; при потере Wi-Fi устройство поднимает точку доступа (AP-режим).

6. Веб-сервер: HTTP и WebSocket

Локальное управление работает даже без интернета. Оно состоит из двух частей: HTTP-сервера, который отдаёт статику и служебные эндпоинты, и WebSocket-канала для живого двустороннего обмена. В прошивке есть два взаимозаменяемых варианта реализации — синхронный (StandWebServer.cpp + WsServer.cpp) и асинхронный (AsyncWebServer.cpp), выбираемые флагами сборки.

6.1. HTTP-эндпоинты

Веб-интерфейс (собранное Svelte-приложение) хранится в файловой системе и отдаётся статикой. Помимо файлов, сервер обслуживает служебные маршруты:

МаршрутМетодНазначение
/setGETУстановить значение элемента параметрами запроса
/statusGETТекущее состояние устройства
/listGETСписок файлов в файловой системе
/editGET/PUT/POST/DELETEФайловый менеджер: чтение, создание, загрузка, удаление
/updatePOSTОбновление прошивки по воздуху (OTA)
/localotaGETЛокальное OTA-обновление

Отдача файлов проходит через handleFileRead(), которая поддерживает gzip-сжатые ресурсы (веб-интерфейс хранится сжатым для экономии флеша).

6.2. WebSocket: живой канал

WebSocket даёт браузеру то же, что MQTT даёт приложению — мгновенные обновления без опроса. Сообщения представляют собой строку с заголовком-командой в начале, обрамлённым символом |. Обработчик webSocketEvent() разбирает тип сообщения и команду:

КомандаЧто делает
/config|Отдать браузеру конфигурацию элементов (по кадрам)
/gifnoc|Принять и сохранить новую конфигурацию
/oiranecs|Принять сценарий, затем перечитать config и scenario
/tuoyal|Работа с раскладкой интерфейса (layout)
/sgnittes|Сохранить настройки устройства
/mqtt|Обновить параметры подключения к брокеру
/charts|Отправить исторические данные для графиков
Обратите внимание: некоторые команды записаны «наизнанку» (gifnoc = config задом наперёд, oiranecs = scenario). Это способ отличить «сохрани» от «отдай» для парного ресурса. Большие ответы (конфигурация) отправляются частями через sendFileToWsByFrames(), чтобы не переполнить память.

Живучесть соединения контролируется пингами: периодическая задача раз в несколько секунд проверяет активность клиентов и отключает «молчащих».

6.3. Два канала — одна модель

Важно, что MQTT и WebSocket не конфликтуют: при изменении значения regEvent() публикует его одновременно в оба канала (publishStatusMqtt и publishStatusWs). Поэтому приложение и браузер всегда показывают синхронное состояние, а управлять можно откуда угодно.

7. Движок сценариев

Сценарии — это пользовательская логика устройства: «если температура выше 25, выключи нагреватель». Движок в classes/IoTScenario.cpp — это полноценный интерпретатор небольшого языка выражений, построенный по классической схеме (лексер → парсер → дерево AST → исполнение). Архитектурно он повторяет учебный компилятор Kaleidoscope из документации LLVM.

7.1. Из чего состоит движок

  • Лексер (gettok()) — разбивает текст на токены: идентификаторы, числа, строки, операторы.
  • Парсер (семейство Parse…()) — рекурсивный спуск с учётом приоритета операторов; строит дерево выражений (ExprAST).
  • Узлы AST — наследники ExprAST с методом exec(), который возвращает IoTValue. Есть узлы для чисел, идентификаторов, бинарных операций, ветвления if/then/else, вызова функций.

Движок поддерживает три режима работы ради экономии памяти (важно для ESP8266): чтение прямо из файла посимвольно, из строки в памяти, либо однократная компиляция в дерево для максимальной скорости.

7.2. Язык сценариев

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

  • идентификаторы элементов конфигурации (их значения);
  • числа (целые, дробные, отрицательные) и строки в кавычках;
  • сравнения < > <= >= == !=, присваивание =;
  • арифметику + - * / и логику & |;
  • вызовы функций (в т. ч. функций модулей через execute);
  • блоки { }, ветвление if/then/else, комментарии после #.
# пример логики
if (temperature > 25) then {
    relay = 0                      # выключить нагреватель
    telegram.send("Перегрев!")     # вызов функции модуля
} else {
    relay = 1
}

7.3. Ключевая оптимизация: событийное исполнение

Сценарий не крутится в цикле постоянно. Он выполняется только тогда, когда произошло событие с элементом, который упомянут в условии. Это видно из связки: handleEvent() вызывает iotScen.exec(eventIdName), передавая имя сработавшего элемента. Узлы AST умеют отвечать на вопрос hasEventIdName() — участвуют ли они в текущем событии. Если ни одно условие блока не связано с событием, блок пропускается.

Почему это важно: такой подход экономит процессорное время и память. На маломощном ESP сценарий из сотни строк не нагружает систему, потому что при каждом событии реально проверяются лишь те выражения, что касаются именно этого события.

8. Сквозные сценарии передачи данных

Соберём всё вместе на трёх типичных потоках. Это лучший способ увидеть, как подсистемы работают сообща.

Поток A. Датчик обновил значение

  1. Планировщик времени внутри модуля вызывает doByInterval().
  2. Модуль читает железо и вызывает regEvent(value).
  3. regEvent публикует значение в MQTT (…/status) и в WebSocket → приложение и браузер обновляются мгновенно.
  4. regEvent кладёт событие в eventBuf.
  5. В следующем такте handleEvent() прогоняет событие через сценарии — возможно, срабатывает какая-то реакция.

Поток B. Пользователь нажал кнопку в приложении

  1. Приложение публикует в топик …/{id}/control.
  2. Брокер доставляет сообщение, срабатывает mqttCallback().
  3. Ветка «control» вызывает generateOrder(id, value) → запись в orderBuf.
  4. В такте handleOrder() находит элемент и вызывает setValue().
  5. setValue внутри снова вызывает regEvent → значение расходится по всем каналам и в сценарии (Поток A с шага 3).

Поток C. Событие с другого устройства

  1. Другой контроллер публикует своё событие в …/{id}/event.
  2. Наше устройство получает его (если включён приём mqttin).
  3. analyzeMsgFromNet() создаёт локальный «сетевой» элемент с ограниченным временем доверия.
  4. Появление этого элемента порождает событие → проверяется в местных сценариях.
  5. Если событие перестало приходить, элемент «протухает» и удаляется в elementsLoop().
Единый знаменатель: все три потока сходятся на паре regEvent / шина событий. Откуда бы ни пришли данные — от железа, приложения или сети — дальше они движутся по одному маршруту. Это и делает архитектуру предсказуемой.

9. Карта файлов ядра

ФайлЧто искать внутри
src/Main.cppsetup(), loop(), elementsLoop(), порядок инициализации
src/classes/IoTItem.cppБазовый класс элементов, regEvent(), округление значений
include/classes/IoTItem.hИнтерфейс модулей, структура IoTValue, хуки
src/EventsAndOrders.cppШина: generateOrder/Event, handleOrder/Event
src/MqttClient.cppПодключение, топики, mqttCallback(), публикация
src/StandWebServer.cppHTTP-роуты, отдача файлов, OTA
src/WsServer.cppWebSocket, разбор команд, покадровая отправка
src/classes/IoTScenario.cppЛексер, парсер, узлы AST, exec()
src/ESPConfiguration.cppconfigure(), создание элементов через getAPI
src/modules/API.cppСгенерированная фабрика getAPI() (после сборки)
src/PeriodicTasks.cppРегистрация периодических задач планировщика
Как читать код дальше: начните с Main.cpp (увидите порядок вызовов), затем IoTItem.cpp::regEvent (увидите, куда уходят данные), затем EventsAndOrders.cpp (увидите развязку). После этого MQTT, веб-сервер и сценарии читаются как «потребители» и «производители» этой шины.
IoTManager — Архитектура ядра · документ основан на анализе ветки ver4stable
Исходный код: github.com/IoTManagerProject/IoTManager

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

X