ГлавнаяАкадемияСценарии умного дома: режимы, состояния, приоритеты → Реализация в Node-RED: Subflow для управления режимами

Реализация в Node-RED: Subflow для управления режимами

Урок 2 · Сценарии умного дома: режимы, состояния, приоритеты · 30 мин · theory

Введение в Subflows: инкапсуляция логики

При построении сложных систем автоматизации, особенно в Node-RED, инженер неизбежно сталкивается с проблемой роста сложности. Потоки (flows) разрастаются, линии связей переплетаются, и то, что начиналось как элегантное решение, превращается в так называемую "лапшу" (spaghetti flow), которую невозможно поддерживать и отлаживать. Для борьбы с этой сложностью в Node-RED существует мощный механизм — Subflow (подпоток).

> 💡 Подсказка: Рассматривайте Subflow как "черный ящик" или функцию в программировании. Вы подаете данные на вход, он выполняет свою работу и отдает результат на выход, а вам не нужно беспокоиться о его внутреннем устройстве.

Subflow — это группа узлов, объединенная в единый, переиспользуемый узел, который появляется в вашей палитре узлов Node-RED. В отличие от простой группы узлов (команда "Group Selection"), которая служит лишь для визуального объединения на холсте, Subflow является полноценным логическим компонентом.

Ключевые преимущества использования Subflows:

  • Переиспользование кода (Reusability): Если у вас есть повторяющаяся последовательность действий (например, опрос датчика, валидация данных и отправка в MQTT), вы можете создать Subflow один раз и использовать его в десятках мест вашего проекта. Любое изменение или исправление ошибки в логике Subflow автоматически применится ко всем его экземплярам. Это реализация принципа DRY (Don't Repeat Yourself).
  • Упрощение визуальной структуры: Сложный поток из 10-15 узлов, отвечающий за управление климатом, можно "свернуть" в один-единственный узел Subflow `[Climate Control]`. Ваш основной поток становится чистым, лаконичным и читаемым. Вся сложность скрыта внутри.
  • Централизованное управление логикой: Логика управления глобальными режимами дома — идеальный кандидат для инкапсуляции. Вместо того чтобы в разных частях проекта реализовывать проверку `global.get('house_mode')`, у вас будет единый, стандартизированный компонент, отвечающий за смену этих режимов. Это снижает вероятность ошибок и упрощает модернизацию системы.
  • Концепция инкапсуляции является здесь центральной. Subflow скрывает свою внутреннюю реализацию (`switch`, `change`, `function` узлы) и предоставляет наружу только четко определенный интерфейс: входы для получения команд и выходы для отправки результатов. Вам, как пользователю этого Subflow, не важно, как именно он внутри определяет, что режим сменился. Важно лишь, что, подав на вход сообщение `{ "payload": "away" }`, вы получите на выходе сообщение о том, что система успешно перешла в режим "Нет дома".

    В контексте нашего курса, мы создадим Subflow, который будет управлять тремя основными состояниями дома: 'Дома' (`home`), 'Нет дома' (`away`) и 'Ночь' (`night`). Этот Subflow станет ядром нашей системы управления режимами, к которому будут подключаться все триггеры, которые мы рассматривали ранее: от ручных кнопок и команд по MQTT до сложных сценариев на основе геопозиции и времени.

    ---

    Проектирование Subflow управления режимами: входы, выходы, свойства

    роектирование Subflow управления режимами: входы, выходы, свойства

    Прежде чем приступить к сборке, необходимо спроектировать наш Subflow. Проектирование сводится к определению его "контракта" с внешним миром — что он принимает на вход, что отдает на выход и как его можно настроить.

    > 🔗 Связанный материал: В уроке COURSE-07-M02-L01 мы спроектировали граф состояний. Логика узла `switch` внутри Subflow будет прямым отражением этого графа, обеспечивая предсказуемые переходы между режимами.

    Определение API (входы и выходы)

    Наш Subflow будет выполнять одну задачу: получать команду на смену режима и устанавливать новое состояние, выступая центральным контроллером логики.

    * Контракт входного сообщения:

            {

    "payload": "home" // или "away", или "night"

    }

    1. Выход 1 (Успех): Сюда будет отправляться сообщение после успешной смены режима. Это сообщение будет содержать информацию о новом установленном режиме.

    2. Выход 2 (Ошибка): Если на вход поступит некорректная команда (например, `"sleep"` вместо `"night"`), сообщение будет перенаправлено на этот выход для дальнейшей обработки и журналирования. Это соответствует паттерну "Обработка ошибок".

    Создание и настройка Subflow

  • В меню Node-RED выберите `Hamburger Menu (☰) > Subflows > Create Subflow`.
  • Откроется новая вкладка для редактирования Subflow. Переименуйте его, например, в `[Mode Manager]`.
  • Настройте входы и выходы. По умолчанию у вас будет один вход и один выход. Нажмите кнопку `+ output` на панели свойств, чтобы добавить второй выход.
  • Настройка свойств (Subflow Properties)

    Subflow может иметь настраиваемые свойства, которые действуют как переменные окружения. Это позволяет сделать его более гибким и переиспользуемым. Мы добавим свойство для MQTT-топика, чтобы его можно было легко изменить при развертывании на другом объекте.

  • Находясь на вкладке Subflow, нажмите кнопку `Edit properties`.
  • Добавьте свойство:
  • * 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, который будет маршрутизировать поток в зависимости от команды, реализуя механику триггеров из нашего графа состояний.

  • Разместите узел `Switch` на холсте Subflow и соедините его с входом.
  • Настройте `Switch` для проверки `msg.payload`:
  • * Правило 1: `==` (string) `home` -> Выход 1

    * Правило 2: `==` (string) `away` -> Выход 2

    * Правило 3: `==` (string) `night` -> Выход 3

    * Правило 4 (по умолчанию): `otherwise` -> Выход 4

    Теперь создадим ветки для каждого режима и определим правила перехода.

    * Здесь будет происходить сброс таймеров или установка специфических для дома параметров автоматизации.

    Управление состоянием: использование Global Context для персистентности

    правление состоянием: использование Global Context для персистентности

    Наш Subflow умеет принимать команды, но пока он не выполняет главную функцию — не изменяет и не хранит глобальное состояние дома. Этот раздел — ключевой для понимания управления состоянием во всём курсе. Здесь мы создадим фундамент для надежной работы нашей системы, который будет использоваться во всех последующих уроках. Система должна "помнить", в каком режиме она находится, даже после перезагрузки контроллера. Для этой цели используется Node-RED Global Context.

    > ⚠️ Внимание: Неправильная настройка персистентного контекста может привести к потере состояния при перезагрузке. Всегда проверяйте, что в `settings.js` выбран тип хранилища `file`, а не `memory`, для критически важных данных.

    Обзор типов контекста

    В Node-RED существует три области видимости переменных (контекста). Понимание их различий критически важно для построения надежной архитектуры.

  • Node Context: Переменные видны только тому узлу, который их создал. Их жизненный цикл ограничен обработкой одного сообщения. Пример: хранение счетчика попыток внутри узла `function` при работе с API.
  • Flow Context: Переменные видны всем узлам на одной вкладке (flow). По умолчанию данные хранятся в памяти и теряются при перезапуске, но их так же можно сделать персистентными. Пример: временные данные, специфичные для одной подсистемы, например, 'последняя температура в гостиной', которые не нужны глобально.
  • Global Context: Переменные видны абсолютно всем узлам во всем проекте Node-RED, независимо от вкладки. Это единственно правильное место для хранения общесистемных состояний, таких как `house_mode`, `security_status` и других критически важных флагов, которые мы будем использовать повсеместно.
  • Настройка персистентного хранилища (Persistent Context)

    По умолчанию, Global Context хранится в оперативной памяти и обнуляется при каждой перезагрузке. Для надежной системы автоматизации это недопустимо. На контроллере HI (на базе Debian) необходимо настроить хранение контекста в файловой системе.

  • Подключитесь к контроллеру по SSH.
  • Откройте файл конфигурации Node-RED для редактирования: `nano ~/.node-red/settings.js`.
  • Найдите секцию `contextStorage`. Если ее нет, добавьте ее. Приведите ее к следующему виду:
  •     contextStorage: {

    // 'default' - это хранилище, которое будет использоваться для

    // global.set/get и flow.set/get без указания имени хранилища.

    default: {

    module: "localfilesystem" // Используем файловую систему для надежности.

    },

    // Мы также можем определить именованные хранилища.

    memoryOnly: {

    module: "memory" // Это хранилище только в ОЗУ.

    }

    },

  • Эта конфигурация устанавливает файловое хранилище как стандартное (`default`), но также создает дополнительное хранилище `memoryOnly`. Это позволяет нам делать выбор: `global.set('myVar', 1)` сохранит данные в файл, а `global.set('myTempVar', 1, 'memoryOnly')` — только в память.
  • Сохраните файл (`Ctrl+X`, `Y`, `Enter`).
  • Перезапустите сервис Node-RED, чтобы изменения вступили в силу: `node-red-restart`.
  • Теперь все данные, сохраняемые в `global context` (по умолчанию), будут записываться в файлы в директории `~/.node-red/context/` и автоматически восстанавливаться при запуске.

    Чтение и запись в Global Context

    Есть два основных способа работы с контекстом:

    * Запись: `Set` `global.house_mode` `to` `msg.payload`.

    * Чтение: `Set` `msg.current_mode` `to` `global.house_mode`.

    * Запись: `global.set('

    Практическая реализация: сборка и интеграция Subflow

    Теперь объединим все спроектированные элементы в готовый к работе компонент и интегрируем его в нашу систему.

    Финальная сборка Subflow

    Мы доработаем наш Subflow, добавив в него узел `Function` для управления Global Context.

  • Внутри Subflow, после узла `Switch`, перед соединением с Выходом 1, вставьте узел `Function` для каждой из трех веток (`home`, `away`, `night`). Удобнее использовать один узел `function`, соединив все три ветки с его входом.
  • Назовите этот узел `[Update Global State]`.
  • Внесите в него следующий код:
  • // --- КОНТРАКТ ---
    

    // Вход: 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": [

    []

    ]

    }

    ]

    Интеграция и тестирование

    Теперь используем наш "черный ящик" в основном потоке.

  • Создайте новый поток (вкладку) и назовите его "System Modes".
  • Добавьте MQTT-вход:
  • * Узел `mqtt in`.

    * Topic: `hi/system/set_mode`.

    * QoS: `1`.

  • Добавьте ваш Subflow:
  • * Найдите узел `Mode Manager` в палитре (вероятно, в самом низу) и перетащите его на холст.

  • Добавьте MQTT-выход:
  • * Узел `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]

    План тестирования:
  • Разверните (Deploy) изменения.
  • Откройте панель Global Context в Node-RED (справа, выпадающее меню рядом с Debug). Убедитесь, что там пока нет переменных `house_mode` или `previous_mode`.
  • Используйте узел `Inject` (или MQTT-клиент, например, MQTT Explorer), чтобы отправить команды:
  • * Отправьте строку `"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"`.

  • Отправьте команду "away":
  • * Проверка: `house_mode` = "away", `previous_mode` = "home".

  • Отправьте некорректную команду, например, "test":
  • * Проверка: На первом выходе Subflow не должно быть сообщений. На втором выходе (ошибка) должно появиться сообщение с `msg.error`. Глобальный контекст не должен измениться.

  • Перезагрузите контроллер или сервис Node-RED (`node-red-restart`).
  • * Проверка: После запуска откройте панель Global Context. Значение `house_mode: "away"` должно было восстановиться. Это подтверждает, что персистентность работает корректно.

    ---

    Итоги и следующие шаги

    В этом уроке мы сделали важный шаг от разрозненной логики к структурированному и надежному управлению состоянием системы. Мы инкапсулировали всю сложность переключения режимов в единый, переиспользуемый и легко тестируемый компонент — Subflow.

    Ключевые выводы:

    Мы создали не просто поток, а архитектурный паттерн, который будет многократно использоваться во всех последующих модулях.

    Что дальше?

    Теперь, когда у нас есть надежный механизм для установки базовых режимов, возникает логичный вопрос: а что делать, если триггеры генерируют конфликтующие команды? Например, сработал таймер перехода в режим "Ночь", но один из членов семьи еще не вернулся домой, и система по геопозиции хочет оставаться в режиме "Нет дома".

    В следующем модуле, "Приоритеты режимов и разрешение конфликтов", мы рассмотрим: