Практика: делаем сценарий управления светом 'production-ready'
Введение: От прототипа к production-ready
> 🔗 Связанный материал: Этот урок объединяет знания из предыдущих частей модуля. Убедитесь, что вы изучили: COURSE-05-M08-L01 (Проблема 'Replay'), COURSE-05-M08-L02 (Персистентный контекст), COURSE-05-M08-L03 (Шаблон 'Inject-Once').
В предыдущих уроках мы подробно рассмотрели теоретические аспекты, которые отличают простой демонстрационный сценарий от надежного решения, готового к эксплуатации на реальном объекте. Центральной проблемой, которую мы анализировали, является нестабильность и непредсказуемость поведения систем автоматизации после перезагрузки контроллера HI, будь то плановое обслуживание или внезапное отключение электропитания.
Основная опасность заключается в так называемом "слепом" управлении. Это ситуация, когда программная логика в Node-RED не имеет достоверной информации о реальном физическом состоянии исполнительного устройства (например, включено реле или выключено) и отправляет команды, основываясь на устаревших или неверных данных. Это приводит к рассинхронизации, когда пользователь видит в интерфейсе одно состояние, а по факту устройство находится в другом. Еще более серьезная проблема — это "Replay" команд, когда после перезагрузки контроллер повторно отправляет старую команду, сохраненную MQTT-брокером (с флагом `Retain`), что может привести к нежелательному включению света посреди ночи или активации другого оборудования.
Цель этого урока — перейти от теории к практике и на конкретном примере произвести рефакторинг — осмысленное улучшение — базового сценария управления освещением. Мы возьмем "наивный" прототип, который часто создают начинающие инженеры, и шаг за шагом превратим его в отказоустойчивое, предсказуемое и надежное решение промышленного уровня ( production-ready ).Процесс рефакторинга будет состоять из трех ключевых этапов, каждый из которых решает одну из выявленных проблем:
В результате вы получите готовый шаблон, который можно с уверенностью применять на объектах для управления любыми устройствами, имеющими два или более состояний.
---
Деконструкция: 'наивный' сценарий управления светом
> ⚠️ Внимание: Представленный 'наивный' сценарий является анти-паттерном. Его использование на объектах может привести к некорректной работе оборудования, рассинхронизации состояний и частым жалобам от клиента.
Давайте рассмотрим типичный сценарий управления светом, который можно собрать за 5 минут. Он функционален, но чрезвычайно хрупок.
Задача: Управлять группой света по MQTT. Команды приходят в топик `livingroom/light/main/set`, а состояние публикуется в `livingroom/light/main/state`. Используется логика "toggle" — каждая команда переключает состояние на противоположное. Упрощенная схема потока:[MQTT In] ------------> [Function: Toggle Logic] ------------> [MQTT Out]
`livingroom/light/main/set` | `livingroom/light/main/state`
|
+-----> (также отправляет команду на реле)
Реализация в Node-RED:
Вот как выглядит код внутри узла `Function`:
// Получаем текущее состояние из КОНТЕКСТА ПОТОКА (хранится в RAM)
// Если состояния нет (первый запуск), считаем, что свет выключен (false).
let currentState = flow.get('lightState') || false;
// Инвертируем состояние (toggle)
let newState = !currentState;
// Сохраняем новое состояние обратно в КОНТЕКСТ ПОТОКА
flow.set('lightState', newState);
// Формируем payload для отправки в MQTT и на реле
// в виде строки "ON" или "OFF"
msg.payload = newState ? "ON" : "OFF";
// Обновляем статус узла для визуальной диагностики
node.status({
fill: newState ? "green" : "red",
shape: "dot",
text: "State: " + msg.payload + " at " + new Date().toLocaleTimeString()
});
return msg;
Входящие команды в `mqtt in` могут быть любыми, так как узел `Function` их игнорирует и просто переключает состояние. Например, пустая строка или JSON:
{ "command": "TOGGLE" }
Анализ уязвимостей 'наивного' сценария
Этот подход, несмотря на свою простоту, содержит критические недостатки, делающие его непригодным для реального использования:
Эти три проблемы в совокупности создают непредсказуемую и ненадежную систему, которая будет источником постоянных проблем на объекте. Далее мы последовательно устраним каждую из них.
---
Шаг 1: Обеспечение надежного хранения состояния
Первая и главная задача — заставить сценарий помнить свое состояние между перезагрузками. Для этого мы воспользуемся персистентным контекстом, который, как мы рассматривали в уроке COURSE-05-M08-L02, сохраняет данные в энергонезависимую память.
На контроллерах HI по умолчанию настроено хранилище контекста типа `file`. Это означает, что при указании специального параметра данные будут записаны в зарезервированный файл на встроенном флеш-накопителе. Это обеспечивает их сохранность даже после полного отключения питания.
Модификация потока
Мы внесем минимальные, но ключевые изменения в наш `Function` узел.
Практика:Использование `flow.set()` и `flow.get()` с третьим аргументом `'file'` (имя хранилища, настроенного в `settings.js` контроллера).
Обновленный код узла `Function`:// 1. Читаем состояние из ПЕРСИСТЕНТНОГО хранилища 'file'
// Обратите внимание на второй аргумент функции get!
let currentState = flow.get('lightState', 'file') || false;
// Логика переключения остается прежней
let newState = !currentState;
// 2. Сохраняем новое состояние в ПЕРСИСТЕНТНОЕ хранилище 'file'
// Обратите внимание на третий аргумент функции set!
flow.set('lightState', newState, 'file');
// Формируем payload для отправки
msg.payload = newState ? "ON" : "OFF";
// Обновляем статус узла
node.status({
fill: newState ? "green" : "red",
shape: "dot",
text: "State: " + msg.payload + " (saved)"
});
return msg;
Что мы изменили и почему это важно:
- `flow.get('lightState', 'file')`: Теперь при каждом получении команды мы запрашиваем состояние не из быстрой оперативной памяти, а из файла. Если Node-RED только что запустился, он сможет прочитать то состояние, которое было актуально на момент последней операции перед выключением.
- `flow.set('lightState', newState, 'file')`: После каждого переключения мы не просто обновляем переменную в RAM, а инициируем операцию записи нового состояния в файл на диске. Это гарантирует, что даже если через секунду пропадет питание, последнее актуальное состояние будет сохранено.
Теперь наш сценарий успешно решает первую проблему — он больше не страдает "амнезией". После перезагрузки он будет точно знать, в каком состоянии должен находиться свет. Однако он все еще пассивен и не пытается синхронизировать физическое состояние реле с этим знанием. Эту проблему мы решим на следующем шаге.
---
Шаг 2: Корректная инициализация при старте Node-RED
Теперь, когда у нас есть надежно сохраненное состояние, нам нужен механизм, который при каждом запуске системы будет это состояние "применять" — то есть отправлять команду на исполнительное устройство (реле), чтобы его физический статус соответствовал сохраненному программному. Для этого мы будем использовать шаблон 'Inject-Once', детально разобранный в уроке COURSE-05-M08-L03.
> 💡 Подсказка: Задержку в узле `Inject` рекомендуется ставить на 2-5 секунд. Этого времени достаточно, чтобы все ключевые сервисы контроллера HI, включая MQTT-брокер и драйверы шин (Modbus, CAN), успели полностью запуститься и были готовы к приему команд.
Интеграция шаблона в поток
Мы добавим в наш сценарий два новых узла: `Inject` и еще один `Function` для логики инициализации.
Схема обновленного потока:// Инициализация при старте
[Inject: once after 3s] --> [Function: Init Logic] --+
|
// Обработка команд пользователя |
[MQTT In] ------------------> [Function: Toggle Logic] --+--> [MQTT Out]
(команда на реле и обновление статуса)
Настройка узлов
* Открываем настройки узла.
* Активируем галочку "Inject once after \_ seconds, then disable flow".
* Устанавливаем значение задержки, например, `3` секунды.
* В `Payload` можно оставить `timestamp`.
* Этот узел будет соединен с выходом узла `Inject`.
* Его задача — прочитать сохраненное состояние и сформировать сообщение для отправки на реле.
Код для узла `Function: Init Logic`:// 1. Читаем последнее сохраненное состояние из персистентного контекста
// Если состояния не было (самый первый запуск на новом контроллере),
// считаем, что свет должен быть выключен (false).
let lastKnownState = flow.get('lightState', 'file') || false;
// 2. Формируем payload для отправки на реле и в топик состояния
// Мы не меняем состояние, а просто отправляем то, что было сохранено.
msg.payload = lastKnownState ? "ON" : "OFF";
// 3. Устанавливаем специальный флаг для отладки, чтобы понимать,
// что это сообщение было сгенерировано логикой инициализации.
msg.topic_suffix = "/init"; // Не обязательно, но полезно для логов
// Обновляем статус узла для визуальной диагностики
node.status({
fill: "blue",
shape: "ring",
text: "Init: sending state " + msg.payload
});
// Отправляем сообщение дальше по потоку (на тот же узел MQTT Out)
return msg;
Теперь наш сценарий при каждом запуске будет выполнять следующую последовательность действий:
Таким образом, физическое состояние устройства приводится в полное соответствие с программным. Мы решили вторую проблему. Осталась последняя — защита от старых команд.
---
Шаг 3: Защита от 'зомби-команд' (Retain-флаг MQTT)
> ⚠️ Внимание: Игнорирование Retain-сообщений — частая ошибка новичков. После перезагрузки контроллера ваше устройство может получить команду, отправленную несколько дней назад, что приведет к нежелательным последствиям и сведет на нет всю логику инициализации.
Проблема `Retain`-сообщений заключается в том, что они создают "гонку состояний" (race condition). При старте системы почти одновременно происходят два события:
Какая из этих команд будет исполнена последней — предсказать сложно. Чтобы сделать систему детерминированной, мы должны полностью исключить влияние `Retain`-сообщений на нашу логику.
Существует два основных способа это сделать:
Способ 1: Настройка MQTT-брокера (рекомендуется для системного подхода)
На контроллере HI используется брокер Mosquitto. Его можно настроить так, чтобы он в принципе не позволял публиковать `Retain`-сообщения в командные топики. Это делается через файл списков контроля доступа (ACL). Например, можно запретить `retain` для всех топиков, заканчивающихся на `/set`. Это наиболее правильный, но более сложный в настройке способ.
Способ 2: Фильтрация в Node-RED (простой и надежный)
Более простой и быстрый способ, который может реализовать любой инженер прямо в потоке — добавить узел-фильтр. Каждое сообщение, полученное узлом `MQTT In`, несет в себе не только `payload`, но и служебную информацию, включая флаг `msg.retain` (будет `true`, если сообщение "запомненное"). Мы можем проверять этот флаг и просто отбрасывать такие сообщения.
Практика: добавление фильтра
Мы добавим еще один узел `Function` сразу после `MQTT In`.
Схема этой части потока:[MQTT In] --> [Function: Retain Filter] --> [Function: Toggle Logic] --> ...
Код для узла `Function: Retain Filter`:
/*
* Этот узел проверяет флаг 'retain' у входящего MQTT-сообщения.
* Если флаг установлен в true, это означает, что сообщение "старое",
* сохраненное брокером. Такие сообщения нужно игнорировать, чтобы они
* не нарушали нашу логику инициализации.
*
* Узел возвращает 'null', чтобы остановить дальнейшее прохождение сообщения.
*/
if (msg.retain === true) {
node.warn("Ignored retained message on topic: " + msg.topic);
node.status({fill:"yellow", shape:"ring", text:"Retain ignored"});
return null; // <-- Ключевой момент! Поток останавливается здесь.
}
// Если это обычное, "живое" сообщение, оно проходит дальше без изменений.
node.status({fill:"grey", shape:"dot", text:""});
return msg;
Теперь любое `Retain`-сообщение, которое брокер попытается доставить после перезагрузки, будет "поймано" и уничтожено этим фильтром, не доходя до нашей основной логики `Toggle Logic`. Это гарантирует, что единственной командой, которая будет исполнена при старте, будет команда от нашего блока инициализации.
Мы решили все три проблемы и готовы собрать финальный, отказоустойчивый сценарий.
---
Итоги: Финальный 'production-ready' сценарий
> 💡 Подсказка: Чтобы не создавать эту логику каждый раз заново, преобразуйте готовый поток в 'subflow' (подпоток). Выделите все узлы, кроме `MQTT In` и `MQTT Out` (или сделайте их частью подпотока, а топики задавайте через переменные окружения), и выберите в меню "Subflows -> Selection to Subflow". Это позволит вам повторно использовать готовый блок для десятков групп света, меняя только MQTT-топики.
Давайте соберем все наши доработки в единый, целостный поток. Он объединяет в себе все три техники: персистентное хранение, логику инициализации и защиту от `Retain`.
Финальная схема потока:// ================= PRODUCTION-READY LIGHT CONTROL =================
// ----- Initialization Logic (runs once on start) -----
[Inject: once after 3s] --> [Function: Init Logic] --+
|
// ----- User Command Logic (runs on every command) -- |
[MQTT In] --> [Function: Retain Filter] --> [Function: Toggle & Save] --+
|
// ----- Common Output to device and state topic -----------------------+
|
+--> [MQTT Out: Command to Relay]
| `livingroom/light/main/cmnd`
|
+--> [MQTT Out: State Update]
`livingroom/light/main/state`
Этот поток теперь полностью отказоустойчив к перезагрузкам:
Этот паттерн универсален. Заменив MQTT на Modbus, а логику `toggle` на установку уставки, вы можете точно так же управлять термостатом. Заменив `ON/OFF` на `OPEN/CLOSE` — управлять шторами. Это фундаментальный шаблон для создания надежных систем на платформе HI.
Код для импорта в Node-RED
Ниже представлен полный JSON-код готового потока. Вы можете скопировать его и импортировать в свой редактор Node-RED (`Меню -> Импорт`) для изучения и адаптации.
[
{
"id": "1a2b3c4d.5e6f7g",
"type": "tab",
"label": "Production Light Control",
"disabled": false,
"info": ""
},
{
"id": "c1d2e3f4.a5b6c7",
"type": "mqtt in",
"z": "1a2b3c4d.5e6f7g",
"name": "Listen for commands",
"topic": "livingroom/light/main/set",
"qos": "2",
"datatype": "auto",
"broker": "your-mqtt-broker-id",
"x": 150,
"y": 200,
"wires": [
[
"d4e5f6a7.b8c9d0"
]
]
},
{
"id": "d4e5f6a7.b8c9d0",
"type": "function",
"z": "1a2b3c4d.5e6f7g",
"name": "Retain Filter",
"func": "if (msg.retain === true) {\n node.warn(\"Ignored retained message on topic: \" + msg.topic);\n node.status({fill:\"yellow\", shape:\"ring\", text:\"Retain ignored\"});\n return null;\n}\n\nnode.status({});\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 350,
"y": 200,
"wires": [
[
"e7f8a9b0.c1d2e3"
]
]
},
{
"id": "e7f8a9b0.c1d2e3",
"type": "function",
"z": "1a2b3c4d.5e6f7g",
"name": "Toggle & Save State",
"func": "let currentState = flow.get('lightState', 'file') || false;\nlet newState = !currentState;\n\nflow.set('lightState', newState, 'file');\n\nmsg.payload = newState ? \"ON\" : \"OFF\";\n\nnode.status({\n fill: newState ? \"green\" : \"red\",\n shape: \"dot\",\n text: \"State: \" + msg.payload + \" (saved)\"\n});\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 570,
"y": 200,
"wires": [
[
"f9a0b1c2.d3e4f5",
"a1b2c3d4.e5f6a7"
]
]
},
{
"id": "f9a0b1c2.d3e4f5",
"type": "mqtt out",
"z": "1a2b3c4d.5e6f7g",
"name": "Send Command to Relay",
"topic": "livingroom/light/main/cmnd",
"qos": "1",
"retain": "false",
"broker": "your-mqtt-broker-id",
"x": 830,
"y": 180,
"wires": []
},
{
"id": "a1b2c3d4.e5f6a7",
"type": "mqtt out",
"z": "1a2b3c4d.5e6f7g",
"name": "Update State Topic",
"topic": "livingroom/light/main/state",
"qos": "1",
"retain": "true",
"broker": "your-mqtt-broker-id",
"x": 820,
"y": 240,
"wires": []
},
{
"id": "b3c4d5e6.f7a8b9",
"type": "inject",
"z": "1a2b3c4d.5e6f7g",
"name": "Inject-Once on Start",
"props": [
{
"p": "payload"
}
],
"repeat": "",
"crontab": "",
"once": true,
"onceDelay": "3",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 150,
"y": 100,
"wires": [
[
"c5d6e7f8.a9b0c1"
]
]
},
{
"id": "c5d6e7f8.a9b0c1",
"type": "function",
"z": "1a2b3c4d.5e6f7g",
"name": "Init Logic",
"func": "let lastKnownState = flow.get('lightState', 'file') || false;\n\nmsg.payload = lastKnownState ? \"ON\" : \"OFF\";\n\nnode.status({\n fill: \"blue\",\n shape: \"ring\",\n text: \"Init: sending state \" + msg.payload\n});\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 340,
"y": 100,
"wires": [
[
"f9a0b1c2.d3e4f5",
"a1b2c3d4.e5f6a7"
]
]
}
]
Что дальше?
В следующих уроках мы рассмотрим, как применять этот паттерн к более сложным устройствам, таким как диммеры с аналоговым управлением 0-10В и моторы с несколькими состояниями, а также как внедрять логику безопасных состояний (fail-safe) и взаимных блокировок (interlocks).