Усложненный сценарий: позиционирование шторы в % открытия
Введение: от полного хода к процентному позиционированию
В предыдущих уроках мы освоили базовое управление исполнительными устройствами, такими как приводы штор, роллет или шаровых кранов, используя бинарную логику «открыть/закрыть». Эта модель эффективна для множества задач, но не позволяет добиться гранулярного контроля. Пользователь не может, например, открыть штору на 75% или приглушить дневной свет, оставив ее на 30%. Цель данного урока — перейти от этой простой модели к процентному позиционированию.
Процентное позиционирование — это способность системы устанавливать привод в любое промежуточное положение, выраженное в процентах от 0 (полностью закрыто) до 100 (полностью открыто). Это открывает новые возможности для сценариев автоматизации: создание световых сцен, точное регулирование потока жидкости или имитация присутствия в доме.
> 🔗 Связанный материал: Для полного освоения материала данного урока и обеспечения точности работы сценариев, критически важно, чтобы вы изучили и применили на практике методы из урока COURSE-05-M06-L03: «Калибровка: определение времени полного хода привода». Без точного значения времени полного хода вся логика процентного позиционирования будет некорректной.
Фундаментом для нашего алгоритма служит значение времени полного хода (Full Travel Time), которое мы научились измерять. Зная это время, мы можем рассчитать, как долго нужно подавать напряжение на привод, чтобы он переместился на заданный процент. Простейшая формула выглядит так:
`ВремяРаботы = ВремяПолногоХода * (ЦелевойПроцент / 100)`
Например, если полное открытие шторы занимает 20 секунд (20 000 мс), а мы хотим открыть её на 50%, то привод должен работать:
`ВремяРаботы = 20000 мс * (50 / 100) = 10000 мс` или 10 секунд.
Архитектура нашего решения будет строиться на стандартных компонентах платформы HI:
Таким образом, мы создадим интеллектуальный слой между высокоуровневой командой («установить 75%») и низкоуровневым физическим действием (подать напряжение на реле на X миллисекунд).
---
Проблема «текущего состояния» и её решение
Простая формула, рассмотренная выше, имеет фундаментальный недостаток: она не учитывает текущее положение шторы. Представим ситуацию:
- Время полного хода: 20 секунд.
- Штора находится в положении 50% (была открыта наполовину).
- Поступает команда: установить положение 75%.
Если мы применим простую формулу, система рассчитает время работы как `20000 * (75/100) = 15000 мс`. Привод, который уже находится на 50%, проработает еще 15 секунд и полностью откроется, ударившись о концевой выключатель, а система будет считать, что он на 75%. Это неверно и недопустимо.
Для корректной работы нам необходимо всегда знать, где находится штора в данный момент. Эта задача решается с помощью управления состоянием (State Management). Мы должны хранить последнее известное положение шторы в памяти контроллера.
В Node-RED для этой цели идеально подходят переменные контекста (context variables). Мы будем использовать `flow context` — переменные, доступные в пределах одной вкладки (потока).
> 💡 Подсказка: Для критически важных систем используйте возможности энергонезависимой памяти (persistent context) контроллера HI. Настроив в файле `settings.js` хранение контекста в файловой системе (по умолчанию) или в базе данных MySQL, вы гарантируете, что последнее известное положение шторы (`current_percent`) будет восстановлено даже после перезагрузки контроллера или сбоя питания. Это предотвратит необходимость полной рекалибровки при каждом включении.
Теперь наш алгоритм становится значительно сложнее и точнее:
* Если `target_percent > current_percent`, направление движения — «Открыть».
* Если `target_percent < current_percent`, направление движения — «Закрыть».
* Если `target_percent == current_percent`, никаких действий не требуется.
`ВремяРаботы = ВремяПолногоХода * (abs(ЦелевойПроцент - ТекущийПроцент) / 100)`
где `abs()` — это модуль числа, так как время не может быть отрицательным.
Этот алгоритм гарантирует, что привод будет двигаться ровно на необходимое расстояние из любого начального положения, обеспечивая точное и предсказуемое позиционирование.
---
Практика: Функция расчета и управления состоянием
Центральным элементом нашего потока будет узел `function`, который реализует описанный выше алгоритм. Он будет принимать целевой процент, выполнять все вычисления и формировать управляющее сообщение для следующих узлов.
Создание и настройка узла `function`
Логика внутри функции
Скопируйте следующий JavaScript-код в редактор узла. Код подробно прокомментирован, чтобы объяснить каждый шаг.
// ====== Константы и конфигурация ======
// Укажите время полного хода привода в миллисекундах,
// полученное на этапе калибровки (COURSE-05-M06-L03).
const FULL_TRAVEL_TIME_MS = 22500; // Пример: 22.5 секунды
// Имя переменной для хранения состояния в контексте потока.
const CONTEXT_VARIABLE_NAME = 'curtain_living_room_position';
// ====== Получение и валидация входных данных ======
// Ожидаем, что msg.payload будет числом от 0 до 100.
let targetPercent = parseFloat(msg.payload);
if (isNaN(targetPercent) || targetPercent < 0 || targetPercent > 100) {
node.error("Ошибка: Некорректный целевой процент. Ожидалось число от 0 до 100, получено: " + msg.payload, msg);
node.status({ fill: "red", shape: "dot", text: "Invalid input: " + msg.payload });
return null; // Прерываем выполнение потока
}
// Округляем до целого числа для простоты.
targetPercent = Math.round(targetPercent);
// ====== Чтение текущего состояния из контекста ======
// Получаем последнее известное положение. Если его нет, считаем, что штора закрыта (0%).
let currentPercent = flow.get(CONTEXT_VARIABLE_NAME) || 0;
node.log(`Текущее положение: ${currentPercent}%, Целевое: ${targetPercent}%`);
// ====== Расчет разницы и направления ======
let deltaPercent = targetPercent - currentPercent;
if (deltaPercent === 0) {
node.status({ fill: "green", shape: "dot", text: `Уже на ${targetPercent}%` });
return null; // Штора уже в нужном положении, ничего не делаем.
}
let direction = (deltaPercent > 0) ? "open" : "close";
let runTimeMs = Math.round(FULL_TRAVEL_TIME_MS * (Math.abs(deltaPercent) / 100));
// ====== Формирование управляющего сообщения ======
// Мы не можем просто передать runtime. Нам нужно сохранить и целевую позицию
// для обновления контекста ПОСЛЕ завершения движения.
// 1. Устанавливаем топик, который будет использоваться для маршрутизации (в'switch' node)
msg.topic = direction;
// 2. В payload помещаем длительность работы привода в мс. Это будет использовано 'trigger' node.
msg.payload = runTimeMs;
// 3. Сохраняем целевое положение, чтобы обновить контекст после выполнения.
msg.final_position = targetPercent;
msg.context_variable_name = CONTEXT_VARIABLE_NAME;
// ====== Визуальный статус и возврат сообщения ======
node.status({
fill: "blue",
shape: "dot",
text: `Движение: ${direction} на ${Math.abs(deltaPercent)}% (${runTimeMs} мс)`
});
// Отправляем сформированное сообщение дальше по потоку
return msg;
Контракт входящего сообщения:
Узел ожидает, что `msg.payload` будет числом (или строкой, приводимой к числу) в диапазоне от 0 до 100.
// Пример входящего msg.payload
75
Контракт исходящего сообщения:
Узел формирует комплексное сообщение для дальнейшей обработки.
// Пример исходящего msg (если currentPercent=50, targetPercent=75)
{
"topic": "open",
"payload": 5625, // 22500 * (abs(75-50)/100)
"final_position": 75,
"context_variable_name": "curtain_living_room_position",
"_msgid": "..."
}
Этот узел является "мозгом" нашего сценария. Он инкапсулирует всю логику расчета и подготавливает удобное сообщение, которое легко обработать следующими узлами в потоке.
---
Сборка полного потока в Node-RED
Теперь, когда у нас есть узел с основной логикой, соберем полный поток, который свяжет MQTT-команды с физическими реле контроллера.
> ⚠️ Внимание: Критически важно реализовать механизм интерлока (interlock), чтобы исключить одновременную подачу питания на обмотки «Открыть» и «Закрыть». Это может привести к короткому замыканию и выходу привода из строя. Как мы рассматривали в модуле COURSE-05-M04, это может быть аппаратный интерлок на уровне релейного модуля или программный в логике Node-RED. В нашей схеме программный интерлок обеспечивается тем, что узел `switch` направляет команду только в одну из двух веток.
Схема потока
Ниже представлена полная схема потока от получения команды до обновления состояния.
+----------------+
--( 'open' )->| Trigger: Pulse |---+-->[GPIO Out: Open Relay]
/ +----------------+ |
+-----------+ +-------------+ +--------+ |
| MQTT In | -> | Function: | -> | Switch | | +-------------------+
| (command) | | Calc Time | | on topic| +-->| Function: |-->[Debug: State]
+-----------+ +-------------+ +--------+ | | Update Context |
\ +----------------+ | +-------------------+
--( 'close' )->| Trigger: Pulse |---+-->[GPIO Out: Close Relay]
+----------------+
Настройка узлов
* Topic: `devices/living_room/curtain/set` (или ваш проектный топик).
* QoS: 1.
* Output: `a parsed JSON object` (если отправляете JSON) или `a string` (если отправляете просто число). Наша функция обработает число.
* Скопируйте код из предыдущего раздела.
* Property: `msg.topic`.
* Правило 1: `==` (строка) `open`.
* Правило 2: `==` (строка) `close`.
* Создайте два одинаковых узла `trigger`, по одному для каждой ветки (`open` и `close`).
* Send: `boolean` `true`.
* then wait for: `msg.payload` (выберите `msg.` и впишите `payload`). Это заставит узел ждать на то количество миллисекунд, которое мы рассчитали.
* then send: `boolean` `false`.
* Эта конфигурация создаст импульс `true` -> (пауза) -> `false`, идеально подходящий для управления реле.
* Pin: Выберите GPIO-пин, к которому подключено реле «Открыть» для первого узла и «Закрыть» для второго.
* Type: `Digital output`.
* Initialize pin state: `low (0)`.
Этот узел должен стоять после узлов `trigger`. Он получает сообщение после* того, как `trigger` завершил свою работу.
* Код:
let position = msg.final_position;
let context_var = msg.context_variable_name;
flow.set(context_var, position);
node.status({ fill: "green", shape: "dot", text: `Состояние обновлено: ${position}%` });
// Формируем сообщение для отладки и MQTT-статуса
msg.payload = {
"current_position": position
};
msg.topic = 'devices/living_room/curtain/status'; // Топик для обратной связи
return msg;
* Подключите этот узел после `function: Update Context`, чтобы публиковать текущее положение шторы. Это позволяет интерфейсам управления (например, в мобильном приложении) отображать актуальное состояние.
JSON-код для импорта потока
Вы можете импортировать этот поток целиком через меню Node-RED (`Import` -> `Clipboard`).
[
{
"id": "a1b2c3d4.e5f6a7",
"type": "mqtt in",
"z": "f0g1h2i3.j4k5l6",
"name": "CMD: curtain_living_room/set",
"topic": "devices/living_room/curtain/set",
"qos": "1",
"datatype": "auto",
"broker": "your_mqtt_broker_id",
"x": 190,
"y": 400,
"wires": [
[
"b2c3d4e5.f6a7b8"
]
]
},
{
"id": "b2c3d4e5.f6a7b8",
"type": "function",
"z": "f0g1h2i3.j4k5l6",
"name": "Calculate Run Time",
"func": "// ====== Константы и конфигурация ======\n// Укажите время полного хода привода в миллисекундах,\n// полученное на этапе калибровки (COURSE-05-M06-L03).\nconst FULL_TRAVEL_TIME_MS = 22500; // Пример: 22.5 секунды\n\n// Имя переменной для хранения состояния в контексте потока.\nconst CONTEXT_VARIABLE_NAME = 'curtain_living_room_position';\n\n\n// ====== Получение и валидация входных данных ======\n// Ожидаем, что msg.payload будет числом от 0 до 100.\nlet targetPercent = parseFloat(msg.payload);\n\nif (isNaN(targetPercent) || targetPercent < 0 || targetPercent > 100) {\n node.error(\"Ошибка: Некорректный целевой процент. Ожидалось число от 0 до 100, получено: \" + msg.payload, msg);\n node.status({ fill: \"red\", shape: \"dot\", text: \"Invalid input: \" + msg.payload });\n return null; // Прерываем выполнение потока\n}\n\n// Округляем до целого числа для простоты.\ntargetPercent = Math.round(targetPercent);\n\n\n// ====== Чтение текущего состояния из контекста ======\n// Получаем последнее известное положение. Если его нет, считаем, что штора закрыта (0%).\nlet currentPercent = flow.get(CONTEXT_VARIABLE_NAME) || 0;\nnode.log(`Текущее положение: ${currentPercent}%, Целевое: ${targetPercent}%`);\n\n\n// ====== Расчет разницы и направления ======\nlet deltaPercent = targetPercent - currentPercent;\n\nif (deltaPercent === 0) {\n node.status({ fill: \"green\", shape: \"dot\", text: `Уже на ${targetPercent}%` });\n return null; // Штора уже в нужном положении, ничего не делаем.\n}\n\nlet direction = (deltaPercent > 0) ? \"open\" : \"close\";\nlet runTimeMs = Math.round(FULL_TRAVEL_TIME_MS * (Math.abs(deltaPercent) / 100));\n\n\n// ====== Формирование управляющего сообщения ======\n// Мы не можем просто передать runtime. Нам нужно сохранить и целевую позицию\n// для обновления контекста ПОСЛЕ завершения движения.\n\n// 1. Устанавливаем топик, который будет использоваться для маршрутизации (в'switch' node)\nmsg.topic = direction;\n\n// 2. В payload помещаем длительность работы привода в мс. Это будет использовано 'trigger' node.\nmsg.payload = runTimeMs;\n\n// 3. Сохраняем целевое положение, чтобы обновить контекст после выполнения.\nmsg.final_position = targetPercent;\nmsg.context_variable_name = CONTEXT_VARIABLE_NAME;\n\n\n// ====== Визуальный статус и возврат сообщения ======\nnode.status({ \n fill: \"blue\", \n shape: \"dot\", \n text: `Движение: ${direction} на ${Math.abs(deltaPercent)}% (${runTimeMs} мс)` \n});\n\n// Отправляем сформированное сообщение дальше по потоку\nreturn msg;",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"x": 450,
"y": 400,
"wires": [
[
"c3d4e5f6.a7b8c9"
]
]
},
{
"id": "c3d4e5f6.a7b8c9",
"type": "switch",
"z": "f0g1h2i3.j4k5l6",
"name": "Route by Direction",
"property": "topic",
"propertyType": "msg",
"rules": [
{
"t": "eq",
"v": "open",
"vt": "str"
},
{
"t": "eq",
"v": "close",
"vt": "str"
}
],
"checkall": "true",
"repair": false,
"outputs": 2,
"x": 670,
"y": 400,
"wires": [
[
"d4e5f6a7.b8c9d0"
],
[
"e5f6a7b8.c9d0e1"
]
]
},
{
"id": "d4e5f6a7.b8c9d0",
"type": "trigger",
"z": "f0g1h2i3.j4k5l6",
"name": "Pulse Open",
"op1": "true",
"op2": "false",
"op1type": "bool",
"op2type": "bool",
"duration": "-1",
"extend": false,
"overrideDelay": true,
"units": "ms",
"reset": "",
"bytopic": "all",
"topic": "topic",
"outputs": 1,
"x": 860,
"y": 360,
"wires": [
[
"f6a7b8c9.d0e1f2"
]
]
},
{
"id": "e5f6a7b8.c9d0e1",
"type": "trigger",
"z": "f0g1h2i3.j4k5l6",
"name": "Pulse Close",
"op1": "true",
"op2": "false",
"op1type": "bool",
"op2type": "bool",
"duration": "-1",
"extend": false,
"overrideDelay": true,
"units": "ms",
"reset": "",
"bytopic": "all",
"topic": "topic",
"outputs": 1,
"x": 860,
"y": 440,
"wires": [
[
"a7b8c9d0.e1f2a3"
]
]
},
{
"id": "f6a7b8c9.d0e1f2",
"type": "rpi-gpio out",
"z": "f0g1h2i3.j4k5l6",
"name": "Relay Open",
"pin": "17",
"set": "",
"level": "0",
"freq": "",
"out": "out",
"x": 1050,
"y": 360,
"wires": []
},
{
"id": "a7b8c9d0.e1f2a3",
"type": "rpi-gpio out",
"z": "f0g1h2i3.j4k5l6",
"name": "Relay Close",
"pin": "18",
"set": "",
"level": "0",
"freq": "",
"out": "out",
"x": 1050,
"y": 440,
"wires": []
},
{
"id": "b8c9d0e1.f2a3b4",
"type": "function",
"z": "f0g1h2i3.j4k5l6",
"name": "Update Context",
"func": "let position = msg.final_position;\nlet context_var = msg.context_variable_name;\n\nflow.set(context_var, position);\n\nnode.status({ fill: \"green\", shape: \"dot\", text: `Состояние обновлено: ${position}%` });\n\n// Формируем сообщение для отладки и MQTT-статуса\nmsg.payload = {\n \"current_position\": position\n};\nmsg.topic = 'devices/living_room/curtain/status'; // Топик для обратной связи\nreturn msg;\n",
"outputs": 1,
"noerr": 0,
"initialize": "",
"finalize": "",
"x": 870,
"y": 520,
"wires": [
[
"c9d0e1f2.a3b4c5"
]
]
},
{
"id": "d4e5f6a7.b8c9d0",
"type": "link in",
"z": "f0g1h2i3.j4k5l6",
"name": "from triggers",
"links": ["d4e5f6a7.b8c9d0","e5f6a7b8.c9d0e1"],
"x": 675,
"y": 520,
"wires": [
["b8c9d0e1.f2a3b4"]
]
},
{
"id": "c9d0e1f2.a3b4c5",
"type": "debug",
"z": "f0g1h2i3.j4k5l6",
"name": "State Log",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "true",
"targetType": "full",
"statusVal": "",
"statusType": "auto",
"x": 1080,
"y": 520,
"wires": []
}
]
(Примечание: в импортированном коде потребуется настроить GPIO-пины и ID MQTT-брокера под ваш проект).
---
Итоги, отладка и возможные улучшения
В этом уроке мы совершили качественный скачок от простого управления приводами к точному процентному позиционированию. Это одна из самых востребованных функций в современных системах автоматизации, и теперь вы обладаете всеми необходимыми знаниями для её реализации.
Краткое повторение ключевых шагов:
Советы по отладке
- Используйте узлы `debug`: Размещайте их на выходе каждого ключевого узла (`mqtt in`, `function: Calculate`, `trigger`, `function: Update Context`), чтобы пошагово отслеживать, как преобразуется `msg` и какие значения принимают `topic` и `payload`.
- Следите за контекстом: На боковой панели Node-RED откройте вкладку `Context Data` и выберите `Flow`. Вы сможете в реальном времени видеть, как изменяется ваша переменная (`curtain_living_room_position`), и проверять, соответствует ли она ожиданиям.
- Визуальный статус: Не пренебрегайте `node.status()`. Информативные статусы под узлами мгновенно сообщат вам о текущем состоянии логики, ошибках или завершении операции.
- Начните с малого: Перед подключением реального привода, замените узлы `gpio out` на узлы `debug`, чтобы убедиться, что логика генерирует правильные последовательности `true`/`false` с верными задержками.
Возможные улучшения сценария
- Обработка команды `STOP`: Наш текущий сценарий не умеет останавливать движение на полпути. Для этого потребуется более сложная логика с использованием сброса узла `trigger`. Например, сообщение с `msg.reset = true` на вход `trigger` остановит его таймер.
- Синхронизация с физическими выключателями: Если шторами можно управлять и с настенного выключателя, система потеряет информацию об их положении. Решение — отслеживать входы, к которым подключены выключатели. При их активации можно либо запускать полный цикл "закрыть-открыть" для рекалибровки, либо, если выключатель просто доводит штору до концевика, принудительно устанавливать `current_percent` в `0` или `100`.
- Учет нелинейности движения: На некоторых приводах скорость в начале и в конце движения может отличаться. Для сверхточного позиционирования может потребоваться введение поправочных коэффициентов, но в 99% случаев для бытовых задач этим можно пренебречь.
Что дальше?
Мы рассмотрели управление приводом без обратной связи, где вся логика позиционирования лежит на контроллере. В следующих уроках мы перейдем к работе с более "умными" исполнительными устройствами, которые имеют встроенную обратную связь и сообщают о своем положении по промышленным протоколам, таким как Modbus, KNX или DALI. Это упрощает логику, но требует глубокого понимания протоколов взаимодействия.