Шаг 2: Реализация state machine и журнала событий
---
---
Введение: от графа состояний к коду
ведение: от графа состояний к коду
На предыдущих занятиях мы изучили теоретические основы конечных автоматов, спроектировав граф состояний для нашего проекта. Эта визуальная модель позволила нам определить все возможные режимы работы объекта (например, "Дома", "Отсутствие", "Ночь", "Тревога") и правила, по которым система должна переключаться между ними. Теперь наша задача — перевести эту абстрактную схему в работающий, надежный и отлаживаемый программный код на платформе Node-RED.
> 🔗 Связанный материал: Для полного понимания контекста этого урока необходимо изучить материалы предыдущих занятий: Введение в конечные автоматы (FSM) и Масштабирование логики с помощью Subflows, где мы заложили теоретическую базу и рассмотрели инструменты для ее реализации.
От концепции к логике переключений
Центральной идеей, которая позволит нам реализовать спроектированный граф, является конечный автомат (или State Machine). Это модель, описывающая систему, способную находиться в одном из конечного числа состояний в любой момент времени. Переход из одного состояния в другое происходит в ответ на внешнее или внутреннее событие.
Давайте формализуем ключевые понятия, с которыми будем работать:
📋 Ключевые понятия:
- Состояние (State): Конкретный режим работы системы. В нашем проекте это может быть `'home.day'`, `'away'`, `'security.armed'`. Система может находиться только в одном состоянии одновременно.
- Событие (Event): Триггер, инициирующий попытку перехода (сигнал датчика, команда пользователя).
- Переход (Transition): Смена одного состояния на другое по заданным правилам. Например, запрет перехода из `'away'` сразу в `'night'` без авторизации.
- Действие (Action): Полезная работа (выключение света, изменение температуры), выполняемая при активации состояния.
Почему именно конечный автомат?
Использование конечного автомата — это проверенный инженерный паттерн, который предотвращает конфликты между сценариями. Вместо хаотичного набора триггеров мы получаем единый центр принятия решений.
> ⚠️ Важное замечание по архитектуре:
> Для реализации State Machine в рамках всего дома мы будем использовать global context. В отличие от flow context, который изолирован внутри одной вкладки, хранение состояния в `global` позволяет обращаться к текущему режиму дома (например, `global.get("home_state")`) из любого узла или subflow в любой части проекта. Это критически важно для обеспечения синхронности действий всех подсистем.
В данном модуле (M10) мы переходим от учебных примеров к промышленной реализации: мы создадим отказоустойчивый контроллер состояний, который не только переключает режимы, но и записывает каждое изменение в журнал событий для последующего анализа.
Выбор инструментов в Node-RED для реализации State Machine
ыбор инструментов в Node-RED для реализации State Machine
Платформа Node-RED предлагает несколько подходов для реализации конечного автомата. Выбор правильного инструмента напрямую влияет на гибкость, производительность и удобство дальнейшей поддержки системы. Рассмотрим два основных варианта.
Сравнение подходов
| Критерий | Готовые узлы (e.g., `node-red-contrib-finite-statemachine`) | Собственная реализация (на узлах `Function`) |
| ------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------- |
| Скорость разработки | Высокая для простых автоматов. | Средняя, требует написания initial-кода. |
| Прозрачность логики | Низкая. Логика переходов скрыта внутри узла. | Высокая. Вся логика находится в одном месте (в коде узла `Function`). |
| Гибкость | Ограничена возможностями узла. | Максимальная. Можно реализовать любую, даже самую сложную логику приоритетов. |
| Отладка | Сложная. Трудно понять, почему переход не произошел. | Простая. Можно использовать `node.warn()` или `node.error()`. |
| Интеграция | Требует "обвязки" узлами для перехвата событий. | Встроенная. Логирование — часть логики самого автомата. |
| Зависимости | Внешняя. Зависимость от стороннего разработчика. | Отсутствуют. Используются только базовые узлы Node-RED. |
Для проекта уровня сертификации Интегратор (Integration) или Архитектор автоматизации (Architect), где требуется максимальная надежность, собственная реализация на узле `Function` является предпочтительным методом. В рамках текущего модуля мы разберем базовую структуру кода, а сложную реализацию для многоуровневых систем рассмотрим в уроке Проектирование сложных состояний.
Упаковка State Machine в Subflows
Реализовав логику автомата в узле `Function`, её часто необходимо тиражировать (например, если в каждой комнате дома используется свой независимый автомат для освещения). Чтобы не копировать код и упростить поддержку, рекомендуется оборачивать такие `Function`-узлы в подпотоки. Детальнее этот подход мы разбираем в уроке Subflows. Подпоток позволяет создать визуальный интерфейс для ввода начальных параметров, при этом сама логика State Machine остается единой для всех экземпляров.
Хранение состояния: уровни контекста
Ключевой элемент любого конечного автомата — это память, где хранится его текущее состояние. Для этого в Node-RED используется механизм контекстов:
Главное преимущество использования контекста на контроллерах HI — его персистентность.
> 💡 Подсказка: В настройках Node-RED контроллеров HI по умолчанию включено сохранение (persistence) контекстов на диск. Это гарантирует, что ваша state machine восстановит свое последнее активное состояние после перезапуска Node-RED или перезагрузки контроллера.
Для реализации нашего автомата мы будем использовать `flow context`, если логика локальна, или `global context`, если режим влияет на весь дом.
- Чтение: `let currentState = flow.get('system_state') || 'initial';`
- Запись: `flow.set('system_state', 'new_state');`
⚠️ Важно: При использовании `global` контекста убедитесь, что имена переменных уникальны (напр. `global.set('sys_mode_main', 'home')`), чтобы избежать конфликтов между разными сценариями.
Практика: Создание «движка» конечного автомата
Теперь перейдем к созданию ядра нашей системы. Мы создадим один узел `Function`, который будет выполнять роль центрального "движка" состояний.
Подготовка в Node-RED
* Выход 1: Сообщения для запуска действий (управление устройствами и сценариями).
* Выход 2: Сообщения для журнала событий (логирование успехов и ошибок).
Код движка (State Machine Engine)
// ID: FLOW-SYSTEM-FSM-001
// Узел: State Machine Engine
// --- 1. Определение состояний и переходов ---
const transitions = {
'undefined': { 'initialize': 'home_day' }, // Начальный переход при первом запуске
'home_day': {
'event_leave': 'away',
'event_night': 'home_night',
'event_arm': 'security_armed'
},
'home_night': {
'event_leave': 'away',
'event_day': 'home_day',
'event_arm': 'security_armed'
},
'away': {
'event_return': 'home_day',
'event_arm': 'security_armed_away'
},
'security_armed': {
'disarm': 'home_day'
},
'security_armed_away': {
'disarm': 'away'
}
//... другие состояния и переходы
};
// --- 2. Получение события и текущего состояния ---
const currentState = flow.get('system_state') || 'undefined';
// Событие может приходить в topic или в payload. Отдаем приоритет topic.
const event = msg.topic || msg.payload.event;
// --- 3. Проверка валидности события и перехода ---
if (!event) {
node.warn(`[State Machine] Получено сообщение без события (event). Игнорируется.`);
return null; // Прекращаем выполнение
}
const possibleTransitions = transitions[currentState];
if (!possibleTransitions || !possibleTransitions[event]) {
node.warn(`[State Machine] Недопустимый переход из '${currentState}' по событию '${event}'.`);
// Формируем сообщение для журнала об ошибке
const logMsg = {
payload: {
timestamp: Date.now(),
event: event,
from_state: currentState,
to_state: currentState, // Состояние не изменилось
status: "denied",
details: `Transition from '${currentState}' on event '${event}' is not allowed.`
}
};
return [null, logMsg]; // Отправляем сообщение только на второй выход (лог)
}
// --- 4. Выполнение перехода и обновление состояния ---
const newState = possibleTransitions[event];
flow.set('system_state', newState);
// Обновляем статус узла для визуальной диагностики
node.status({ fill: "blue", shape: "dot", text: `State: ${newState}` });
// --- 5. Формирование сообщений для действий и журнала ---
// Сообщение для лога (успешный переход)
const logMsg = {
payload: {
timestamp: Date.now(),
event: event,
from_state: currentState,
to_state: newState,
status: "success",
details: `Переход из '${currentState}' в '${newState}' по событию '${event}'.`,
source_msg: msg // Сохраняем исходное сообщение для отладки
}
};
// Сообщение для запуска действий
// Мы передаем информацию о старом и новом состоянии,
// чтобы потоки-исполнители могли реализовать логику входа/выхода.
const actionMsg = {
topic: `statemachine/transition`,
payload: {
from: currentState,
to: newState,
event: event
}
};
// Отправляем сообщения на соответствующие выходы
// Выход 1: actionMsg, Выход 2: logMsg
return [actionMsg, logMsg];
Архитектура скрипта: Как это работает
- Блок 1 (Конфигурация `transitions`): Это "сердце" автомата — декларативное описание нашего графа состояний. Мы описываем объект, где ключ — это исходное состояние, а значение — объект с возможными событиями и состояниями, в которые они ведут. Это делает логику легко читаемой и отчуждаемой от кода исполнения.
- Блок 2 (Определение `currentState`, `event`): Мы считываем текущее состояние из переменной `flow context`. Если значения еще нет (первый запуск), используется специальное состояние `'undefined'`. Событие (например, `'event_leave'`) определяется как управляющий сигнал и может быть передано либо в `msg.topic`, либо в `msg.payload.event`.
- Блок 3 (Валидация и защита от override): Ключевой шаг для надежности. Код проверяет, существует ли текущее состояние в карте переходов, и разрешен ли для него пришедший `event`. Если переход невалиден, он блокируется на уровне движка: на Выход 1 отправляется `null` (действия не запускаются), а на Выход 2 улетает лог типа `status: "denied"`.
- Блок 4 (Атомарное обновление): Если переход разрешен, вычисляется `newState` и немедленно совершается запись `flow.set('system_state', newState)`. Вызов `node.status` в этой же секции визуально подсвечивает под узлом текущее состояние, что экстремально полезно при отладке в редакторе Node-RED.
- Блок 5 (Разделение ответственности): Движок генерирует два независимых сообщения:
* `logMsg` (Выход 2): Содержит расширенный набор данных для записи в долгосрочное хранилище или отладочную консоль.
> 💡 Совет по оптимизации и инкапсуляции:
> Такое разделение на "выдачу заданий" и "логирование" является паттерном хорошей архитектуры: сам по себе движок не занимается напрямую переключением реле или отправкой Telegram-уведомлений. Если вам понадобится создать независимые state machine для обособленных зон (например, бани или гаража), этот узел имеет смысл перевести в переиспользуемый подпоток. Подробнее о работе с ними мы писали в уроке по Subflows (COURSE-07-M02-L03).
Локальный тест-план (Чек-лист проверки)
Перед тем как двигаться дальше, проверим автономную работу этого узла:
- [ ] Тест первичной инициализации: Подключите к входу узел `Inject` `{ "topic": "initialize" }`. При нажатии визуальный статус узла `Function` должен смениться на `State: home_day`.
- [ ] Тест успешного перехода: Подключите `Inject` `{ "topic": "event_leave" }`. Статус должен поменяться на `State: away`. К Выходу 1 подключите `Debug` — вы должны увидеть `actionMsg` со сменой состояний.
- [ ] Тест запрещённого перехода (отказ): Находясь в статусе `away`, нажмите `Inject` с темой `event_leave` еще раз или отправьте неизвестный `invalid_event`. На Выходе 1 ничего не должно появиться, а на Выход 2 должен прийти лог `status: "denied"`. Статус узла `Function` должен остаться `State: away`.
Практика: Реализация журнала событий
Надежный журнал — это ваш лучший друг во время отладки и эксплуатации системы. Он позволяет ответить на вопросы: "Почему вчера в 3 часа ночи включился свет?" или "Какой сценарий был активен во время сбоя?".
> ⚠️ Внимание: Избегайте логирования каждого входящего сообщения. Записывайте в журнал только факты смены состояния или получения невалидного события. Иначе при высокой частоте событий вы рискуете создать чрезмерную нагрузку на базу данных и шину MQTT (log flood).
Интеграция с архитектурой MQTT+MySQL (M08)
В предыдущих модулях (в частности, в M08) мы уже спроектировали и развернули надежную подсистему логирования, связывающую MQTT-брокер и базу данных MySQL. Создание нового, изолированного потока записи логов для конечного автомата было бы грубым нарушением принципа DRY (Don't Repeat Yourself) и шагом назад в развитии нашей архитектуры.
Вместо того чтобы создавать с нуля отдельный механизм отправки и обработки логов базы данных, мы переиспользуем уже готовую инфраструктуру из модуля M08. Наш узел `Function` ("State Machine Engine") уже настроен на генерацию стандартизированных JSON-объектов при каждом изменении состояния или ошибке перехода. Эти объекты идеально подходят для отправки в нашу существующую шину.
Пример формируемого JSON-сообщения (успешный переход):
{
"timestamp": 1678886400000,
"event": "event_night",
"from_state": "home_day",
"to_state": "home_night",
"status": "success",
"details": "Переход из 'home_day' в 'home_night' по событию 'event_night'."
}
Пошаговая интеграция с готовым Subflow
Чтобы направить эти события в нашу глобальную базу данных, нам нужно лишь подключить второй выход нашего автомата к уже существующему Subflow логирования. Если вам нужно освежить в памяти принципы создания и использования подпотоков, обратитесь к уроку по Subflows.
+-----------------------+
(вход)---->| State Machine Engine |----(выход 1)---> [К потокам действий]
+-----------------------+
|
+---(выход 2)--> [Subflow: M08 System Logger] ---> (MQTT -> MySQL)
Теперь каждое отслеженное изменение состояния автоматически передается в M08 System Logger. Этот Subflow стандартизирует форматы, публикует данные в системный MQTT-топик, а уже существующий глобальный обработчик M08 перехватывает их и надежно сохраняет в таблицу MySQL на контроллере HI. Это наглядный пример того, как правильная архитектура позволяет легко интегрировать новые сложные механизмы (State Machine) в единую экосистему умного дома без дублирования кода.
Чек-лист проверки логирования
После настройки интеграции проведите базовое тестирование механизма:
- [ ] Инициируйте смену состояния (например, переведите систему из `home_day` в `home_night`, отправив тестовый event).
- [ ] Проверьте отладочную панель Node-RED (вкладка Debug, подключенная ко второму выходу состояния) — убедитесь, что JSON-объект лога формируется корректно.
- [ ] Откройте MQTT Explorer и подпишитесь на системный топик логов из модуля M08 (например, `hi/logs/system`). Убедитесь, что сообщение публикуется в топик.
- [ ] Зайдите в базу данных MySQL (через phpMyAdmin/Adminer или консоль) и выполните запрос `SELECT * FROM logs ORDER BY timestamp DESC LIMIT 5;`. Проверьте, что событие смены состояния успешно записано в БД.
Резюме и следующие шаги
На этом уроке мы совершили важнейший шаг — перешли от абстрактной модели к работающему коду. Мы создали универсальный и отказоустойчивый механизм для реализации конечного автомата, который лежит в основе всего сценарного слоя.
Ключевые результаты:Созданный нами движок пока работает в "вакууме". Он получает абстрактные события и генерирует абстрактные сообщения о действиях.
В следующем уроке (COURSE-07-M01-L06) мы "оживим" нашу систему:- Мы интегрируем движок состояний с реальными устройствами и протоколами, такими как Modbus, DALI и MQTT.
- Мы создадим потоки-исполнители, которые будут подписываться на `actionMsg` и выполнять конкретные действия: включать/выключать группы света, отправлять команды на климатические системы.
- И самое главное — мы реализуем логику приоритетов, о которой говорили при проектировании, чтобы обеспечить корректную обработку ручного управления и экстренных режимов.