Реализация в Node-RED: Subflow для управления режимами
Введение в Subflows: инкапсуляция логики
При построении сложных систем автоматизации, особенно в Node-RED, инженер неизбежно сталкивается с проблемой роста сложности. Потоки (flows) разрастаются, линии связей переплетаются, и то, что начиналось как элегантное решение, превращается в так называемую "лапшу" (spaghetti flow), которую невозможно поддерживать и отлаживать. Для борьбы с этой сложностью в Node-RED существует мощный механизм — Subflow (подпоток).
> 💡 Подсказка: Рассматривайте Subflow как "черный ящик" или функцию в программировании. Вы подаете данные на вход, он выполняет свою работу и отдает результат на выход, а вам не нужно беспокоиться о его внутреннем устройстве.
Subflow — это группа узлов, объединенная в единый, переиспользуемый узел, который появляется в вашей палитре узлов Node-RED. В отличие от простой группы узлов (команда "Group Selection"), которая служит лишь для визуального объединения на холсте, Subflow является полноценным логическим компонентом.Ключевые преимущества использования Subflows:
Концепция инкапсуляции является здесь центральной. Subflow скрывает свою внутреннюю реализацию (`switch`, `change`, `function` узлы) и предоставляет наружу только четко определенный интерфейс: входы для получения команд и выходы для отправки результатов. Вам, как пользователю этого Subflow, не важно, как именно он внутри определяет, что режим сменился. Важно лишь, что, подав на вход сообщение `{ "payload": "away" }`, вы получите на выходе сообщение о том, что система успешно перешла в режим "Нет дома".
В контексте нашего курса, мы создадим Subflow, который будет управлять тремя основными состояниями дома: 'Дома' (`home`), 'Нет дома' (`away`) и 'Ночь' (`night`). Этот Subflow станет ядром нашей системы управления режимами, к которому будут подключаться все триггеры, которые мы рассматривали ранее: от ручных кнопок и команд по MQTT до сложных сценариев на основе геопозиции и времени.
---
Проектирование Subflow управления режимами: входы, выходы, свойства
роектирование Subflow управления режимами: входы, выходы, свойства
Прежде чем приступить к сборке, необходимо спроектировать наш Subflow. Проектирование сводится к определению его "контракта" с внешним миром — что он принимает на вход, что отдает на выход и как его можно настроить.
> 🔗 Связанный материал: В уроке COURSE-07-M02-L01 мы спроектировали граф состояний. Логика узла `switch` внутри Subflow будет прямым отражением этого графа, обеспечивая предсказуемые переходы между режимами.
Определение API (входы и выходы)
Наш Subflow будет выполнять одну задачу: получать команду на смену режима и устанавливать новое состояние, выступая центральным контроллером логики.
- Входы: Нам потребуется один вход. На него будут поступать сообщения, где `msg.payload` содержит строковое имя целевого режима.
{
"payload": "home" // или "away", или "night"
}
- Выходы: Мы предусмотрим два выхода для повышения надежности и информативности.
2. Выход 2 (Ошибка): Если на вход поступит некорректная команда (например, `"sleep"` вместо `"night"`), сообщение будет перенаправлено на этот выход для дальнейшей обработки и журналирования. Это соответствует паттерну "Обработка ошибок".
Создание и настройка Subflow
Настройка свойств (Subflow Properties)
Subflow может иметь настраиваемые свойства, которые действуют как переменные окружения. Это позволяет сделать его более гибким и переиспользуемым. Мы добавим свойство для MQTT-топика, чтобы его можно было легко изменить при развертывании на другом объекте.
* Property Name: `MQTT_CURRENT_MODE_TOPIC`
* Label: `MQTT Topic for Current Mode`
* Type: `string`
* Default Value: `hi/system/current_mode`
* Icon: `font-awesome/fa-tag`
Теперь внутри Subflow мы сможем получить доступ к этому значению, что делает наш компонент независимым от жестко закодированных топиков.
Внутренняя логика: Switch и Change
Основу логики составит узел Switch, который будет маршрутизировать поток в зависимости от команды, реализуя механику триггеров из нашего графа состояний.
* Правило 1: `==` (string) `home` -> Выход 1
* Правило 2: `==` (string) `away` -> Выход 2
* Правило 3: `==` (string) `night` -> Выход 3
* Правило 4 (по умолчанию): `otherwise` -> Выход 4
Теперь создадим ветки для каждого режима и определим правила перехода.
- Для ветки "home" (выход 1 из Switch):
Управление состоянием: использование Global Context для персистентности
правление состоянием: использование Global Context для персистентности
Наш Subflow умеет принимать команды, но пока он не выполняет главную функцию — не изменяет и не хранит глобальное состояние дома. Этот раздел — ключевой для понимания управления состоянием во всём курсе. Здесь мы создадим фундамент для надежной работы нашей системы, который будет использоваться во всех последующих уроках. Система должна "помнить", в каком режиме она находится, даже после перезагрузки контроллера. Для этой цели используется Node-RED Global Context.
> ⚠️ Внимание: Неправильная настройка персистентного контекста может привести к потере состояния при перезагрузке. Всегда проверяйте, что в `settings.js` выбран тип хранилища `file`, а не `memory`, для критически важных данных.
Обзор типов контекста
В Node-RED существует три области видимости переменных (контекста). Понимание их различий критически важно для построения надежной архитектуры.
Настройка персистентного хранилища (Persistent Context)
По умолчанию, Global Context хранится в оперативной памяти и обнуляется при каждой перезагрузке. Для надежной системы автоматизации это недопустимо. На контроллере HI (на базе Debian) необходимо настроить хранение контекста в файловой системе.
contextStorage: {
// 'default' - это хранилище, которое будет использоваться для
// global.set/get и flow.set/get без указания имени хранилища.
default: {
module: "localfilesystem" // Используем файловую систему для надежности.
},
// Мы также можем определить именованные хранилища.
memoryOnly: {
module: "memory" // Это хранилище только в ОЗУ.
}
},
Теперь все данные, сохраняемые в `global context` (по умолчанию), будут записываться в файлы в директории `~/.node-red/context/` и автоматически восстанавливаться при запуске.
Чтение и запись в Global Context
Есть два основных способа работы с контекстом:
- Узел `Change`: Простой, декларативный способ. Идеально подходит для установки или получения простых значений.
* Чтение: `Set` `msg.current_mode` `to` `global.house_mode`.
- Узел `Function`: Более гибкий, программный способ. Позволяет выполнять сложную логику.
Практическая реализация: сборка и интеграция Subflow
Теперь объединим все спроектированные элементы в готовый к работе компонент и интегрируем его в нашу систему.
Финальная сборка Subflow
Мы доработаем наш Subflow, добавив в него узел `Function` для управления Global Context.
// --- КОНТРАКТ ---
// Вход: msg.payload содержит целевой режим ('home', 'away', 'night')
// Выход: msg.payload не меняется, но обновляется Global Context
// Получаем текущий режим из персистентного контекста
const currentMode = global.get('house_mode') || 'undefined';
const newMode = msg.payload;
// 1. Проверяем, действительно ли режим меняется.
// Это защищает от лишних срабатываний (идемпотентность).
if (currentMode === newMode) {
node.status({ fill: "grey", shape: "dot", text: `Already in: ${newMode}` });
return null; // Останавливаем поток, т.к. изменений нет
}
// 2. Сохраняем предыдущий и новый режимы в Global Context
global.set('previous_mode', currentMode);
global.set('house_mode', newMode);
global.set('last_mode_change_ts', Date.now()); // Сохраняем время смены
// 3. Обновляем визуальный статус узла Subflow
node.status({ fill: "green", shape: "dot", text: `Set to: ${newMode}` });
// 4. Формируем сообщение для аудита/журналирования
msg.audit = {
event_id: 'MODE_CHANGE_SUCCESS',
details: {
from: currentMode,
to: newMode
}
};
// 5. Возвращаем сообщение для дальнейшей обработки (например, отправки в MQTT)
return msg;
Теперь наш Subflow не только меняет состояние, но и делает это "умно": избегает лишних действий, сохраняет историю и готовит данные для журналирования.
Импорт готового Subflow
Вы можете импортировать полностью готовый Subflow, используя следующий JSON. Скопируйте код, затем в меню Node-RED выберите `Import` и вставьте его.
[
{
"id": "c8e2a3b0.123456",
"type": "subflow",
"name": "Mode Manager",
"info": "Управляет глобальными режимами дома (home, away, night) с сохранением в персистентный контекст.",
"category": "",
"in": [
{
"x": 80,
"y": 180,
"wires": [
{
"id": "e5f8b9c1.987654"
}
]
}
],
"out": [
{
"x": 800,
"y": 180,
"wires": [
{
"id": "a1b2c3d4.fedcba",
"port": 0
}
]
},
{
"x": 800,
"y": 300,
"wires": [
{
"id": "e5f8b9c1.987654",
"port": 3
}
]
}
],
"env": [
{
"name": "MQTT_CURRENT_MODE_TOPIC",
"type": "str",
"value": "hi/system/current_mode",
"label": "MQTT Topic for Current Mode"
}
],
"color": "#DDAA99",
"inputLabels": [
"Команда ('home', 'away', 'night')"
],
"outputLabels": [
"Успех (новый режим)",
"Ошибка (неверная команда)"
],
"icon": "font-awesome/fa-cogs",
"status": {
"x": 800,
"y": 120,
"wires": {
"id": "a1b2c3d4.fedcba",
"port": 0
}
},
"wires": []
},
{
"id": "e5f8b9c1.987654",
"type": "switch",
"z": "c8e2a3b0.123456",
"name": "Validate Command",
"property": "payload",
"propertyType": "msg",
"rules": [
{
"t": "eq",
"v": "home",
"vt": "str"
},
{
"t": "eq",
"v": "away",
"vt": "str"
},
{
"t": "eq",
"v": "night",
"vt": "str"
},
{
"t": "else"
}
],
"checkall": "true",
"repair": false,
"outputs": 4,
"x": 270,
"y": 180,
"wires": [
[
"a1b2c3d4.fedcba"
],
[
"a1b2c3d4.fedcba"
],
[
"a1b2c3d4.fedcba"
],
[
"f9e8d7c6.123456"
]
]
},
{
"id": "f9e8d7c6.123456",
"type": "change",
"z": "c8e2a3b0.123456",
"name": "Format Error",
"rules": [
{
"t": "set",
"p": "error",
"pt": "msg",
"to": "Invalid mode command received: " & payload,
"tot": "jsonata"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 500,
"y": 300,
"wires": [
[]
]
},
{
"id": "a1b2c3d4.fedcba",
"type": "function",
"z": "c8e2a3b0.123456",
"name": "Update Global State",
"func": "const currentMode = global.get('house_mode') || 'undefined';\nconst newMode = msg.payload;\n\nif (currentMode === newMode) {\n node.status({ fill: \"grey\", shape: \"dot\", text: `Already in: ${newMode}` });\n return null;\n}\n\nglobal.set('previous_mode', currentMode);\nglobal.set('house_mode', newMode);\nglobal.set('last_mode_change_ts', Date.now());\n\nnode.status({ fill: \"green\", shape: \"dot\", text: `Set to: ${newMode}` });\n\nmsg.audit = {\n event_id: 'MODE_CHANGE_SUCCESS',\n details: {\n from: currentMode,\n to: newMode\n }\n};\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 510,
"y": 180,
"wires": [
[]
]
}
]
Интеграция и тестирование
Теперь используем наш "черный ящик" в основном потоке.
* Узел `mqtt in`.
* Topic: `hi/system/set_mode`.
* QoS: `1`.
* Найдите узел `Mode Manager` в палитре (вероятно, в самом низу) и перетащите его на холст.
* Узел `mqtt out`.
* Topic: `hi/system/current_mode` (или оставьте пустым, чтобы он брал топик из свойства Subflow, если мы его добавим в `msg.topic`). В нашем случае, мы будем публиковать в топик, заданный в свойствах.
* Retain: `true`. Это важно, чтобы новые клиенты MQTT сразу получали текущее состояние.
(MQTT Broker) (Node-RED Flow)
hi/system/set_mode ---> [MQTT In] ---> [Subflow: Mode Manager] --+-- (Success) --> [MQTT Out] ---> hi/system/current_mode
|
+-- (Error) --> [Debug]
|
`--------------> [Audit Log Flow]
План тестирования:
* Отправьте строку `"home"` в топик `hi/system/set_mode`.
* Проверка:
* В панели Global Context должны появиться переменные: `house_mode` = "home", `previous_mode` = "undefined".
* На выходе Subflow в узле `Debug` вы увидите полное `msg` с `payload: "home"` и объектом `audit`.
* В MQTT Explorer топик `hi/system/current_mode` должен получить значение `"home"`.
* Проверка: `house_mode` = "away", `previous_mode` = "home".
* Проверка: На первом выходе Subflow не должно быть сообщений. На втором выходе (ошибка) должно появиться сообщение с `msg.error`. Глобальный контекст не должен измениться.
* Проверка: После запуска откройте панель Global Context. Значение `house_mode: "away"` должно было восстановиться. Это подтверждает, что персистентность работает корректно.
---
Итоги и следующие шаги
В этом уроке мы сделали важный шаг от разрозненной логики к структурированному и надежному управлению состоянием системы. Мы инкапсулировали всю сложность переключения режимов в единый, переиспользуемый и легко тестируемый компонент — Subflow.
Ключевые выводы:
- Инкапсуляция — ваш лучший друг: Subflows позволяют создавать чистые, читаемые и масштабируемые проекты, пряча сложную реализацию за простым интерфейсом. Централизованное управление логикой в одном месте кардинально снижает затраты на поддержку и количество ошибок.
- Состояние должно быть персистентным: Использование Global Context с настроенным файловым хранилищем является обязательным требованием для любой профессиональной инсталляции. Это гарантирует, что система вернется в корректное состояние после любого сбоя питания или перезагрузки.
- Стандартизация — основа сложных сценариев: Создав стандартизированный `[Mode Manager]`, мы заложили фундамент. Теперь любой другой сценарий (управление светом, климатом, безопасностью) может просто и надежно получать текущий режим дома из `global.get('house_mode')`, будучи уверенным в его актуальности.
Мы создали не просто поток, а архитектурный паттерн, который будет многократно использоваться во всех последующих модулях.
Что дальше?
Теперь, когда у нас есть надежный механизм для установки базовых режимов, возникает логичный вопрос: а что делать, если триггеры генерируют конфликтующие команды? Например, сработал таймер перехода в режим "Ночь", но один из членов семьи еще не вернулся домой, и система по геопозиции хочет оставаться в режиме "Нет дома".
В следующем модуле, "Приоритеты режимов и разрешение конфликтов", мы рассмотрим:
- Как вводить понятие приоритета для каждого режима.
- Как создать "суб-автомат", который будет принимать решение о смене состояния на основе не только команды, но и текущего приоритета.
- Как обрабатывать ситуации, когда более приоритетный режим (`Away`) блокирует переход в менее приоритетный (`Night`).