Практика: Добавление обработки ошибок в сценарий управления клапаном
Введение: Исходный сценарий и его уязвимости
В работе инженера по автоматизации создание сценария, который работает в идеальных условиях — это лишь половина задачи. Профессионализм проявляется в том, как система ведет себя при сбоях. Рассмотрим типовой сценарий управления шаровым краном с электроприводом для перекрытия воды, реализованный на платформе HI. В "счастливом" сценарии все выглядит просто:
Этот базовый поток, или "счастливый путь" (happy path), может выглядеть следующим образом:
[mqtt in] --(команда)--> [Function: Подготовить команду] --(payload)--> [Modbus-Write: Управление клапаном] --(успех)--> [Debug: Успешно]
`hi/valves/main-water/set`
Внутри узла `Function: Подготовить команду` находится простая логика:
// Пример входящего сообщения: msg.payload = "OPEN" или msg.payload = "CLOSE"
let command = msg.payload;
if (command === "CLOSE") {
// Формируем payload для узла Modbus-Write
// Предположим, запись 'true' в Coil с адресом 0 закрывает клапан
msg.payload = {
'value': true,
'fc': 5, // FC 5: Force Single Coil
'unitid': 15, // Адрес Modbus-устройства
'address': 0
};
return msg;
} else if (command === "OPEN") {
msg.payload = {
'value': false,
'fc': 5,
'unitid': 15,
'address': 0
};
return msg;
}
// Игнорируем некорректные команды
return null;
Анализ потенциальных точек отказа:
Этот сценарий абсолютно не защищен от реальных проблем на объекте. Давайте проанализируем, что может пойти не так:
- Отказ оборудования: Modbus-устройство (релейный модуль) может выйти из строя, на нем может пропасть питание.
- Обрыв линии связи: Физическое повреждение кабеля шины RS-485 или сетевого кабеля для Modbus TCP.
- Ошибка исполнительного механизма: Электропривод клапана заклинило, и он не может физически изменить свое положение.
- Сбой программного обеспечения: Ошибка в конфигурации узла `Modbus-Write`, неправильный адрес или функция.
- Отсутствие подтверждения (таймаут): Команда на закрытие отправлена, но узел `Modbus-Write` не получает ответа от устройства в течение заданного времени. В этом состоянии система не знает, закрылся клапан или нет.
Для некритичных систем (например, декоративная подсветка) такие сбои неприятны, но не катастрофичны. Но когда речь идет о клапане, перекрывающем воду, последствия могут быть разрушительными. Если система "думает", что закрыла воду по сигналу от датчика протечки, а на самом деле клапан остался открыт из-за обрыва шины, это приведет к гарантированному затоплению.
Именно для предотвращения таких ситуаций и вводится концепция обработки ошибок и принудительного перехода в "Безопасное Состояние" (Safe-State), которую мы реализуем на практике в этом уроке.
---
Шаг 1: Интеграция нод Catch и Status для отлова ошибок
Первый и самый важный шаг в построении отказоустойчивой системы — научиться обнаруживать сам факт ошибки. Для этого в Node-RED существуют два мощных инструмента: узлы `Catch` и `Status`.
> 🔗 Связанный материал: Подробное описание работы нод `Catch` и `Status` вы найдете в уроках "Отлов ошибок выполнения с помощью ноды Catch" (COURSE-05-M05-L02) и "Использование ноды Status для мониторинга состояния" (COURSE-05-M05-L04).
Наша задача — создать единую точку, куда будут стекаться все сообщения об ошибках, чтобы затем направить их в общую логику обработки.
### Практические действия:
* Перетащите узел `Catch` из палитры на рабочее поле вашего потока.
* Дважды кликните по нему. В настройках в поле `Scope` выберите `all nodes on current flow`. Это означает, что узел будет ловить все необработанные ошибки, возникшие на текущей вкладке.
* Дайте узлу осмысленное имя, например, "Перехват ошибок Modbus".
* Перетащите узел `Status` на рабочее поле.
* Дважды кликните по нему. В поле `For` нажмите кнопку `select nodes` и выберите тот узел, который непосредственно взаимодействует с оборудованием. В нашем случае это `Modbus-Write: Управление клапаном`.
* Этот узел будет генерировать сообщения каждый раз, когда меняется статус целевого узла (например, `connecting`, `connected`, `error`, `timeout`).
* Теперь соедините выход узла `Catch` и выход узла `Status` с одним и тем же следующим узлом. Обычно для этого используют узел `Function`, который будет анализировать тип ошибки. Назовем его "Анализатор Ошибок".
Теперь, если узел `Modbus-Write` столкнется с проблемой (например, таймаутом ответа от устройства), произойдет одно из двух событий (или оба):
- `Modbus-Write` сгенерирует внутреннюю ошибку, которую перехватит `Catch`.
- `Modbus-Write` изменит свой визуальный статус, что будет зафиксировано узлом `Status`.
### Структура сообщений об ошибках
Ключевым моментом является понимание структуры объекта `msg`, который генерируют эти узлы.
| Узел-источник | Ключевое свойство | Пример `msg.error` или `msg.status` | Пояснение |
| :------------ | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------ | :-------------------------------------------------------------------------------------------------------------------------- |
| `Catch` | `msg.error` | `{ "message": "Port Not Open", "source": { "id": "...", "type": "modbus-write", "name": "Управление клапаном" } }` | Содержит текстовое описание ошибки и информацию об узле, который ее сгенерировал. Идеально для логгирования. |
| `Status` | `msg.status` | `{ "fill": "red", "shape": "dot", "text": "timeout: id: 15, address: 0, quantity: 1", "source": { "id": "...", "type": "modbus-write", "name": "..." } }` | Содержит объект с визуальными параметрами статуса. Поле `text` часто дублирует суть ошибки. Удобно для отслеживания сбоев связи. |
Внутри узла "Анализатор Ошибок" мы можем написать логику, которая определяет источник проблемы.
// msg.error приходит от ноды Catch
// msg.status приходит от ноды Status
if (msg.error) {
node.warn("Ошибка от Catch: " + msg.error.message);
// Добавляем флаг, что это критическая ошибка
msg.isCriticalError = true;
msg.errorSource = 'Catch';
msg.errorDetails = msg.error.message;
} else if (msg.status && msg.status.fill === 'red') {
node.warn("Ошибка состояния от Status: " + msg.status.text);
// Добавляем флаг, что это критическая ошибка
msg.isCriticalError = true;
msg.errorSource = 'Status';
msg.errorDetails = msg.status.text;
} else {
// Если это не ошибка (например, статус 'connected'), останавливаем поток
return null;
}
return msg;
Теперь на выходе узла "Анализатор Ошибок" мы имеем стандартизированное сообщение, которое сигнализирует о сбое и готово к передаче в логику Safe-State.
---
Шаг 2: Реализация логики "Безопасного Состояния" (Safe-State)
Получив сигнал об ошибке, система должна немедленно и безусловно выполнить действие, которое минимизирует потенциальный ущерб. Для клапана на воду это всегда закрытие. Эта логика должна быть максимально простой, надежной и изолированной.
> ⚠️ Внимание: Логика Safe-State должна быть максимально простой и надежной. Не добавляйте в эту ветку обработчики, которые сами могут вызвать ошибку. Команда на закрытие клапана должна уходить напрямую на исполнительное устройство, минуя сложные сценарии.
### Определение и реализация потока Safe-State
* После нашего "Анализатора Ошибок" добавьте узел `Function`, который будет называться "Формирование команды Safe-State".
Этот узел будет на входе получать сообщение об ошибке, а на выходе — генерировать универсальную команду на закрытие клапана*.
Вот пример кода для этого узла:
// Этот узел получает на вход сообщение с флагом isCriticalError = true
// Сохраняем информацию об исходной ошибке для логирования
let originalError = msg.errorDetails || "Неизвестная ошибка";
msg.topic = "hi/alarms/valve_control"; // Топик для отправки тревоги
// Формируем payload для тревожного сообщения в MQTT
let alarmPayload = {
timestamp: new Date().toISOString(),
deviceId: "valve-main-water",
status: "ERROR",
description: "Сбой управления клапаном: " + originalError,
actionTaken: "Активация Safe-State (принудительное закрытие)"
};
// Кладём его в отдельное свойство, чтобы отправить позже
msg.alarm = alarmPayload;
// =========================================================================
// ОСНОВНАЯ ЛОГИКА: ФОРМИРОВАНИЕ КОМАНДЫ НА ЗАКРЫТИЕ
// Эта команда не зависит от того, что стало причиной ошибки.
// Она всегда одинакова и максимально проста.
// =========================================================================
msg.payload = {
'value': true, // Команда на закрытие (согласно нашему примеру)
'fc': 5,
'unitid': 15,
'address': 0
};
// Выводим статус, чтобы было видно, что сработала логика Safe-State
node.status({ fill: "red", shape: "ring", text: "SAFE-STATE ACTIVE!" });
// Сообщение с командой msg.payload пойдет на узел Modbus-Write.
// Сообщение с тревогой msg.alarm мы обработаем отдельно.
return msg;
* Выход из узла "Формирование команды Safe-State" необходимо подключить... обратно ко входу узла `Modbus-Write: Управление клапаном`.
* Таким образом, мы переиспользуем тот же самый узел для отправки команды. Если первая попытка отправить команду вызвала ошибку (например, `OPEN`), то вторая, принудительная, попытается отправить команду `CLOSE`.
> ℹ️ Информация: Иногда для критических систем создают дублирующий, "аварийный" узел `Modbus-Write`, настроенный с более агрессивными параметрами (например, большее количество повторных попыток). В нашем случае для упрощения мы используем тот же узел.
Фрагмент потока теперь выглядит так:
+----------------------------------+
| |
--(ошибка)-->[Catch / Status]-->[Function: Анализатор Ошибок]-->[Function: Команда Safe-State]--+
| |
[Modbus-Write] <-----------------------------------------------------------------------------+
^
|
[Function: Подготовить команду] <--- [mqtt in]
Таким образом, мы замкнули петлю обратной связи по ошибке. Любой сбой при взаимодействии с устройством теперь приводит к повторной, но уже принудительной попытке перевести его в безопасное состояние.
---
Шаг 3: Обработка таймаутов и система оповещений
Мы научились ловить явные ошибки. Но что делать с самой коварной ситуацией: команда отправлена, узел `Modbus-Write` не вернул ошибку, но и подтверждения о реальном исполнении нет? Устройство может быть "зависшим", а система будет находиться в неведении. Этот сценарий требует обработки таймаута ожидания подтверждения.
> 💡 Подсказка: Выбирайте время таймаута с запасом. Если по паспорту клапан закрывается за 15 секунд, установите таймаут на 25-30 секунд, чтобы избежать ложных срабатываний из-за небольших сетевых задержек или особенностей протокола.
Для реализации таймера ожидания идеально подходит узел `Trigger`.
### Реализация таймера ожидания с помощью `Trigger`
Разместите узел `Trigger` в потоке сразу после* узла, который отправляет команду (`Modbus-Write`).
* Настройте его следующим образом:
* `Send`: `nothing` (первоначально ничего не отправлять).
* `then wait for`: `25` `seconds` (устанавливаем наш таймаут).
* `then send`: `{"error": "Timeout", "description": "Не получено подтверждение о смене состояния клапана"}` (сообщение, которое будет отправлено, если таймер не сбросят).
* Внизу поставьте галочку `Handle every message separately`.
Выход из `Modbus-Write` (который сигнализирует об успешной отправке* команды) подключите ко входу `Trigger`. Это "взведет" таймер.
* Выход из `Trigger` подключите ко входу нашего "Анализатора Ошибок". Если таймер сработает, он отправит туда сообщение об ошибке, которое запустит уже знакомую нам логику Safe-State.
* Сброс таймера: Теперь нужен механизм сброса. Предположим, у нас есть отдельный поток, который периодически опрашивает реальное состояние клапана (например, считывая состояние концевого выключателя или ответный регистр Modbus). Как только этот поток получает подтверждение, что клапан изменил состояние, он должен отправить любое сообщение на вход узла `Trigger`. Это сбросит таймер, и таймаут не сработает.
### Интеграция системы оповещений
Мы уже подготовили почву для этого в предыдущем шаге, сформировав `msg.alarm`. Теперь завершим эту логику.
* После узла "Формирование команды Safe-State" добавьте узел `Change`. Его задача — переместить объект тревоги из `msg.alarm` в `msg.payload`, потому что узел `mqtt out` отправляет именно `msg.payload`.
* Настройте `Change` так: `Set` `msg.payload` `to` `msg.alarm`.
* Подключите к его выходу узел `mqtt out`.
* Настройте `mqtt out` на отправку сообщений в специальный топик, например, `hi/alarms/valve_control`.
Теперь любая нештатная ситуация (ошибка Modbus, таймаут подтверждения) приведет не только к попытке закрыть клапан, но и к отправке стандартизированного тревожного сообщения. Это сообщение может быть принято системой верхнего уровня, панелью визуализации или отправлено администратору через Telegram/Push-уведомление.
Пример сообщения в топике `hi/alarms/valve_control`:
{
"timestamp": "2023-10-27T12:35:01.123Z",
"deviceId": "valve-main-water",
"status": "ERROR",
"description": "Сбой управления клапаном: timeout: id: 15, address: 0, quantity: 1",
"actionTaken": "Активация Safe-State (принудительное закрытие)"
}
---
Резюме: Финальная схема и методы тестирования
Собрав все элементы воедино, мы получили надежный и отказоустойчивый сценарий. Он не только выполняет свою основную функцию, но и активно противостоит наиболее частым сбоям, информируя о них и минимизируя возможный ущерб.
Финальная архитектура потока:// Основной поток команд
[mqtt in] -> [Function: Подготовить команду] -> [Modbus-Write] -> [Trigger: Таймер ожидания]
^ |
| (сброс таймера) |
+----[Получение подтверждения]----+
// Поток обработки ошибок
[Catch] --------+
|
[Status] -------+-> [Function: Анализатор Ошибок] -> [Function: Команда Safe-State] -+-> (обратно на Modbus-Write)
| |
[Trigger] ------+ +-> [Change: alarm->payload] -> [mqtt out: ALARMS]
(выход по таймауту)
### Финальный код для импорта
Ниже представлен JSON-код, который вы можете импортировать в Node-RED. Он содержит все описанные узлы и логику. Вам потребуется лишь адаптировать настройки `Modbus-Client` и MQTT-брокера под вашу систему.
[
{
"id": "f5e8a1b3.1c9d",
"type": "tab",
"label": "COURSE-05-M05-L05: Отказоустойчивый клапан",
"disabled": false,
"info": ""
},
{
"id": "a1b2c3d4.e5f6",
"type": "mqtt in",
"z": "f5e8a1b3.1c9d",
"name": "Команда на управление клапаном",
"topic": "hi/valves/main-water/set",
"qos": "1",
"broker": "YOUR_MQTT_BROKER_ID",
"x": 150,
"y": 100,
"wires": [
[
"b2c3d4e5.f6a7"
]
]
},
{
"id": "b2c3d4e5.f6a7",
"type": "function",
"z": "f5e8a1b3.1c9d",
"name": "Подготовить команду Modbus",
"func": "// Пример входящего сообщения: msg.payload = \"OPEN\" или msg.payload = \"CLOSE\"\nlet command = msg.payload;\n\nif (command === \"CLOSE\") {\n msg.payload = {\n 'value': true,\n 'fc': 5, \n 'unitid': 15, \n 'address': 0\n };\n return msg;\n} else if (command === \"OPEN\") {\n msg.payload = {\n 'value': false,\n 'fc': 5,\n 'unitid': 15,\n 'address': 0\n };\n return msg;\n}\n\nreturn null;",
"outputs": 1,
"noerr": 0,
"x": 400,
"y": 100,
"wires": [
[
"c3d4e5f6.a7b8"
]
]
},
{
"id": "c3d4e5f6.a7b8",
"type": "modbus-write",
"z": "f5e8a1b3.1c9d",
"name": "Управление клапаном",
"showStatusActivities": true,
"showErrors": false,
"server": "YOUR_MODBUS_CLIENT_ID",
"x": 630,
"y": 200,
"wires": [
[],
[]
]
},
{
"id": "d4e5f6a7.b8c9",
"type": "catch",
"z": "f5e8a1b3.1c9d",
"name": "Перехват ошибок Modbus",
"scope": null,
"uncaught": false,
"x": 160,
"y": 300,
"wires": [
[
"e5f6a7b8.c9d0"
]
]
},
{
"id": "e5f6a7b8.c9d0",
"type": "function",
"z": "f5e8a1b3.1c9d",
"name": "Анализатор Ошибок",
"func": "if (msg.error) {\n node.warn(\"Ошибка от Catch: \" + msg.error.message);\n msg.isCriticalError = true;\n msg.errorSource = 'Catch';\n msg.errorDetails = msg.error.message;\n} else if (msg.status && msg.status.fill === 'red') {\n node.warn(\"Ошибка состояния от Status: \" + msg.status.text);\n msg.isCriticalError = true;\n msg.errorSource = 'Status';\n msg.errorDetails = msg.status.text;\n} else if (msg.payload && msg.payload.error === 'Timeout') {\n node.warn(\"Сработал таймаут ожидания подтверждения\");\n msg.isCriticalError = true;\n msg.errorSource = 'Timeout';\n msg.errorDetails = msg.payload.description;\n} else {\n return null;\n}\n\nreturn msg;\n",
"outputs": 1,
"noerr": 0,
"x": 420,
"y": 360,
"wires": [
[
"f6a7b8c9.d0e1"
]
]
},
{
"id": "f6a7b8c9.d0e1",
"type": "function",
"z": "f5e8a1b3.1c9d",
"name": "Формирование команды Safe-State",
"func": "let originalError = msg.errorDetails || \"Неизвестная ошибка\";\nmsg.topic = \"hi/alarms/valve_control\";\n\nlet alarmPayload = {\n timestamp: new Date().toISOString(),\n deviceId: \"valve-main-water\",\n status: \"ERROR\",\n description: \"Сбой управления клапаном: \" + originalError,\n actionTaken: \"Активация Safe-State (принудительное закрытие)\"\n};\nmsg.alarm = alarmPayload;\n\nmsg.payload = {\n 'value': true, \n 'fc': 5,\n 'unitid': 15,\n 'address': 0\n};\n\nnode.status({ fill: \"red\", shape: \"ring\", text: \"SAFE-STATE ACTIVE!\" });\n\nreturn msg;",
"outputs": 1,
"noerr": 0,
"x": 710,
"y": 360,
"wires": [
[
"c3d4e5f6.a7b8",
"a2b3c4d5.e6f7"
]
]
},
{
"id": "status-node",
"type": "status",
"z": "f5e8a1b3.1c9d",
"name": "Статус клапана",
"scope": [
"c3d4e5f6.a7b8"
],
"x": 140,
"y": 360,
"wires": [
[
"e5f6a7b8.c9d0"
]
]
},
{
"id": "trigger-node",
"type": "trigger",
"z": "f5e8a1b3.1c9d",
"name": "Таймер ожидания 25с",
"op1": "",
"op2": "{\"error\":\"Timeout\",\"description\":\"Не получено подтверждение о смене состояния клапана\"}",
"op1type": "nul",
"op2type": "json",
"duration": "25",
"extend": false,
"units": "s",
"reset": "",
"bytopic": "all",
"x": 890,
"y": 100,
"wires": [
[
"e5f6a7b8.c9d0"
]
]
},
{
"id": "a2b3c4d5.e6f7",
"type": "change",
"z": "f5e8a1b3.1c9d",
"name": "alarm -> payload",
"rules": [
{
"t": "set",
"p": "payload",
"pt": "msg",
"to": "alarm",
"tot": "msg"
}
],
"action": "",
"property": "",
"from": "",
"to": "",
"reg": false,
"x": 750,
"y": 440,
"wires": [
[
"b3c4d5e6.f7a8"
]
]
},
{
"id": "b3c4d5e6.f7a8",
"type": "mqtt out",
"z": "f5e8a1b3.1c9d",
"name": "Отправка тревоги",
"topic": "hi/alarms/valve_control",
"qos": "1",
"retain": "false",
"broker": "YOUR_MQTT_BROKER_ID",
"x": 980,
"y": 440,
"wires": []
}
]
### Практические советы по тестированию
Настоящая проверка системы — это имитация сбоя. Чтобы убедиться, что ваша логика работает, выполните следующие тесты на объекте:
### Что дальше?
В этом уроке мы сделали огромный шаг от простого скрипта к надежной, промышленной логике управления. Мы научились предвидеть проблемы и автоматически реагировать на них. В следующих уроках мы рассмотрим более сложные сценарии, включая управление устройствами с промежуточными положениями, реализацию блокировок (интерлоков) и создание комплексных систем безопасности, где несколько устройств работают в связке для предотвращения аварий.