ГлавнаяАкадемияИсполнительные устройства: интерлоки, таймауты → Практика: Добавление обработки ошибок в сценарий управления клапаном

Практика: Добавление обработки ошибок в сценарий управления клапаном

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

Введение: Исходный сценарий и его уязвимости

В работе инженера по автоматизации создание сценария, который работает в идеальных условиях — это лишь половина задачи. Профессионализм проявляется в том, как система ведет себя при сбоях. Рассмотрим типовой сценарий управления шаровым краном с электроприводом для перекрытия воды, реализованный на платформе HI. В "счастливом" сценарии все выглядит просто:

  • Пользователь нажимает кнопку в интерфейсе, или срабатывает датчик протечки.
  • В Node-RED поступает MQTT-сообщение с командой, например, `{"command": "CLOSE"}`.
  • Сценарий формирует команду для исполнительного устройства (в нашем случае, релейного модуля, подключенного по Modbus).
  • Устройство исполняет команду, кран закрывается.
  • Система получает подтверждение и отображает актуальный статус.
  • Этот базовый поток, или "счастливый путь" (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;

    Анализ потенциальных точек отказа:

    Этот сценарий абсолютно не защищен от реальных проблем на объекте. Давайте проанализируем, что может пойти не так:

    Для некритичных систем (например, декоративная подсветка) такие сбои неприятны, но не катастрофичны. Но когда речь идет о клапане, перекрывающем воду, последствия могут быть разрушительными. Если система "думает", что закрыла воду по сигналу от датчика протечки, а на самом деле клапан остался открыт из-за обрыва шины, это приведет к гарантированному затоплению.

    Именно для предотвращения таких ситуаций и вводится концепция обработки ошибок и принудительного перехода в "Безопасное Состояние" (Safe-State), которую мы реализуем на практике в этом уроке.

    ---

    Шаг 1: Интеграция нод Catch и Status для отлова ошибок

    Первый и самый важный шаг в построении отказоустойчивой системы — научиться обнаруживать сам факт ошибки. Для этого в Node-RED существуют два мощных инструмента: узлы `Catch` и `Status`.

    > 🔗 Связанный материал: Подробное описание работы нод `Catch` и `Status` вы найдете в уроках "Отлов ошибок выполнения с помощью ноды Catch" (COURSE-05-M05-L02) и "Использование ноды Status для мониторинга состояния" (COURSE-05-M05-L04).

    Наша задача — создать единую точку, куда будут стекаться все сообщения об ошибках, чтобы затем направить их в общую логику обработки.

    ### Практические действия:

  • Добавление узла `Catch`:
  • * Перетащите узел `Catch` из палитры на рабочее поле вашего потока.

    * Дважды кликните по нему. В настройках в поле `Scope` выберите `all nodes on current flow`. Это означает, что узел будет ловить все необработанные ошибки, возникшие на текущей вкладке.

    * Дайте узлу осмысленное имя, например, "Перехват ошибок Modbus".

  • Добавление узла `Status`:
  • * Перетащите узел `Status` на рабочее поле.

    * Дважды кликните по нему. В поле `For` нажмите кнопку `select nodes` и выберите тот узел, который непосредственно взаимодействует с оборудованием. В нашем случае это `Modbus-Write: Управление клапаном`.

    * Этот узел будет генерировать сообщения каждый раз, когда меняется статус целевого узла (например, `connecting`, `connected`, `error`, `timeout`).

  • Объединение потоков ошибок:
  • * Теперь соедините выход узла `Catch` и выход узла `Status` с одним и тем же следующим узлом. Обычно для этого используют узел `Function`, который будет анализировать тип ошибки. Назовем его "Анализатор Ошибок".

    Теперь, если узел `Modbus-Write` столкнется с проблемой (например, таймаутом ответа от устройства), произойдет одно из двух событий (или оба):

    ### Структура сообщений об ошибках

    Ключевым моментом является понимание структуры объекта `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

  • Определение: Для нашего сценария 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:
  • * Выход из узла "Формирование команды 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`:
  • Разместите узел `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`. Теперь завершим эту логику.

  • Добавление узла `Change` и `MQTT Out`:
  • * После узла "Формирование команды 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": []

    }

    ]

    ### Практические советы по тестированию

    Настоящая проверка системы — это имитация сбоя. Чтобы убедиться, что ваша логика работает, выполните следующие тесты на объекте:

  • Тест на ошибку связи: Отключите Modbus-устройство от шины RS-485 или выключите его питание. Отправьте команду на управление клапаном. Вы должны увидеть, как узел `Status` станет красным, сработает узел `Catch`, и в топик `hi/alarms/valve_control` придет сообщение об ошибке.
  • Тест на таймаут подтверждения: Если у вас есть поток получения подтверждения, временно отключите его. Отправьте команду на управление клапаном. Через 25 секунд узел `Trigger` должен отправить сообщение об ошибке, которое запустит тот же самый сценарий Safe-State.
  • Тест на восстановление: После устранения неисправности (подключите устройство обратно) система должна позволить вернуть управление в штатный режим. Ошибку может потребоваться сквитировать (сбросить), например, отправив специальную команду или нажав кнопку в интерфейсе. Механизм квитирования — тема для отдельного, более продвинутого урока.
  • ### Что дальше?

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