Практика: Flow управления светом по датчику движения с антифлаппингом
Постановка задачи: стабильное управление светом
Основная цель данного урока — создать сценарий автоматического управления освещением, который не просто работает, а работает комфортно и предсказуемо для человека. Мы стремимся к системе, где свет включается мгновенно при появлении человека в помещении, горит все время, пока он там находится, и выключается с разумной задержкой после того, как помещение опустело.
🔗 Связанный материал: Мы подробно разбирали природу и последствия «дребезга» (flapping) в сценариях автоматизации в уроке COURSE-07-M04-L01 «Проблема 'дребезга' (flapping) в автоматизации: источники и последствия». Рекомендуется освежить эти знания перед продолжением.
Проблема, которую мы решаем, особенно актуальна при использовании пассивных инфракрасных датчиков движения (PIR-сенсоров). Их принцип работы заключается в обнаружении изменений в тепловом поле. Если человек на несколько секунд замирает (например, читая книгу или работая за компьютером), датчик перестает регистрировать движение и отправляет контроллеру сигнал об отсутствии присутствия (occupancy: false). Если автоматика реагирует на этот сигнал мгновенно, свет погаснет, доставив пользователю существенный дискомфорт. Постоянные и несвоевременные включения/выключения света не только раздражают, но и снижают срок службы осветительных приборов.
📋 Ключевые понятия:
- Датчик движения (PIR-сенсор): Устройство, обнаруживающее движение объектов, излучающих тепло.
- "Дребезг" (Flapping): Быстрые, нежелательные переключения состояния системы из-за особенностей работы датчиков или внешних факторов.
- Комфортная задержка: Промежуток времени, по истечении которого система выполняет действие (например, выключение света) после прекращения инициирующего события.
Для решения этой задачи мы будем использовать одну из ключевых техник подавления «дребезга» — задержку на выключение (Delay-Off). Эта техника реализована в Node-RED с помощью специализированного узла `node-red-contrib-trigger`. Он позволит нам игнорировать кратковременные сигналы об отсутствии движения и выключать свет только после того, как в помещении действительно никого не будет в течение заданного периода времени.
Критерии успеха для нашего сценария:
---
Наивный подход и его недостатки
Прежде чем построить правильное решение, давайте рассмотрим самый простой, «наивный» сценарий, чтобы наглядно увидеть его проблемы. Этот подход часто реализуют новички, и он является отличным примером того, «как делать не надо».
Логика такого сценария выглядит предельно просто: получили сообщение от датчика движения — отправили команду на реле.
ASCII-схема базового Flow:[mqtt in] --------> [change] --------> [mqtt out]
(датчик движения) (формирование (реле освещения)
команды)
Предположим, наш датчик движения публикует свое состояние в MQTT-топик `hi/corridor/pir/state`. Формат сообщения соответствует принятому в нашей академии контракту сообщений:
При обнаружении движения:
{
"occupancy": true,
"source": "pir-sensor-corridor-01",
"ts": 1678886400000
}
Когда движение прекращается (часто с некоторой внутренней задержкой самого датчика):
{
"occupancy": false,
"source": "pir-sensor-corridor-01",
"ts": 1678886490000
}
Реализация в Node-RED
* Правило: `Set msg.payload`
* `to the value`: `msg.payload.occupancy`
* Это правило извлекает значение `true` или `false` из входящего JSON и помещает его напрямую в `msg.payload`.
JSON-код такого Flow:
[
{
"id": "a1b2c3d4.e5f6g7",
"type": "mqtt in",
"z": "...",
"name": "Движение в коридоре",
"topic": "hi/corridor/pir/state",
"qos": "1",
"broker": "...",
"x": 150,
"y": 100,
"wires": [
[
"b2c3d4e5.f6g7h8"
]
]
},
{
"id": "b2c3d4e5.f6g7h8",
"type": "json",
"z": "...",
"name": "Parse JSON",
"property": "payload",
"action": "",
"pretty": false,
"x": 320,
"y": 100,
"wires": [
[
"c3d4e5f6.g7h8i9"
]
]
},
{
"id": "c3d4e5f6.g7h8i9",
"type": "change",
"z": "...",
"name": "ON/OFF Command",
"rules": [
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "payload.occupancy",
"tot": "msg"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 500,
"y": 100,
"wires": [
[
"d4e5f6g7.h8i9j0"
]
]
},
{
"id": "d4e5f6g7.h8i9j0",
"type": "mqtt out",
"z": "...",
"name": "Свет в коридоре",
"topic": "hi/corridor/light/set",
"qos": "1",
"retain": "false",
"broker": "...",
"x": 700,
"y": 100,
"wires": []
}
]
Анализ недостатков
Представим поведение системы:
Такое поведение делает автоматизацию бесполезной и даже вредной. Пользователь либо будет вынужден постоянно двигаться, либо найдет способ отключить эту «умную» систему. Этот пример наглядно демонстрирует, почему простое прямое связывание датчика с исполнителем в реальных системах недопустимо.
---
Практика: внедрение узла Trigger для задержки выключения
Теперь перейдем к созданию надежного сценария с использованием узла `node-red-contrib-trigger`. Этот узел – мощный инструмент для управления потоками сообщений во времени. Он позволяет реализовать логику «если событие прекратилось, подожди N секунд, и только потом действуй».
> 💡 Подсказка: Как выбрать время задержки? Для проходных зон (коридоры, холлы), где люди обычно не задерживаются, достаточно 1-2 минут. Для жилых комнат, кабинетов или санузлов, где человек может находиться без активного движения (читать, работать, принимать душ), стоит установить более длительную задержку – от 5 до 15 минут. Начинайте с меньшего значения и увеличивайте, если пользователи жалуются на преждевременное отключение.
Модификация Flow
Мы добавим узел `trigger` между узлом, парсящим JSON от датчика, и узлом, который формирует команду для реле.
ASCII-схема улучшенного Flow:[mqtt in] -> [json] -> [switch] --- (true) --> [payload ON] ---+--> [mqtt out]
| |
`-- (false) --> [trigger] --- (команда OFF) --+
Настройка узла `trigger`
* Send: `nothing` (изначально ничего не отправлять).
Первое поле*: `send` the `original message payload`. Это означает, что при получении первого сообщения узел ничего не будет делать сразу.
* then: `wait for` `5 minutes` (здесь вы устанавливаете желаемую задержку на выключение).
Второе поле*: `then send`. Устанавливаем `boolean` значение `false`. Это та команда, которая будет отправлена по истечении таймера.
* Handling: `Extend delay if new message arrives`. Это ключевая опция! Она означает, что если во время отсчета 5-минутного таймера придет новое сообщение, таймер будет сброшен и отсчет начнется заново.
* by message: (этот пункт появится после выбора `Extend...`) В поле `msg.topic` введите `reset`. Это позволит нам принудительно сбрасывать таймер.
Детальная логика работы
Теперь нам нужно разделить поток на две ветки с помощью узла `switch`: одна для включения света, другая — для управления таймером выключения.
* Проверяет свойство `msg.payload.occupancy`.
* Выход 1: Если `is true`.
* Выход 2: Если `is false`.
* Сообщение с `{"occupancy": true}` идет на узел `change`, который устанавливает `msg.payload` в `true` (команда на включение света).
* Важно: это же сообщение мы должны отправить на вход узла `trigger` со специальным топиком, чтобы сбросить таймер выключения. Добавляем еще одно правило в `change`: `set msg.topic` to `reset`.
* Таким образом, каждое обнаружение движения не только подтверждает, что свет должен гореть, но и отменяет любые планы по его выключению.
* Сообщение с `{"occupancy": false}` идет на вход узла `trigger`.
* `trigger` получает это сообщение и запускает свой 5-минутный таймер. Он ничего не отправляет на выход в этот момент.
* Если в течение 5 минут с выхода 1 придет новое сообщение с `msg.topic` равным `reset`, таймер сбросится.
* Если за 5 минут не придет ни одного сообщения, `trigger` отправит на выход сообщение с `payload` равным `false` (которое мы настроили в поле `then send`).
Финальный JSON-код этого стабильного сценария:
[
{
"id": "e1f2a3b4.c5d6e7",
"type": "mqtt in",
"z": "...",
"name": "Движение в коридоре",
"topic": "hi/corridor/pir/state",
"qos": "1",
"broker": "...",
"wires": [["f2a3b4c5.d6e7f8"]]
},
{
"id": "f2a3b4c5.d6e7f8",
"type": "json",
"z": "...",
"name": "Parse JSON",
"property": "payload",
"action": "obj",
"pretty": false,
"wires": [["a3b4c5d6.e7f8a9"]]
},
{
"id": "a3b4c5d6.e7f8a9",
"type": "switch",
"z": "...",
"name": "Движение есть?",
"property": "payload.occupancy",
"propertyType": "msg",
"rules": [
{ "t": "true" },
{ "t": "false" }
],
"checkall": "true",
"repair": false,
"outputs": 2,
"wires": [["b4c5d6e7.f8a9b0"], ["c5d6e7f8.a9b0c1"]]
},
{
"id": "b4c5d6e7.f8a9b0",
"type": "change",
"z": "...",
"name": "Команда ON + Сброс таймера",
"rules": [
{ "t": "set", "p": "payload", "pt": "msg", "to": "true", "tot": "bool" },
{ "t": "set", "p": "topic", "pt": "msg", "to": "reset", "tot": "str" }
],
"wires": [["d6e7f8a9.b0c1d2", "c5d6e7f8.a9b0c1"]]
},
{
"id": "c5d6e7f8.a9b0c1",
"type": "trigger",
"z": "...",
"name": "Задержка на выключение 5 мин",
"op1": "",
"op2": "false",
"op1type": "nul",
"op2type": "bool",
"duration": "5",
"extend": true,
"units": "min",
"reset": "reset",
"bytopic": "topic",
"wires": [["d6e7f8a9.b0c1d2"]]
},
{
"id": "d6e7f8a9.b0c1d2",
"type": "mqtt out",
"z": "...",
"name": "Свет в коридоре",
"topic": "hi/corridor/light/set",
"qos": "1",
"retain": "false",
"broker": "...",
"wires": []
}
]
---
Расширение сценария: Ручное управление
Наш сценарий отлично справляется с автоматическим управлением, но для полноценной системы необходимо учесть ручное вмешательство пользователя — например, с помощью настенного выключателя.
> ⚠️ Внимание: Неверно спроектированная логика совмещения ручного и автоматического управления может привести к 'залипанию' сценариев, когда автоматика не может вернуть себе управление, или наоборот, гасит свет, включённый человеком.
Решение этой задачи — паттерн "Manual Override" (Ручное управление). Он основан на введении состояний (режимов), таких как `auto` и `manual`.
🔗 Связанный материал: Подробный разбор этого паттерна со схемами и примерами кода вы найдете в уроке `COURSE-07-M04-L01` «Паттерн 'Manual Override': как совместить автоматику и ручное управление».
Ключевые точки интеграции этого паттерна в наш существующий Flow:
Такой подход позволяет автоматике "уважать" действия пользователя и не выключать свет, который был включен вручную.
---
Итоги и финальная схема Flow
В рамках этого урока мы прошли путь от примитивного и некомфортного сценария до надежной и гибкой системы управления освещением. Мы использовали несколько ключевых техник, обязательных для любого профессионального инженера по автоматизации.
Резюме использованных техник:- Антифлаппинг с `trigger`: Мы внедрили узел `trigger` для создания задержки на выключение, что позволило избавиться от "дребезга" датчика движения и случайных отключений света.
- Интеграция паттерна 'Manual Override': Мы добавили возможность ручного управления и возврата в автоматический режим, используя переменные контекста потока (`flow context`) для разделения режимов `auto` и `manual`. Это позволило автоматике и пользователю сосуществовать, не мешая друг другу.
- Событийная логика: Весь сценарий построен на обработке асинхронных событий от MQTT, что является основой для масштабируемых систем на Node-RED.
Финальный Flow объединяет все эти подходы, обеспечивая стабильную и многофункциональную автоматизацию.
Возможные улучшения
Созданный нами сценарий уже достаточно хорош для большинства задач, но его можно и дальше совершенствовать:
- Интеграция с датчиком освещенности: Добавить в начало потока автоматики проверку уровня освещенности (`lux`). Если в помещении достаточно светло (например, > 300 lux), автоматическое включение света блокируется. Это экономит электроэнергию.
- Динамические задержки: Время задержки на выключение может меняться в зависимости от времени суток. Например, ночью задержка может быть меньше (2 минуты), а днем и вечером — больше (10 минут).
- Режим "Присутствие": Вместо одного PIR-сенсора можно использовать комбинацию датчиков (движения, открытия двери, микроволновый датчик), чтобы более точно определять присутствие человека в комнате.
Финальный JSON-код Flow
Ниже представлен полный, готовый к импорту JSON-код, который объединяет логику антидребезга (узел `trigger`) и паттерн ручного управления `Manual Override`.
[
{ "id": "Flow_8", "type": "tab", "label": "COURSE-07-M01-L06: Light Control", "disabled": false, "info": "" },
{ "id": "1a2b3c4d", "type": "mqtt in", "z": "Flow_8", "name": "Движение в коридоре", "topic": "hi/corridor/pir/state", "qos": "1", "broker": "...", "x": 140, "y": 100, "wires": [["5e6f7g8h"]] },
{ "id": "5e6f7g8h", "type": "json", "z": "Flow_8", "name": "", "property": "payload", "action": "", "pretty": false, "x": 330, "y": 100, "wires": [["9i0j1k2l"]] },
{ "id": "9i0j1k2l", "type": "switch", "z": "Flow_8", "name": "Движение есть?", "property": "payload.occupancy", "propertyType": "msg", "rules": [{ "t": "true" }, { "t": "false" }], "checkall": "true", "repair": false, "outputs": 2, "x": 510, "y": 100, "wires": [["3m4n5o6p"], ["7q8r9s0t"]] },
{ "id": "3m4n5o6p", "type": "change", "z": "Flow_8", "name": "Команда ON + Сброс таймера", "rules": [{ "t": "set", "p": "payload", "pt": "msg", "to": "true", "tot": "bool" }, { "t": "set", "p": "topic", "pt": "msg", "to": "reset", "tot": "str" }], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 760, "y": 80, "wires": [["u1v2w3x4", "7q8r9s0t"]] },
{ "id": "7q8r9s0t", "type": "trigger", "z": "Flow_8", "name": "Задержка на выключение 5 мин", "op1": "", "op2": "false", "op1type": "nul", "op2type": "bool", "duration": "5", "extend": true, "override": "reset", "units": "min", "reset": "reset", "bytopic": "topic", "topic": "topic", "outputs": 1, "x": 780, "y": 140, "wires": [["y5z6a7b8"]] },
{ "id": "y5z6a7b8", "type": "switch", "z": "Flow_8", "name": "Режим auto?", "property": "light_corridor_mode", "propertyType": "flow", "rules": [{ "t": "eq", "v": "auto", "vt": "str" }, { "t": "else" }], "checkall": "true", "repair": false, "outputs": 2, "x": 1020, "y": 140, "wires": [["u1v2w3x4"], []] },
{ "id": "c9d0e1f2", "type": "mqtt in", "z": "Flow_8", "name": "Выключатель", "topic": "hi/corridor/switch/state", "qos": "1", "broker": "...", "x": 120, "y": 300, "wires": [["g3h4i5j6"]] },
{ "id": "g3h4i5j6", "type": "json", "z": "Flow_8", "name": "", "property": "payload", "action": "", "pretty": false, "x": 290, "y": 300, "wires": [["k7l8m9n0"]] },
{ "id": "k7l8m9n0", "type": "switch", "z": "Flow_8", "name": "Тип нажатия", "property": "payload.click", "propertyType": "msg", "rules": [{ "t": "eq", "v": "single", "vt": "str" }, { "t": "eq", "v": "double", "vt": "str" }], "checkall": "true", "repair": false, "outputs": 2, "x": 470, "y": 300, "wires": [["o1p2q3r4"], ["p2q3r4s5"]] },
{ "id": "o1p2q3r4", "type": "change", "z": "Flow_8", "name": "Установить режим 'manual'", "rules": [{ "t": "set", "p": "light_corridor_mode", "pt": "flow", "to": "manual", "tot": "str" }], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 700, "y": 280, "wires": [["t6u7v8w9"]] },
{ "id": "p2q3r4s5", "type": "change", "z": "Flow_8", "name": "Установить режим 'auto'", "rules": [{ "t": "set", "p": "light_corridor_mode", "pt": "flow", "to": "auto", "tot": "str" }], "action": "", "property": "", "from": "", "to": "", "reg": false, "x": 690, "y": 340, "wires": [[]] },
{ "id": "t6u7v8w9", "type": "gate", "z": "Flow_8", "name": "Toggle", "controlTopic": "control", "defaultState": "open", "openCmd": "open", "closeCmd": "close", "toggleCmd": "toggle", "passthroughCmd": "passthrough", "x": 930, "y": 280, "wires": [["u1v2w3x4"]] },
{ "id": "u1v2w3x4", "type": "mqtt out", "z": "Flow_8", "name": "Свет в коридоре", "topic": "hi/corridor/light/set", "qos": "1", "retain": "false", "broker": "...", "x": 1280, "y": 100, "wires": [] },
{ "id": "z0a1b2c3", "type": "inject", "z": "Flow_8", "name": "Init: set auto mode on start", "props": [], "repeat": "", "crontab": "", "once": true, "onceDelay": 0.1, "topic": "", "x": 200, "y": 400, "wires": [["p2q3r4s5"]] }
]
---
Что дальше?
В следующем уроке мы рассмотрим еще один важный аспект стабильности — Watchdog-таймеры. Вы научитесь создавать сценарии, которые могут обнаруживать «зависание» устройств или других потоков Node-RED и предпринимать корректирующие действия, например, перезагружать устройство или отправлять аварийное уведомление.
---