ГлавнаяАкадемияNode-RED: установка, flows, msg/JSON, отладка → Паттерн 'Command/Acknowledge' (Cmd/Ack)

Паттерн 'Command/Acknowledge' (Cmd/Ack)

Урок 4 · Node-RED: установка, flows, msg/JSON, отладка · 30 мин · theory

Введение в паттерн 'Command/Acknowledge'

В профессиональных системах автоматизации недостаточно просто отправить команду устройству. Необходимо иметь гарантии, что команда была не только получена, но и успешно выполнена. Паттерн Command/Acknowledge (Cmd/Ack), или "Команда/Подтверждение", представляет собой фундаментальный механизм для построения надежных систем, который решает именно эту задачу.

> ℹ️ Информация: Паттерн Cmd/Ack критически важен при работе с шинными протоколами (Modbus RTU, KNX), где отсутствие ответа от устройства является штатной ситуацией при физическом обрыве связи или сбое питания.

Основная проблема, которую решает данный паттерн — это рассинхронизация состояния. Представьте сценарий: ваша система автоматизации отправляет команду на включение освещения в помещении. Управляющая логика в Node-RED немедленно переводит виртуальное состояние светильника в "Включено" и отображает это в интерфейсе пользователя. Однако из-за сбоя в сети MQTT, временного отключения питания релейного модуля или обрыва шины Modbus, физическое устройство команду не получило. В результате система считает, что свет горит, а на самом деле в комнате темно. Это создает ложное представление о состоянии объекта и подрывает доверие к системе в целом.

Паттерн Cmd/Ack предотвращает подобные ситуации, внедряя строгий цикл обратной связи.

📋 Ключевые понятия:

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

  • Отправка команды (Cmd): Контроллер отправляет команду на исполнительное устройство.
  • Запуск таймера: Одновременно с отправкой команды запускается внутренний таймер ожидания.
  • Ожидание подтверждения (Ack): Система переходит в состояние ожидания ответного сообщения от устройства.
  • Обработка результата:
  • * Успех: Если подтверждение (Ack) приходит до истечения тайм-аута, таймер сбрасывается. Система регистрирует успешное выполнение команды и переходит в новое стабильное состояние.

    * Тайм-аут: Если подтверждение не приходит в установленное время, таймер срабатывает. Система регистрирует ошибку выполнения команды. Это событие может запустить логику оповещения, повторной отправки или перевода устройства в состояние "Нет связи".

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

    Таким образом, паттерн Command/Acknowledge превращает любую "ненадежную" операцию в предсказуемый и контролируемый процесс.

    ---

    Базовая реализация с помощью узла `trigger`

    В экосистеме Node-RED основной "строительный блок" для реализации паттерна Cmd/Ack — это узел `trigger`. Его функционал идеально подходит для создания окна ожидания с обработкой тайм-аута.

    Узел `trigger` работает по простому, но мощному принципу: при получении входящего сообщения он может выполнить до трех действий:

  • Немедленно отправить первое сообщение (которое мы будем использовать как нашу команду).
  • Затем перейти в режим ожидания на заданный интервал времени.
  • Если за это время его работа не будет прервана, отправить второе сообщение (которое мы будем использовать как сигнал о тайм-ауте).
  • Ключевая возможность для паттерна Cmd/Ack — это способность узла `trigger` сбрасывать свой таймер. Если во время ожидания он получает сообщение, содержащее свойство `msg.reset`, таймер аннулируется, и второе "тайм-аутное" сообщение никогда не будет отправлено. Именно это поведение мы будем использовать для обработки сообщения-подтверждения (Ack).

    Схема потока и конфигурация узла

    Рассмотрим базовую схему потока, реализующую этот паттерн:

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

    (Входная | Function: | +----------------+ +----------------+

    команда) -->| Формирование | -> | trigger | -> | mqtt out | --> (Отправка Cmd)

    | команды (Cmd) | +----------------+ +----------------+

    +-----------------+ |

    |(Тайм-аут)

    v

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

    | Function: |

    | Обработка | --> (Логирование ошибки)

    | тайм-аута |

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

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

    (Входное +----------------+ | Function: |

    подтверждение)| mqtt in |-> | Формирование |

    (Ack) -->| (статус) | | msg.reset |

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

    | |

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

    |

    v

    (вход узла trigger)

    Конфигурация узла `trigger`:
  • Send: `the original message payload`. Это позволит пропустить через узел сформированную команду. В некоторых случаях здесь можно установить специфичное сообщение, но для начала достаточно передавать исходное.
  • then wait for: Установите время ожидания, например, `5 seconds`. Это и есть наш тайм-аут.
  • then send: Выберите `a specific message`. В `payload` укажите объект, который будет сигнализировать об ошибке.
  •     {

    "error": "TIMEOUT",

    "description": "No acknowledgement received from device."

    }

  • Handling: Убедитесь, что выбрана опция `handle each message separately`. Это критически важно, чтобы несколько одновременных команд не мешали друг другу.
  • Обработка подтверждения (Ack):

    Сообщение-подтверждение, приходящее от устройства (например, через `mqtt in`), должно быть преобразовано в сообщение для сброса таймера. Это делается с помощью узла `Function`, который стоит на пути от `Ack` к входу `trigger`.

    // Код в узле Function для формирования msg.reset
    

    // Предполагается, что входящий msg - это Ack от устройства

    // Главное - создать это свойство

    msg.reset = true;

    // Как правило, никакие другие данные в этом сообщении не нужны

    msg.payload = null;

    return msg;

    Когда узел `trigger`, находящийся в состоянии ожидания, получит это сообщение, он немедленно прекратит отсчет времени и не будет отправлять сообщение о тайм-ауте. Цикл успешно завершен. Если же это сообщение не придет в течение 5 секунд, `trigger` отправит свое "тайм-аутное" сообщение по второму выходу, сигнализируя о сбое.

    ---

    Практический пример: Управление светом по MQTT

    Давайте соберем полноценный рабочий поток для управления "умной" лампой или реле, работающим по протоколу MQTT.

    > 💡 Подсказка: Используйте разные топики для команд (`.../set`) и для статусов (`.../status`). Это стандартная практика, которая упрощает логику и отладку потоков. Мы подробно рассматривали проектирование иерархии топиков MQTT в предыдущих уроках.

    Сценарий:

    Сборка потока

    Ниже представлен JSON потока, который можно импортировать в Node-RED.

    [
    

    {

    "id": "a1b2c3d4.e5f6g7",

    "type": "inject",

    "z": "f8e7d6c5.b4a3b2",

    "name": "Включить свет (Cmd)",

    "props": [

    {

    "p": "payload"

    }

    ],

    "repeat": "",

    "crontab": "",

    "once": false,

    "onceDelay": 0.1,

    "topic": "",

    "payload": "true",

    "payloadType": "bool",

    "x": 150,

    "y": 100,

    "wires": [

    [

    "b1c2d3e4.f5g6h7"

    ]

    ]

    },

    {

    "id": "b1c2d3e4.f5g6h7",

    "type": "function",

    "z": "f8e7d6c5.b4a3b2",

    "name": "Формирование команды",

    "func": "// Сохраняем исходную команду в msg.command\n// для последующей обработки в ветке тайм-аута\nmsg.command = msg.payload;\n\n// Устанавливаем топик для отправки команды\nmsg.topic = 'hi/office/light-01/set';\n\nreturn msg;",

    "outputs": 1,

    "noerr": 0,

    "initialize": "",

    "finalize": "",

    "x": 350,

    "y": 100,

    "wires": [

    [

    "c1d2e3f4.g5h6i7"

    ]

    ]

    },

    {

    "id": "c1d2e3f4.g5h6i7",

    "type": "trigger",

    "z": "f8e7d6c5.b4a3b2",

    "name": "Ожидание Ack (3 сек)",

    "op1": "",

    "op2": "{\n \"error\": \"TIMEOUT\",\n \"topic\": \"hi/office/light-01/set\",\n \"command_sent\": msg.command\n}",

    "op1type": "passthrough",

    "op2type": "json",

    "duration": "3",

    "extend": false,

    "overrideDelay": false,

    "units": "s",

    "reset": "",

    "bytopic": "all",

    "topic": "topic",

    "outputs": 2,

    "x": 550,

    "y": 160,

    "wires": [

    [

    "d1e2f3g4.h5i6j7"

    ],

    [

    "e1f2g3h4.i5j6k7"

    ]

    ]

    },

    {

    "id": "d1e2f3g4.h5i6j7",

    "type": "mqtt out",

    "z": "f8e7d6c5.b4a3b2",

    "name": "Отправить команду в MQTT",

    "topic": "",

    "qos": "1",

    "retain": "",

    "broker": "your-mqtt-broker-id",

    "x": 780,

    "y": 100,

    "wires": []

    },

    {

    "id": "e1f2g3h4.i5j6k7",

    "type": "debug",

    "z": "f8e7d6c5.b4a3b2",

    "name": "ОШИБКА: ТАЙМ-АУТ",

    "active": true,

    "tosidebar": true,

    "console": false,

    "tostatus": true,

    "complete": "true",

    "targetType": "full",

    "statusVal": "payload.error",

    "statusType": "msg",

    "x": 770,

    "y": 220,

    "wires": []

    },

    {

    "id": "f1g2h3i4.j5k6l7",

    "type": "mqtt in",

    "z": "f8e7d6c5.b4a3b2",

    "name": "Получение статуса (Ack)",

    "topic": "hi/office/light-01/status",

    "qos": "1",

    "datatype": "auto",

    "broker": "your-mqtt-broker-id",

    "x": 160,

    "y": 280,

    "wires": [

    [

    "g1h2i3j4.k5l6m7"

    ]

    ]

    },

    {

    "id": "g1h2i3j4.k5l6m7",

    "type": "switch",

    "z": "f8e7d6c5.b4a3b2",

    "name": "Статус соответствует команде?",

    "property": "payload",

    "propertyType": "msg",

    "rules": [

    {

    "t": "eq",

    "v": "true",

    "vt": "bool"

    },

    {

    "t": "eq",

    "v": "false",

    "vt": "bool"

    }

    ],

    "checkall": "true",

    "repair": false,

    "outputs": 2,

    "x": 390,

    "y": 280,

    "wires": [

    [

    "h1i2j3k4.l5m6n7"

    ],

    [

    "h1i2j3k4.l5m6n7"

    ]

    ]

    },

    {

    "id": "h1i2j3k4.l5m6n7",

    "type": "function",

    "z": "f8e7d6c5.b4a3b2",

    "name": "Сброс триггера",

    "func": "msg.reset = true;\nreturn msg;",

    "outputs": 1,

    "noerr": 0,

    "initialize": "",

    "finalize": "",

    "x": 580,

    "y": 280,

    "wires": [

    [

    "c1d2e3f4.g5h6i7"

    ]

    ]

    }

    ]

    Разбор `msg` на каждом этапе

  • После `Inject` -> `Function`:
  • Сообщение подготавливается к отправке. Мы сохраняем исходную команду для контекста на случай ошибки.

        {

    "payload": true,

    "topic": "hi/office/light-01/set",

    "command": true

    }

  • Выход 1 узла `trigger` -> `MQTT Out`:
  • Узел `trigger` пропускает исходное сообщение без изменений.

        {

    "payload": true,

    "topic": "hi/office/light-01/set",

    "command": true

    }

  • Приход подтверждения в `MQTT In`:
  • Устройство ответило, `msg.payload` теперь содержит его фактический статус.

        {

    "topic": "hi/office/light-01/status",

    "payload": true,

    "qos": 1,

    "retain": false

    }

  • `Switch` -> `Function (Сброс триггера)`:
  • Сообщение отфильтровано, и теперь на его основе формируется команда сброса.

        // msg на входе в function:

    // { "topic": "hi/office/light-01/status", "payload": true, ... }

    // msg на выходе из function:

    {

    "topic": "hi/office/light-01/status",

    "payload": true, // payload не важен, но он сохраняется

    "reset": true // Это свойство - главное

    }

    Это сообщение поступает на вход `trigger` и отменяет таймер.

  • Выход 2 узла `trigger` (ветка тайм-аута) -> `Debug`:
  • Если подтверждение не пришло, `trigger` генерирует свое собственное сообщение об ошибке, которое мы задали в настройках.

        {

    "payload": {

    "error": "TIMEOUT",

    "description": "No acknowledgement received from device."

    },

    "topic": "hi/office/light-01/set",

    "command": true // Это поле сохранилось из исходного msg

    }

    Наличие `command_sent` (или `command`) в сообщении об ошибке очень полезно для логирования, так как мы точно знаем, какая именно команда не была подтверждена.

    ---

    Расширенная логика: обработка ошибок и повторные попытки (Retries)

    Просто зафиксировать ошибку тайм-аута — это лишь половина дела. В реальных системах часто требуется предпринять активные действия для восстановления связи. Наиболее распространенной стратегией является повторная отправка команды.

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

    Реализация счетчика попыток

    Для отслеживания количества повторных попыток идеально подходит контекст потока (flow context), который мы подробно рассматривали ранее.

    Цикл повтора:
  • Исходная команда проходит через узел `Function`, который инициализирует счетчик в контексте: `flow.set('light_retry_count', 0)`.
  • Команда отправляется через связку Cmd/Ack, как описано выше.
  • В случае тайм-аута сообщение об ошибке поступает не в `Debug`, а в специальный узел `Function` "Обработчик тайм-аута".
  • Этот узел считывает счетчик `flow.get('light_retry_count')`, инкрементирует его и проверяет, не превышен ли лимит (например, 3 попытки).
  • Если лимит не превышен, узел заново формирует исходную команду и отправляет ее на вход Cmd/Ack-логики, запуская цикл заново.
  • Если лимит превышен, узел формирует финальное сообщение об ошибке, которое отправляется в систему оповещения и аудиторского журналирования (Audit Log), а счетчик сбрасывается.
  • Вот как может выглядеть код узла `Function` "Обработчик тайм-аута":

    // Получаем ID команды или устройства для уникального счетчика
    

    const deviceId = 'light-01';

    const retryCounterKey = `${deviceId}_retry_count`;

    const MAX_RETRIES = 3;

    // Получаем текущее значение счетчика, если нет - то 0

    let retryCount = flow.get(retryCounterKey) || 0;

    if (retryCount < MAX_RETRIES) {

    // Увеличиваем счетчик

    retryCount++;

    flow.set(retryCounterKey, retryCount);

    // Выводим в статус информацию о повторной попытке

    node.status({ fill: "yellow", shape: "ring", text: `Retry ${retryCount}/${MAX_RETRIES}` });

    // Восстанавливаем исходную команду из сообщения о тайм-ауте

    // (мы предусмотрительно сохранили ее в trigger)

    msg.payload = msg.command_sent;

    msg.topic = msg.topic;

    // Возвращаем сообщение на первый выход,

    // который соединен с началом Cmd/Ack логики

    return msg;

    } else {

    // Лимит попыток исчерпан

    node.status({ fill: "red", shape: "dot", text: `FAIL: ${MAX_RETRIES} retries` });

    // Сбрасываем счетчик для будущих команд

    flow.set(retryCounterKey, 0);

    // Формируем финальное сообщение о фатальной ошибке

    msg.payload = {

    error: "FATAL_TIMEOUT",

    description: `Device ${deviceId} did not acknowledge command after ${MAX_RETRIES + 1} attempts.`,

    original_command: msg.command_sent

    };

    // Добавляем запись для аудита

    msg.audit = {

    level: "ERROR",

    event: "DeviceUnresponsive",

    message: `Устройство ${deviceId} не отвечает.`

    };

    // Возвращаем сообщение на второй выход, для логирования

    return [null, msg];

    }

    Паттерн 'Exponential Backoff'

    Для еще более надежных систем применяется стратегия экспоненциальной выдержки (Exponential Backoff). Суть в том, чтобы увеличивать задержку между повторными попытками. Например:

    Это дает "передышку" сети или устройству, если они перегружены. Реализуется это добавлением узла `delay` в цикл повтора, где задержка вычисляется динамически на основе значения счетчика `retryCount`.

    ---

    Итоги и лучшие практики

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

    > 🔗 Связанный материал: Для более глубокого понимания управления состояниями см. урок `COURSE-06-M05-L03` "Паттерн State Machine". Паттерн Cmd/Ack часто используется для контроля переходов между состояниями в конечном автомате.

    Резюме

    Мы узнали, что Cmd/Ack решает проблему рассинхронизации состояния между логикой контроллера и физическим миром. Ключевыми "строительными блоками" для его реализации в Node-RED являются:

    Лучшие практики Cmd/Ack

    При внедрении этого паттерна в ваши проекты придерживайтесь следующих правил:

  • Всегда обрабатывайте ветку тайм-аута. Незавершенный выход из узла `trigger` — это бомба замедленного действия. Как минимум, логируйте это событие.
  • Задавайте осмысленные задержки. Тайм-аут должен быть достаточно большим, чтобы устройство успело ответить с учетом сетевых задержек, но не настолько большим, чтобы система казалась "заторможенной". Типичные значения: 1-5 секунд.
  • Четко разделяйте каналы команд и статусов. Использование отдельных MQTT-топиков (`.../set` и `.../status`) или разных регистров Modbus для команд и обратной связи — это стандарт, который значительно упрощает логику.
  • Применяйте ограниченные повторы (Retries). Для критически важных операций внедряйте логику повторных попыток с обязательным ограничением их количества, чтобы избежать перегрузки системы.
  • Изолируйте логику Cmd/Ack. Повторяющуюся логику Cmd/Ack для одного и того же типа устройств лучше всего вынести в отдельный субпоток (subflow). Это сделает ваши основные потоки чище и проще для чтения.
  • Паттерн Command/Acknowledge является неотъемлемой частью арсенала профессионального инженера по автоматизации. Его правильное применение напрямую влияет на отказоустойчивость, предсказуемость и, в конечном счете, на качество всей создаваемой вами системы.

    Что дальше

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