ГлавнаяАкадемияИсполнительные устройства: интерлоки, таймауты → Практика: делаем сценарий управления светом 'production-ready'

Практика: делаем сценарий управления светом 'production-ready'

Урок 3 · Исполнительные устройства: интерлоки, таймауты · 30 мин · theory

Введение: От прототипа к 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 ).

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

  • Внедрение персистентного контекста: Мы заставим сценарий "помнить" свое последнее состояние, записывая его в энергонезависимую память контроллера.
  • Реализация логики инициализации: Мы добавим механизм, который при каждом старте Node-RED будет считывать сохраненное состояние и приводить физическое устройство в соответствие с ним.
  • Защита от "зомби-команд": Мы реализуем фильтр, который будет отсекать устаревшие MQTT-сообщения с флагом `Retain`, предотвращая их нежелательное исполнение.
  • В результате вы получите готовый шаблон, который можно с уверенностью применять на объектах для управления любыми устройствами, имеющими два или более состояний.

    ---

    Деконструкция: 'наивный' сценарий управления светом

    > ⚠️ Внимание: Представленный 'наивный' сценарий является анти-паттерном. Его использование на объектах может привести к некорректной работе оборудования, рассинхронизации состояний и частым жалобам от клиента.

    Давайте рассмотрим типичный сценарий управления светом, который можно собрать за 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:
  • Узел `MQTT In`: Подписан на топик `livingroom/light/main/set`.
  • Узел `Function` ("Toggle Logic"): Содержит основную логику.
  • Узел `MQTT Out`: Публикует новое состояние в топик `livingroom/light/main/state`.
  • Вот как выглядит код внутри узла `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" }
    

    Анализ уязвимостей 'наивного' сценария

    Этот подход, несмотря на свою простоту, содержит критические недостатки, делающие его непригодным для реального использования:

  • Потеря состояния при перезагрузке. Состояние `lightState` хранится в стандартном контексте (`flow.get`/`set`), который находится в оперативной памяти (RAM) контроллера. При перезагрузке Node-RED или отключении питания контроллера вся оперативная память очищается. После запускаシナрий "забудет", что свет был включен, и будет считать его выключенным (`|| false`). Если свет физически остался гореть, возникнет рассинхронизация: первая же команда от пользователя не выключит свет, а снова отправит команду "ON", не изменив ничего.
  • Отсутствие инициализации. Сценарий пассивен. Он только реагирует на внешние команды. Он не предпринимает никаких действий при старте системы, чтобы привести физическое состояние реле в соответствие со своим внутренним, программным состоянием.
  • Уязвимость к `Retain`-сообщениям. Если последняя команда в топик `livingroom/light/main/set` была отправлена с флагом `Retain`, то после перезагрузки и переподключения к MQTT-брокеру узел `MQTT In` немедленно получит эту "запомненную" команду. Это может полностью нарушить логику и привести к нежелательному переключению света, игнорируя реальное или сохраненное состояние.
  • Эти три проблемы в совокупности создают непредсказуемую и ненадежную систему, которая будет источником постоянных проблем на объекте. Далее мы последовательно устраним каждую из них.

    ---

    Шаг 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;

    Что мы изменили и почему это важно:

    Теперь наш сценарий успешно решает первую проблему — он больше не страдает "амнезией". После перезагрузки он будет точно знать, в каком состоянии должен находиться свет. Однако он все еще пассивен и не пытается синхронизировать физическое состояние реле с этим знанием. Эту проблему мы решим на следующем шаге.

    ---

    Шаг 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`:
  • * Открываем настройки узла.

    * Активируем галочку "Inject once after \_ seconds, then disable flow".

    * Устанавливаем значение задержки, например, `3` секунды.

    * В `Payload` можно оставить `timestamp`.

  • Узел `Function` ("Init Logic"):
  • * Этот узел будет соединен с выходом узла `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;

    Теперь наш сценарий при каждом запуске будет выполнять следующую последовательность действий:

  • Node-RED стартует.
  • Через 3 секунды узел `Inject` отправляет одно-единственное сообщение.
  • Узел "Init Logic" получает его, считывает из файла последнее известное состояние (например, `true` — свет был включен).
  • Он формирует сообщение с `payload = "ON"`.
  • Это сообщение уходит в узел `MQTT Out` и далее на реле, которое включает свет.
  • Таким образом, физическое состояние устройства приводится в полное соответствие с программным. Мы решили вторую проблему. Осталась последняя — защита от старых команд.

    ---

    Шаг 3: Защита от 'зомби-команд' (Retain-флаг MQTT)

    > ⚠️ Внимание: Игнорирование Retain-сообщений — частая ошибка новичков. После перезагрузки контроллера ваше устройство может получить команду, отправленную несколько дней назад, что приведет к нежелательным последствиям и сведет на нет всю логику инициализации.

    Проблема `Retain`-сообщений заключается в том, что они создают "гонку состояний" (race condition). При старте системы почти одновременно происходят два события:

  • Наша логика инициализации (Шаг 2) отправляет команду, основанную на сохраненном состоянии.
  • Узел `MQTT In`, подписавшись на топик, немедленно получает от брокера старую команду, помеченную флагом `Retain`.
  • Какая из этих команд будет исполнена последней — предсказать сложно. Чтобы сделать систему детерминированной, мы должны полностью исключить влияние `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`

    Этот поток теперь полностью отказоустойчив к перезагрузкам:

  • При старте системы он подождет 3 секунды.
  • Затем он прочитает из файла последнее сохраненное состояние (например, "ON").
  • Отправит команду "ON" на реле, физически включая свет.
  • Параллельно он будет слушать команды от пользователя. Если в этот момент прилетит `Retain`-сообщение, оно будет проигнорировано.
  • Когда придет обычная команда, он корректно переключит состояние, сохранит его в файл и отправит новые команды.
  • Этот паттерн универсален. Заменив 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).