1. Обзор: из чего состоит ядро
Ядро IoTManager — это неизменяемая часть прошивки, поверх которой работают модули. Оно решает четыре задачи: связь с внешним миром (Wi-Fi, MQTT), локальное управление (веб-сервер и WebSocket), исполнение пользовательской логики (движок сценариев) и оркестрацию всего этого через единый набор объектов-элементов.
Ключевая архитектурная идея: всё в системе — это «элемент» (IoTItem). Датчик, реле, таймер, переменная, лог — каждый из них является объектом с идентификатором и значением. MQTT, веб-сокеты и сценарии не общаются с модулями напрямую — они работают с элементами через общую шину событий.
Внешний мир (зелёный) общается с ядром через MQTT-клиент и веб-сервер. Внутри всё связывает шина событий, которую питают модули и разбирает движок сценариев.
| Подсистема | Ответственность | Основные файлы |
|---|---|---|
| Главный цикл core | Инициализация и поочередный вызов всех подсистем | src/Main.cpp |
| Элементы | Базовый класс всех модулей, значения, регистрация событий | classes/IoTItem.* |
| События/команды | Буферы eventBuf / orderBuf, их обработка | EventsAndOrders.cpp |
| MQTT net | Связь с брокером, публикация, разбор входящих топиков | MqttClient.cpp |
| Веб-сервер net | HTTP-роуты, отдача веб-интерфейса, файлы, OTA | StandWebServer.cpp, AsyncWebServer.cpp |
| WebSocket net | Двусторонний обмен с браузером в реальном времени | WsServer.cpp |
| Сценарии | Лексер, парсер и интерпретатор пользовательской логики | classes/IoTScenario.* |
| Планировщик | Периодические задачи по таймеру | TickerScheduler, PeriodicTasks.cpp |
2. Главный цикл и планировщик
Прошивка построена на классической паре Arduino setup() / loop() (файл src/Main.cpp). Всё ядро — кооперативно-многозадачное: нет вытесняющих потоков, подсистемы просто вызываются по очереди в каждой итерации loop(). Именно поэтому в модулях нельзя использовать delay() — он остановит всех.
2.1. Порядок инициализации (setup)
При старте ядро выполняет шаги в строгом порядке; каждый помечается «маркером ошибки», чтобы в случае зависания было видно, на каком этапе это произошло:
- Инициализация файловой системы и вывод версии прошивки.
- Настройка шины I2C по сохранённым пинам.
configure("/config.json")— создание всех элементов из конфигурации (см. раздел 3).iotScen.loadScenario("/scenario.txt")— подготовка движка сценариев.- Событие
onInit— можно зацепить блок кода на самый ранний старт. - Подключение к Wi-Fi роутеру, запуск веб-сервера и веб-сокетов.
- Первый прогон
elementsLoop(), инициализация NTP и периодических задач. - Событие
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
}
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(...); // → другим устройствам в сети
}
}
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}/state | LWT | «Завещание» — брокер публикует offline при обрыве связи |
5.2. Разбор входящих сообщений
Все входящие сообщения приходят в единый колбэк mqttCallback(), который маршрутизирует их по содержимому топика:
- 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-приложение) хранится в файловой системе и отдаётся статикой. Помимо файлов, сервер обслуживает служебные маршруты:
| Маршрут | Метод | Назначение |
|---|---|---|
/set | GET | Установить значение элемента параметрами запроса |
/status | GET | Текущее состояние устройства |
/list | GET | Список файлов в файловой системе |
/edit | GET/PUT/POST/DELETE | Файловый менеджер: чтение, создание, загрузка, удаление |
/update | POST | Обновление прошивки по воздуху (OTA) |
/localota | GET | Локальное 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() — участвуют ли они в текущем событии. Если ни одно условие блока не связано с событием, блок пропускается.
8. Сквозные сценарии передачи данных
Соберём всё вместе на трёх типичных потоках. Это лучший способ увидеть, как подсистемы работают сообща.
Поток A. Датчик обновил значение
- Планировщик времени внутри модуля вызывает
doByInterval(). - Модуль читает железо и вызывает
regEvent(value). regEventпубликует значение в MQTT (…/status) и в WebSocket → приложение и браузер обновляются мгновенно.regEventкладёт событие вeventBuf.- В следующем такте
handleEvent()прогоняет событие через сценарии — возможно, срабатывает какая-то реакция.
Поток B. Пользователь нажал кнопку в приложении
- Приложение публикует в топик
…/{id}/control. - Брокер доставляет сообщение, срабатывает
mqttCallback(). - Ветка «control» вызывает
generateOrder(id, value)→ запись вorderBuf. - В такте
handleOrder()находит элемент и вызываетsetValue(). setValueвнутри снова вызываетregEvent→ значение расходится по всем каналам и в сценарии (Поток A с шага 3).
Поток C. Событие с другого устройства
- Другой контроллер публикует своё событие в
…/{id}/event. - Наше устройство получает его (если включён приём
mqttin). analyzeMsgFromNet()создаёт локальный «сетевой» элемент с ограниченным временем доверия.- Появление этого элемента порождает событие → проверяется в местных сценариях.
- Если событие перестало приходить, элемент «протухает» и удаляется в
elementsLoop().
regEvent / шина событий. Откуда бы ни пришли данные — от железа, приложения или сети — дальше они движутся по одному маршруту. Это и делает архитектуру предсказуемой.9. Карта файлов ядра
| Файл | Что искать внутри |
|---|---|
src/Main.cpp | setup(), 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.cpp | HTTP-роуты, отдача файлов, OTA |
src/WsServer.cpp | WebSocket, разбор команд, покадровая отправка |
src/classes/IoTScenario.cpp | Лексер, парсер, узлы AST, exec() |
src/ESPConfiguration.cpp | configure(), создание элементов через getAPI |
src/modules/API.cpp | Сгенерированная фабрика getAPI() (после сборки) |
src/PeriodicTasks.cpp | Регистрация периодических задач планировщика |
Main.cpp (увидите порядок вызовов), затем IoTItem.cpp::regEvent (увидите, куда уходят данные), затем EventsAndOrders.cpp (увидите развязку). После этого MQTT, веб-сервер и сценарии читаются как «потребители» и «производители» этой шины.ver4stableИсходный код: github.com/IoTManagerProject/IoTManager