ГлавнаяАкадемияNode-RED: установка, flows, msg/JSON, отладка → Паттерн 'State Machine': хранение и управление состоянием

Паттерн 'State Machine': хранение и управление состоянием

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

Введение в концепцию состояния (State) в автоматизации

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

Рассмотрим простые примеры:

В контексте Node-RED все потоки можно разделить на два типа:

  • Stateless (не хранящие состояние): Это потоки, в которых обработка каждого сообщения (`msg`) происходит полностью независимо от предыдущих. Они работают по принципу «получил данные -> обработал -> отправил дальше». Типичный пример — поток, который принимает температуру с датчика, форматирует ее и отправляет в MQTT. Ему не важно, какая температура была секунду назад.
  • Stateful (хранящие состояние): Это потоки, в которых логика обработки текущего сообщения зависит от предыдущих событий. Классический пример — управление светом по одной кнопке без фиксации. Первое нажатие должно включить свет, второе — выключить. Чтобы принять решение (включить или выключить), поток должен знать текущее состояние света. Он должен «помнить», что произошло ранее.
  • Основная проблема, с которой сталкиваются инженеры при работе с Node-RED, заключается в том, что по своей природе платформа является stateless. Каждый узел `Function` по умолчанию не имеет памяти. Когда в него приходит новое сообщение, он ничего не знает о том, какие сообщения приходили до этого. Вся информация из предыдущего `msg` теряется, если ее специально не сохранить.

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

    ---

    Способы хранения состояния: Context в Node-RED

    пособы хранения состояния: Context в Node-RED

    Для решения проблемы хранения состояния в Node-RED встроен специальный механизм, который называется контекст (Context). Контекст — это, по сути, область памяти, доступная для узлов, где можно хранить переменные, которые сохраняют свои значения между различными выполнениями потока.

    Существует три области видимости (scope) контекста:

    | Область видимости | Доступ | Синтаксис в `Function` | Жизненный цикл | Типичное применение |

    | ----------------- | ------ | ---------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |

    | Node | Узел | `context.get('key')` | Сохраняется до перезапуска потока или полного развертывания | Внутренние счетчики, флаги, временные данные, необходимые только одному конкретному узлу. |

    | Flow | Вкладка| `flow.get('key')` | Сохраняется до перезапуска потока или полного развертывания | Основной инструмент для State Machine. Хранение состояния устройств или систем в рамках одной логической группы (например, состояние климата в гостиной). |

    | Global | Везде | `global.get('key')` | Сохраняется до перезапуска потока или полного развертывания | Глобальные переменные: режим «День/Ночь», статус «Дома/В отъезде», общие настройки системы. |

    Синтаксис и использование

    Для работы с контекстом внутри узла `Function` используются два основных метода: `get` для чтения и `set` для записи.

    // Чтение значения из контекста вкладки (flow)
    

    // Если значение 'lightState' еще не установлено, get() вернет undefined.

    // Мы используем оператор || для установки значения по умолчанию 'OFF'.

    let currentState = flow.get('lightState') || 'OFF';

    node.warn("Текущее состояние светильника: " + currentState);

    // Логика... предположим, мы решили изменить состояние

    let newState = 'ON';

    // Запись нового значения в контекст вкладки (flow)

    flow.set('lightState', newState);

    node.warn("Новое состояние светильника: " + newState);

    // Эти же методы работают для node и global контекста:

    // node.get('counter'); node.set('counter', 1);

    // global.get('dayNightMode'); global.set('dayNightMode', 'DAY');

    return msg;

    Persistence (постоянное хранение)

    По умолчанию все данные в контексте хранятся в оперативной памяти и теряются при перезагрузке контроллера или перезапуске Node-RED. Это неприемлемо для критически важных состояний (например, режим работы котельной или состояние системы безопасности).

    Чтобы решить эту проблему, контроллер HI позволяет настроить постоянное хранение (persistence) для `flow` и `global` контекстов. Данные будут сохраняться в файловой системе в директории `/data`, что гарантирует их восстановление после сбоя питания. Эта настройка выполняется в конфигурационном файле Node-RED (`settings.js`). На платформе HI это уже предварительно сконфигурировано для обеспечения надежности: персистентные данные записываются на физический накопитель в раздел `/data`.

    > ⚠️ Внимание: Использование файловой системы (путь `/data`) для хранения контекста на контроллерах с SD-картой может привести к ее преждевременному износу из-за большого количества циклов записи. Используйте эту опцию с осторожностью и только для критически важных данных, которые меняются не слишком часто. Не следует сохранять в персистентный контекст быстро меняющиеся значения, например, показания датчиков каждую секунду.

    Анализ производительности: * Плюсы: Очень быстро, минимальная задержка при чтении/записи. Идеально для временных данных и состояний, которые не критичны к потере.

    * Минусы: Данные теряются при перезагрузке.

    * Плюсы: Надежно. Данные переживают перезагрузку контроллера, так как физически записаны в `/data`.

    * Минусы: Медленнее, чем доступ к оперативной памяти. Каждый вызов `flow.set()` или `global.set()` вызывает операцию записи на диск, что создает дополнительную нагрузку и увеличивает износ носителя.

    Реализация паттерна 'Конечный автомат' (State Machine)

    Конечный автомат (State Machine) — это мощный паттерн проектирования, который позволяет управлять сложным поведением объекта через набор состояний и переходов между ними. Он идеально подходит для реализации stateful-логики в Node-RED, делая ее централизованной, предсказуемой и легко отлаживаемой.

    Рассмотрим пошаговое создание простого конечного автомата на примере управления диммируемой лампой, которая циклически переключается между тремя состояниями: `OFF` -> `ON` (100% яркости) -> `DIMMED` (50% яркости) -> `OFF`. Управление осуществляется одной кнопкой.

    Цель: Каждое новое сообщение (сигнал от кнопки) должно переводить автомат в следующее состояние. Flow Diagram:
    [Inject: "toggle"] -> [Function: "State Machine Logic"] -> [Debug: "Actuator Command"]
    
    Шаг 1: Инициализация потока

    Создайте на новой вкладке узел `Inject`, узел `Function` и узел `Debug`. Соедините их последовательно. Настройте узел `Inject` так, чтобы он отправлял пустой `msg.payload`, это будет имитировать нажатие кнопки.

    Шаг 2: Написание логики конечного автомата в узле `Function`

    Этот узел — сердце нашего автомата. Вся логика будет инкапсулирована здесь.

    // --- State Machine for a Dimmable Lamp ---
    
    

    // 1. Получение текущего состояния из контекста вкладки (flow).

    // Если 'lampState' еще не существует (первый запуск), устанавливаем 'OFF' по умолчанию.

    let currentState = flow.get('lampState') || 'OFF';

    let newState;

    let commandPayload;

    // 2. Логика переходов (сердце конечного автомата).

    // Определяем, каким будет следующее состояние в зависимости от текущего.

    switch (currentState) {

    case 'OFF':

    // Из OFF переходим в ON

    newState = 'ON';

    commandPayload = { state: 'ON', brightness: 100 };

    break;

    case 'ON':

    // Из ON переходим в DIMMED

    newState = 'DIMMED';

    commandPayload = { state: 'ON', brightness: 50 };

    break;

    case 'DIMMED':

    // Из DIMMED переходим в OFF

    newState = 'OFF';

    commandPayload = { state: 'OFF', brightness: 0 };

    break;

    default:

    // Аварийный случай: если состояние некорректно, сбрасываем в OFF.

    node.warn(`Unknown state detected: ${currentState}. Resetting to OFF.`);

    newState = 'OFF';

    commandPayload = { state: 'OFF', brightness: 0 };

    break;

    }

    // 3. Сохранение нового состояния в контексте.

    // Это критически важный шаг. Без него автомат не будет "помнить" свое новое состояние.

    flow.set('lampState', newState);

    // 4. Формирование управляющего сообщения для исполнительного устройства.

    // Мы используем стандартный контракт сообщения, как обсуждалось ранее.

    msg.payload = {

    value: commandPayload,

    source: 'state-machine-lamp-01',

    ts: Date.now()

    };

    // 5. Визуализация статуса для удобства отладки.

    node.status({

    fill: (newState === 'OFF') ? "red" : "green",

    shape: "dot",

    text: `State: ${newState}, Brightness: ${commandPayload.brightness}%`

    });

    // Отправляем сформированное сообщение дальше по потоку (на реле, DALI-драйвер и т.д.)

    return msg;

    Шаг 3: Тестирование и отладка

    Нажимая на узел `Inject`, наблюдайте за выводом в панели `Debug` и за статусом под узлом `Function`.

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

    ---

    Пример: Управление климатом с помощью конечного автомата

    Теперь рассмотрим более сложный и реалистичный пример — управление фанкойлом в офисном помещении. Автомат будет управлять режимами работы (`heating`, `cooling`, `fan_only`, `off`) на основе данных с датчика температуры и команд от пользователя.

    Входные данные:
  • Датчик температуры: MQTT-сообщения в топик `telemetry/office1/temperature` с `payload`, содержащим текущую температуру.
  • Пользовательский интерфейс: MQTT-сообщения для изменения уставки (`command/office1/climate/setpoint`) и режима (`command/office1/climate/mode`).
  • > 💡 Подсказка: Для сложных устройств удобно хранить состояние в виде объекта в `flow.context`. Например: `flow.set('climate_state', { mode: 'heating', setpoint: 22, fan_speed: 'auto' })`. Это упрощает чтение и модификацию состояния в одном узле `Function`.

    Логика конечного автомата: * Если `mode` = `cooling` и `current_temp` <= `setpoint` - 0.5°C, перейти в `fan_only`.

    * Если `mode` = `cooling` и `current_temp` > `setpoint` + 0.5°C, обратно включить `cooling`.

    * Аналогичная логика для `heating` (гистерезис в 1°C).

    * Пользователь может в любой момент принудительно изменить `mode` или `setpoint`.

    Реализация в узле `Function`:
    // --- State Machine for Climate Control ---
    
    

    // 1. Получаем объект состояния из контекста. Инициализируем при первом запуске.

    let state = flow.get('climate_state') || {

    mode: 'off',

    setpoint: 22.0,

    current_temp: null,

    fan_speed: 'auto',

    active_state: 'idle' // `idle`, `heating`, `cooling`

    };

    // 2. Обновляем объект состояния на основе входящего сообщения.

    if (msg.topic.includes('temperature')) {

    state.current_temp = parseFloat(msg.payload.value);

    } else if (msg.topic.includes('setpoint')) {

    state.setpoint = parseFloat(msg.payload);

    } else if (msg.topic.includes('mode')) {

    state.mode = msg.payload;

    }

    // 3. Основная логика принятия решений.

    let new_active_state = state.active_state;

    const hysteresis = 0.5; // Гистерезис в градусах Цельсия

    // Проверяем, что у нас есть актуальные данные о температуре

    if (state.current_temp !== null) {

    switch (state.mode) {

    case 'cooling':

    if (state.current_temp > state.setpoint + hysteresis) {

    new_active_state = 'cooling'; // Нужно охлаждать

    } else if (state.current_temp < state.setpoint - hysteresis) {

    new_active_state = 'idle'; // Температура достигнута, выключаем компрессор

    }

    break;

    case 'heating':

    if (state.current_temp < state.setpoint - hysteresis) {

    new_active_state = 'heating'; // Нужно греть

    } else if (state.current_temp > state.setpoint + hysteresis) {

    new_active_state = 'idle'; // Температура достигнута

    }

    break;

    case 'fan_only':

    new_active_state = 'fan_only';

    break;

    case 'off':

    new_active_state = 'off';

    break;

    }

    }

    // Если режим выключен, принудительно выключаем всё

    if (state.mode === 'off') {

    new_active_state = 'off';

    }

    // 4. Генерируем управляющую команду, только если состояние изменилось.

    if (new_active_state !== state.active_state) {

    state.active_state = new_active_state;

    // Формируем команду для Modbus-узла, управляющего фанкойлом

    msg.payload = {

    value: {

    state: state.active_state, // 'heating', 'cooling', 'off', 'fan_only'

    fan: (state.active_state !== 'off') ? state.fan_speed : 'off'

    },

    source: 'climate-fsm-office1',

    ts: Date.now()

    };

    // Сохраняем обновленный объект состояния

    flow.set('climate_state', state);

    // Обновляем статус

    node.status({ fill: "blue", shape: "dot", text: `Mode: ${state.mode} | Active: ${state.active_state} | T: ${state.current_temp}°C` });

    // Отправляем команду

    return msg;

    } else {

    // Состояние не изменилось, ничего не отправляем

    flow.set('climate_state', state); // Все равно сохраняем обновленные данные (например, новую температуру)

    node.status({ fill: "grey", shape: "dot", text: `Mode: ${state.mode} | Active: ${state.active_state} | T: ${state.current_temp}°C` });

    return null; // Останавливаем поток

    }

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

    ---

    Интеграция State Machine с другими паттернами

    Паттерн «Конечный автомат» редко используется в вакууме. Его настоящая сила раскрывается в комбинации с другими паттернами проектирования, что позволяет создавать по-настоящему надежные и отказоустойчивые потоки.

    > 🔗 Связанный материал: Подробное описание этих паттернов вы найдете в уроках COURSE-06-M05-L01 ('Gate') и COURSE-06-M05-L03 ('Debounce/Rate Limit').

    Комбинация с 'Debounce'

    Решение: Поместить узел `Debounce` (или `Rate Limit`, `Delay`) перед* узлом `Function` с конечным автоматом. `Debounce` пропустит только первый сигнал из серии, а все последующие, пришедшие в течение заданного интервала (например, 250 мс), проигнорирует. Схема потока:

    `[Button Input]` -> `[Debounce: 250ms]` -> `[Function: State Machine]` -> `[Actuator]`

    Этот простой прием защищает конечный автомат от «шумных» входных данных, обеспечивая корректный одиночный переход состояния на каждое реальное действие пользователя.

    Комбинация с 'Gate'

    Решение: Использовать паттерн `Gate` («Ворота»). Узел `Gate` ставится перед* конечным автоматом и может находиться в двух состояниях: «открыто» (пропускает сообщения) или «закрыто» (блокирует сообщения). Состоянием ворот управляет глобальный контекст. Схема потока:
                                    [Global State Checker] -> [Control Gate]
    

    |

    [Motion Sensor] -> [Gate: blocks/passes] -----+-----> [Function: Light State Machine] -> [Light Actuator]

    В данном случае узел `Gate` проверяет значение `global.get('systemMode')`. Если `systemMode` равно `'home'`, ворота открыты. Если `'away'` или `'maintenance'`, ворота закрываются, и сообщения от датчика движения не доходят до конечного автомата, соответственно, свет не включается. Это позволяет создавать иерархию управления, где глобальные состояния могут влиять на локальные подсистемы.

    ---

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

    Использование паттерна «Конечный автомат» является одним из ключевых навыков для создания профессиональных, надежных и легко поддерживаемых систем автоматизации на Node-RED.

    Ключевые преимущества:

    Рекомендации по выбору области видимости контекста:

    Важность инициализации

    Всегда предусматривайте инициализацию состояния. Ваш код должен корректно обрабатывать ситуацию, когда `context.get()` возвращает `undefined` (например, при самом первом запуске контроллера или после очистки контекста). Паттерн `let state = flow.get('myState') || 'defaultState'` является обязательным. Без него ваш автомат упадет с ошибкой при первом же обращении.

    Лучшие практики:

  • Изолируйте логику: Инкапсулируйте всю логику конечного автомата в один узел `Function`. Дайте ему понятное имя, например, «FSM: Climate Control» или «State Machine: Living Room Lights».
  • Документируйте состояния: Внутри узла `Function` или в узле `Comment` рядом с ним перечислите все возможные состояния и условия переходов. Это бесценно для будущего обслуживания.
  • Используйте `node.status`: Активно используйте визуализацию статуса. Это самый быстрый способ понять, в каком состоянии находится ваш автомат, прямо из редактора Node-RED.
  • Храните состояние в объектах: Для сложных систем не бойтесь использовать объекты для хранения состояния. Это структурирует данные и упрощает их модификацию.
  • Что дальше

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