ГлавнаяАкадемияИсполнительные устройства: интерлоки, таймауты → Усложненный сценарий: позиционирование шторы в % открытия

Усложненный сценарий: позиционирование шторы в % открытия

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

Введение: от полного хода к процентному позиционированию

В предыдущих уроках мы освоили базовое управление исполнительными устройствами, такими как приводы штор, роллет или шаровых кранов, используя бинарную логику «открыть/закрыть». Эта модель эффективна для множества задач, но не позволяет добиться гранулярного контроля. Пользователь не может, например, открыть штору на 75% или приглушить дневной свет, оставив ее на 30%. Цель данного урока — перейти от этой простой модели к процентному позиционированию.

Процентное позиционирование — это способность системы устанавливать привод в любое промежуточное положение, выраженное в процентах от 0 (полностью закрыто) до 100 (полностью открыто). Это открывает новые возможности для сценариев автоматизации: создание световых сцен, точное регулирование потока жидкости или имитация присутствия в доме.

> 🔗 Связанный материал: Для полного освоения материала данного урока и обеспечения точности работы сценариев, критически важно, чтобы вы изучили и применили на практике методы из урока COURSE-05-M06-L03: «Калибровка: определение времени полного хода привода». Без точного значения времени полного хода вся логика процентного позиционирования будет некорректной.

Фундаментом для нашего алгоритма служит значение времени полного хода (Full Travel Time), которое мы научились измерять. Зная это время, мы можем рассчитать, как долго нужно подавать напряжение на привод, чтобы он переместился на заданный процент. Простейшая формула выглядит так:

`ВремяРаботы = ВремяПолногоХода * (ЦелевойПроцент / 100)`

Например, если полное открытие шторы занимает 20 секунд (20 000 мс), а мы хотим открыть её на 50%, то привод должен работать:

`ВремяРаботы = 20000 мс * (50 / 100) = 10000 мс` или 10 секунд.

Архитектура нашего решения будет строиться на стандартных компонентах платформы HI:

  • Команды управления: Будут поступать по протоколу MQTT в стандартизированном формате. Например, сообщение в топик `devices/living_room/curtain/set` с `payload`, содержащим число от 0 до 100.
  • Логика: Будет реализована в среде Node-RED на контроллере. Она будет принимать MQTT-команды, вычислять необходимое время работы и направление движения, а затем управлять реле.
  • Исполнение: Два релейных выхода контроллера, подключенные к обмоткам «Открыть» и «Закрыть» привода, будут активироваться на вычисленное время.
  • Таким образом, мы создадим интеллектуальный слой между высокоуровневой командой («установить 75%») и низкоуровневым физическим действием (подать напряжение на реле на X миллисекунд).

    ---

    Проблема «текущего состояния» и её решение

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

    Если мы применим простую формулу, система рассчитает время работы как `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` (целевой процент) из MQTT.
  • Прочитать текущее состояние: Считываем `current_percent` (текущий процент) из `flow.context`. Если значение отсутствует (например, после первого запуска), принимаем его за 0% (полностью закрыто).
  • Определить направление:
  • * Если `target_percent > current_percent`, направление движения — «Открыть».

    * Если `target_percent < current_percent`, направление движения — «Закрыть».

    * Если `target_percent == current_percent`, никаких действий не требуется.

  • Рассчитать дельту движения: Разница между целевым и текущим положением определяет, на сколько процентов нужно сдвинуть штору. Формула для расчета времени работы теперь выглядит так:
  • `ВремяРаботы = ВремяПолногоХода * (abs(ЦелевойПроцент - ТекущийПроцент) / 100)`

    где `abs()` — это модуль числа, так как время не может быть отрицательным.

  • Выполнить движение: Активировать соответствующее реле на рассчитанное `ВремяРаботы`.
  • Обновить состояние: После завершения движения необходимо немедленно обновить переменную в контексте: `flow.set('current_percent', target_percent)`.
  • Этот алгоритм гарантирует, что привод будет двигаться ровно на необходимое расстояние из любого начального положения, обеспечивая точное и предсказуемое позиционирование.

    ---

    Практика: Функция расчета и управления состоянием

    Центральным элементом нашего потока будет узел `function`, который реализует описанный выше алгоритм. Он будет принимать целевой процент, выполнять все вычисления и формировать управляющее сообщение для следующих узлов.

    Создание и настройка узла `function`

  • Создайте в потоке узел `function`. Назовите его «Calculate Run Time».
  • Этот узел будет иметь один вход и один выход.
  • Логика внутри функции

    Скопируйте следующий 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]

    +----------------+

    Настройка узлов

  • `mqtt in` (Получение команды):
  • * Topic: `devices/living_room/curtain/set` (или ваш проектный топик).

    * QoS: 1.

    * Output: `a parsed JSON object` (если отправляете JSON) или `a string` (если отправляете просто число). Наша функция обработает число.

  • `function` (Calculate Run Time):
  • * Скопируйте код из предыдущего раздела.

  • `switch` (Маршрутизация по направлению):
  • * Property: `msg.topic`.

    * Правило 1: `==` (строка) `open`.

    * Правило 2: `==` (строка) `close`.

  • `trigger` (Генератор импульса) — 2 шт.:
  • * Создайте два одинаковых узла `trigger`, по одному для каждой ветки (`open` и `close`).

    * Send: `boolean` `true`.

    * then wait for: `msg.payload` (выберите `msg.` и впишите `payload`). Это заставит узел ждать на то количество миллисекунд, которое мы рассчитали.

    * then send: `boolean` `false`.

    * Эта конфигурация создаст импульс `true` -> (пауза) -> `false`, идеально подходящий для управления реле.

  • `rpi gpio out` (Управление реле) — 2 шт.:
  • * Pin: Выберите GPIO-пин, к которому подключено реле «Открыть» для первого узла и «Закрыть» для второго.

    * Type: `Digital output`.

    * Initialize pin state: `low (0)`.

  • `function` (Update Context):
  • Этот узел должен стоять после узлов `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;

  • (Опционально) `mqtt out` (Обратная связь):
  • * Подключите этот узел после `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-брокера под ваш проект).

    ---

    Итоги, отладка и возможные улучшения

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

    Краткое повторение ключевых шагов:

  • Калибровка: Определили точное время полного хода привода (`FullTravelTime`).
  • Хранение состояния: Использовали контекст Node-RED (`flow context`) для сохранения последнего известного положения шторы (`current_percent`).
  • Расчет дельты: Реализовали алгоритм, который вычисляет разницу между целевым и текущим положением для определения направления и времени движения.
  • Управление таймерами: Применили узел `trigger` для генерации управляющего импульса нужной длительности, динамически передавая ему время задержки.
  • Советы по отладке

    Возможные улучшения сценария

    Что дальше?

    Мы рассмотрели управление приводом без обратной связи, где вся логика позиционирования лежит на контроллере. В следующих уроках мы перейдем к работе с более "умными" исполнительными устройствами, которые имеют встроенную обратную связь и сообщают о своем положении по промышленным протоколам, таким как Modbus, KNX или DALI. Это упрощает логику, но требует глубокого понимания протоколов взаимодействия.