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

По тз все просто:

Есть клеевая установка
        ↓
Она управляется через PLC
        ↓
Проколом Modbus TCP
        ↓
Какой то наш сервис
        ↓
REST API

То есть вроде бы поднял FastAPI, написал правильные запросы к PLC и готово.

Но написание самого API - это как раз лёгкая часть. А вся сложность скрывалась внутри логики PLC.

Поэтому с вопроса как управлять клеевыми головами я автоматически перешел к более сложному мы точно знаем, какой регистр какой клеевой головой управляет?

Сначала просто проверки

Первым этапом была обычная проверка связи и вообще понимания что за что отвечает.

Edge-компьютер (у нас на эту роль сошли старенькие Mikrotik) должен был через промышленную сеть подключаться к контроллерам клеевых установок по Modbus TCP.

Когда ты работаешь с реальным физическим оборудованием, надо сначала просто проверить:

  1. доступно ли сетевое оборудование;
  2. открывается ли TCP-соединение с контроллером;
  3. отвечает ли Modbus;
  4. можно ли прочитать известный регистр;
  5. совпадает ли прочитанное значение с HMI (да-да, это самая важная часть, как оказалось).

Что получилось в итоге:

Сеть была доступна, но первоначально Modbus endpoint отвечал Connection refused. Пришлось сначала разобраться с сетевой частью и подключением самого Mikrotik (а это не просто "вставил lan провод в сетевую карту своего пк").

После настройки удалось нормально читать регистры всех установок и сравнивать значения с панелью оператора.

Тогда же обнаружилась ещё одна особенность конкретных контроллеров: они нормально отвечали только с одним фиксированным Transaction ID = 0.

Прочитать регистр мало - понять, за что он отвечает сложно

Следующая проблема появилась уже после того, как Modbus заработал.

PLC контроллер прекрасно отдавал какие-то числа.

Но PLC не пишет рядом:

это температура второго шланга, а это температура насоса

Ты получаешь адрес и какое-то числовое значение.

А дальше надо разобраться, что физически за ними находится.

Почему так? Потому, что зачастую на реальном производстве вам никто не даст оригинал PLC где будут прописаны все обозначения и логика всего PLC. А запросы производителю оборудования с целью разьяснить 5-10-100 регистров могут длится годами.

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

Очевидно, что клей там не превратился в плазму.

Но просто написать в коде:

это значение означает "канал отключён"

тоже было нельзя.

Поэтому мы пошли сверять эти позиции с HMI (панелью администратора).

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

Поэтому в API они стали выглядеть так:

{
  "value": null,
  "quality": "unavailable"
}

а не как фантастическая температура.

Это вообще оказалось хорошим правилом для всего проекта:

Modbus даёт тебе регистр. Смысл регистра подтверждается только реальным оборудованием.

Т.е. буквально с наладчиком, он на HMI, а я через консоль в PLC играем в "угадай мелодию". Он выставляет значения а я сверяю показатели с PLC.

А потом выясняется, что одинаковый адрес может означать разные вещи

Самый интересный момент начался при составлении карты клеевых голов и шлангов.

Логика нам казалась очевидной. Пример: есть набор последовательных температурных слотов.

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

слот 1 → HEAD1
слот 2 → HEAD2
слот 3 → HEAD3
слот 4 → HEAD4
слот 5 → HOSE1
...

и использовать её для всех установок дальше.

Но как раз на этапе проверки мы поняли, что конфигурации установок отличались.

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

Из-за этого один и тот же PLC-слот в разных конфигурациях может относиться к разному оборудованию.

Например, на одной установке определённая позиция действительно относится к клеевой голове.

На другой установке та же позиция уже соответствует шлангу.

Причём технически всё продолжает работать идеально.

PLC возвращает значение.

API отдаёт JSON.

Ошибок нет.

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

Почему так спросите вы? Ответ: спросите у того китайца который собирал этот станок.

Пришлось отказаться от одной общей карты

После проверки стало понятно, что общего файла вида:

register_map.py

для всех установок быть не может.

Вместо него я сделал профиль каждой конкретной установки.

Условно:

profiles/
    unit_01.yaml
    unit_02.yaml
    unit_03.yaml
    unit_04.yaml

В профиле хранится фактическая конфигурация конкретной установки:

HEAD1
HEAD2
HOSE1
HOSE2
TANK
...

Туда же попали признаки активности каналов и те позиции, назначение которых ещё не подтверждено:

CONFIRMED
CANDIDATE
UNKNOWN

Да и такие кандидаты в PLC тоже есть. Я их вижу. Но в HMI мы их найти не смогли. А т.к. управлять мы должны были только головами, по ним нам все было известно. Поэтому UNKNOWN оборудование мы просто не трогаем.

После этого этап API

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

Сервис я сделал на FastAPI + asyncio.

При этом HTTP-клиенты не ходят в PLC напрямую. Это неочевидно, но очень необходимо, когда имеешь дело с оборудованием на сотни тысяч долларов.

Я специально не делал такую архитектуру:

GET /telemetry
      ↓
Modbus request
      ↓
PLC
      ↓
HTTP response

Потому что тогда количество обращений к контроллеру начинает зависеть от количества клиентов API. А я знаю сколько их будет? А если они "положат" PLC?

Вместо этого для каждой установки работает отдельный worker.

PLC
 ↓
worker
 ↓
опрос примерно раз в секунду
 ↓
snapshot в RAM
 ↓
REST API

Worker постоянно читает состояние установки и обновляет готовый snapshot в памяти. Но главное, что 1hz для mitsubishi PLC это безопасно.

А REST API уже просто отдаёт этот snapshot клиентам, неважно сколько их.

Если завтра к API подключатся два клиента или двадцать - PLC от этого не начнёт получать в десять раз больше запросов и не остановит оборудование из-за непредвиденной ошибки.

Почему для каждой установки свой worker

Здесь я специально разделил установки друг от друга.

Если одна потеряла соединение или контроллер перестал отвечать, остальные должны продолжить работать.

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

Внутри каждого worker Modbus-запросы идут последовательно.

Один запрос. Один ответ

Следующий запрос и следующий ответ.

При timeout или protocol error соединение закрывается и создаётся заново.

А snapshot обновляется целиком.

То есть клиент не увидит несогласованное состояние оборудования, например:

HEAD1 - уже новый цикл опроса
HEAD2 - предыдущий
HOSE1 - ещё более старый

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

В итоге получилось примерно так:

                  ┌→ worker 1 → PLC 1
                  ├→ worker 2 → PLC 2
FastAPI + RAM ←───┼→ worker 3 → PLC 3
                  └→ worker 4 → PLC 4
        ↓
     REST API

Плюс небольшая история в памяти, события и аудит значимых действий.

С записью всё намного сложнее

Читать состояние PLC относительно безопасно.

Запись - совсем другая история.

Технически можно было сделать такой API:

POST /write

register = 1234
value = 1650

И получить прекрасный универсальный способ писать в любой доступный регистр контроллера.

Я думаю вы и сами понимаете какой это бред.

Внешний клиент вообще не должен знать адреса PLC и че-то там им выставлять.

Он должен работать с физическими сущностями. Говоря простым языком надо делать "защиту от дурака".

Например:

{
  "type": "set_temperature_setpoint",
  "zone": "HEAD1",
  "value_c": 165.0
}

А уже внутри сервиса происходит перевод:

HEAD1
 ↓
профиль конкретной установки
 ↓
подтверждённый PLC-адрес
 ↓
проверки
 ↓
Modbus write

Перед записью сервис должен проверить хотя бы:

  • существует ли HEAD1 на этой установке;
  • подтверждён ли её адрес;
  • разрешена ли запись в эту зону;
  • входит ли температура в допустимый диапазон;
  • не слишком ли большой скачок уставки;
  • актуально ли состояние PLC;
  • соответствует ли профиль текущей конфигурации установки.

Если что-то не сходится - команды нет.

Мне лучше выслушать ругань наладчика, который что-то не так "накрутил" в MES-системе управления производством, чем сломать станок.

Самый неприятный случай - неизвестно, прошла запись или нет

Есть ещё одна интересная вещь, которая редко встречается в обычном backend.

Представим:

API отправил команду
        ↓
PLC мог её получить. А мог в это же время получить другую команду на HMI
        ↓
соединение ушло в timeout или вернуло error

Что тогда делать?

Автоматически повторить?

А если первая команда на самом деле успешно выполнилась?

Тогда мы отправим воздействие дважды.

Поэтому такие операции надо рассматривать не как обычную ошибку с retry, а как отдельное состояние: uncertain

То есть:

мы не можем достоверно сказать, выполнилась команда или нет.

И никакого автоматического повторения.

Для HTTP API это выглядит непривычно.

Для физического оборудования - логично.

И ещё есть оператор с HMI

Есть ещё один участник процесса, которого нельзя игнорировать:

оператор.

Он продолжает работать через штатную HMI. У него вообще своя работа, он знать не знает про наши API и MES-системы (а может и плевать хотел)

А значит потенциально одну уставку могут менять два источника:

HMI ──┐
      ├── PLC
API ──┘

Допустим:

API прочитал: 165 °C

оператор поставил на HMI: 170 °C

API почти одновременно записал: 160 °C

Победит последняя запись.

Поэтому кроме технических проверок нужны ещё и нормальные правила эксплуатации.

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

Именно поэтому на первом этапе управление через API у меня вообще физически отключено.

Чтение работает.

Архитектура команд готова.

Проверки есть.

Но запись открывается только после отдельного подтверждения конкретных зон и сценариев и постепенного внедрения системы операторам на производство.

Что в итоге

В итоге из такой задачи:

сделать API к клеевым установкам

получился небольшой промышленный сервис с довольно сложной архитектурой:

клеевые установки
       ↓
PLC
       ↓
Modbus TCP
       ↓
отдельные polling workers
       ↓
ежесекундные snapshots в RAM
       ↓
FastAPI
       ↓
логика и смысловые команды

Плюс:

  • отдельный профиль для каждой конфигурации;
  • подтверждённая карта голов, шлангов и других зон;
  • явные UNKNOWN для неподтверждённых позиций (что с ними еще делать мы пока незнаем);
  • изоляция отказов между установками;
  • отсутствие прямого доступа клиентов к PLC-регистрам;
  • проверки перед любой записью;
  • отключённое по умолчанию управление.

Сам FastAPI здесь оказался, наверное, самой простой частью проекта.

Основная работа была: сеть, особенности контроллеров, Modbus, проверка регистров, HMI и физическое сопоставление адресов с клеевыми головами и шлангами (я думал оператор меня прибьет, когда мы пытались сопоставить мои PLC и его HMI слоты).

Вывод

Этот проект хорошо показал мне разницу между обычным API и API к реальному оборудованию.

В обычном backend неправильный mapping чаще всего заканчивается неправильным полем в JSON или ошибкой в базе.

Здесь неправильный mapping означает, что ты хотел изменить температуру клеевой головы, а команду потенциально отправил совсем в другую физическую зону.

Есть несколько специфических PLC, несколько клеевых установок, проверенная карта адресов и сервис, который переводит всё это в понятный и относительно безопасный API.