ADR-010: Удалённое управление парком — общая доска в групповом чате MAX¶
Дата: 2026-05-18
Статус: Accepted (реализация — отдельный task)
Ветка: dev-0.12
Затрагивает: integration/maxbot/*, integration/bot_manager.*, commands.cpp, API/integration/maxbot/*, web-UI карточка MaxBot
Контекст¶
Задача¶
Парк из 20–30+ моечных боксов, в каждом — контроллер AWDC с 3-канальным дозатором. Боксы стоят в DMZ-сети мойки. Оператор, которому нужно менять параметры дозаторов, находится вне этой сети, в обычном интернете.
Ограничения объекта: - у мойки нет белого IP (серый адрес + CGNAT у провайдера); - VPN не рассматривается (решение заказчика); - своего почтового сервера нет, SMTP/IMAP — публичные сервисы (проблемы доставки, спам-фильтры, блокировки) — отвергнуто; - свой бэкенд / хаб / облачная инфраструктура — нежелательно («строить инфраструктуру»); - у оператора — стандартные средства связи и интернет.
Сетевой инвариант¶
Устройство за NAT/CGNAT не может принимать входящие соединения. Достучаться до него можно только так: устройство само инициирует исходящее соединение к публично достижимой точке встречи, и оператор общается через эту же точку. Точка встречи обязательна — вопрос лишь в том, насколько она «тяжёлая».
Отвергнутые варианты¶
Бот MAX «по боту на контроллер»¶
getUpdates бота — это очередь «один потребитель»: апдейт доставляется
ровно одному поллеру (первому), для остальных исчезает. Несколько
контроллеров на один токен делят одну очередь и воруют апдейты друг
у друга. Подтверждено скриптом test/maxbot_queue_probe.sh:
Чтение через getUpdates потребляет сообщения. Значит каждому
контроллеру нужен свой бот. Но MAX ограничивает: 5 ботов на
организацию (и с 08.2025 создание ботов — только для верифицированных
юрлиц РФ). Парк >5 так не покрыть.
Канал MAX¶
Канал годится для чтения (GET /messages идемпотентен), но посты
канала анонимны — поле Message.sender для каналов отсутствует
(документировано как optional). Подтверждено test/maxbot_messages_probe.sh,
Тест 0:
Без sender не работает whitelist по пользователю и нет аудита «кто
поменял». Канал работоспособен, но как запасной вариант.
Шлюз/хаб, своё облако, reverse-туннель¶
Все требуют либо своей серверной инфраструктуры, либо железа на каждой площадке. Заказчиком отклонено как избыточное. (Анализ этих вариантов сохранён в истории обсуждения; здесь не повторяется.)
Эмпирическая проверка¶
Перед принятием решения семантика MAX Bot API проверена двумя скриптами
(оставлены в test/ как dev-инструменты):
test/maxbot_queue_probe.sh — GET /updates:
- чтение потребляет очередь (single-consumer) — путь «много
контроллеров на один токен через updates» закрыт.
test/maxbot_messages_probe.sh — GET /messages:
- GET /messages?chat_id=X — идемпотентен: 3 чтения подряд дают
идентичный список (одинаковые mid/seq). Чтение ничего не
потребляет → парк контроллеров может читать параллельно.
- сообщение несёт body.seq — монотонный 18-значный номер → курсор
для дедупа.
- from — это пагинация в глубь истории («сообщения старше точки»),
не «с этого времени и новее». Для нас не нужен.
- групповой чат, бот = админ → GET /messages возвращает всю
историю, и у каждого сообщения есть sender:
(бот как обычный участник группы — GET /messages пуст; права
администратора обязательны).
- POST /messages в чат от имени бота-админа → HTTP 200, сообщение
опубликовано.
Решение¶
Удалённое управление парком — через общую «доску» в приватном групповом чате MAX. Один бот, один токен, один групповой чат.
Почему групповой чат, а не канал: оба дают идемпотентное чтение
GET /messages, но в группе у сообщений есть sender → работает
авторизация по user_id и аудит. Канал этого не даёт.
Архитектура¶
┌─────────────── приватный групповой чат MAX ───────────────┐
│ участники: бот (админ) + операторы │
оператор ──▶│ "DISPENSE@box-7 2 45" ◀── команда (пост оператора) │
│ "[box-7] OK: ch2 prop=45" ◀── ответ (пост бота) │
└────────────────────────────────────────────────────────────┘
▲ POST /messages │ GET /messages (идемпотентно)
│ (ответы) ▼
┌────────┴─────────┐ ┌──────────┴──────────┐
│ контроллер box-7│ ... │ контроллеры box-1..N│
│ токен + chat_id │ │ тот же токен+chat_id│
└──────────────────┘ └─────────────────────┘
- Один бот (админ группы), один токен. Все контроллеры прошиты
одним токеном и одним
chat_idгруппы. Лимит «5 ботов» не задеваем — бот один. В самой группе контроллеры не состоят: они лишь читают/пишут через токен бота; участники группы — бот + операторы. - Команда: оператор постит в группу текст с адресацией:
<CMD>@<device-id> [аргументы]. - Контроллеры раз в
poll_interval_sделаютGET /messages?chat_id= X&count=N, берут сообщения сseqбольше сохранённого, отбирают адресованные себе, проверяют отправителя, выполняют. - Ответ: контроллер (через бота)
POST /messagesв ту же группу с префиксом[<имя устройства>].
Почему это снимает оба тупика¶
| Тупик | Как снят |
|---|---|
| Лимит 5 ботов | бот один на весь парк |
Single-consumer getUpdates |
используется GET /messages — идемпотентен |
| Нет отправителя (канал) | групповой чат даёт sender.user_id |
Модель безопасности¶
Два рубежа, без зависимости от анонимности:
1. Приватная группа — кто в группе, тот и может постить команды.
Управление доступом = управление участниками группы.
2. Whitelist allowed_user_id — контроллер проверяет
sender.user_id команды по CSV-списку разрешённых (механизм
csvContains уже есть). Не в списке — команда отклоняется, в лог.
Ранее предусматривался третий рубеж —
security_token(общий секрет в теле сообщения). С рабочим whitelist по реальномуuser_idон оказался избыточным и был убран из кода и UI; синтаксис#tokenв адресной части (<CMD>@<dev>#token) парсится для совместимости, но игнорируется.
Аудит: sender.name + user_id пишутся в лог при каждой команде —
видно, кто и что менял.
Сегментация парка — один бот, много групп¶
Бот может состоять в любом числе групповых чатов (лимит «5 ботов на
организацию» считает ботов, не группы). Каждый контроллер привязан
конфигом (board_chat_id) к одной группе. Отсюда естественное
деление парка:
- группа на мойку / регион / бригаду;
- операторы каждой группы видят только свой трафик, контроллеры читают только свою группу, бот — один на весь парк;
- имена устройств уникальны в пределах группы (в разных группах имена могут повторяться — это разные чаты).
Так board-схема масштабируется на большой парк без единого «шумного» чата: вместо одного чата на 30 боксов — несколько групп по площадкам, и при этом всё ещё один бот-ключ.
Участники группы — бот (админ) + люди-операторы. Контроллеры в группе не «состоят» (они не аккаунты мессенджера) — они привязаны к ней конфигом и читают её через токен бота извне.
Как реализовать¶
Новый режим интеграции MAX — «board» — в дополнение к текущему
DM/getUpdates режиму (его оставляем для одиночной установки).
1. Конфигурация (NVS namespace int_maxbot)¶
| Ключ | Назначение |
|---|---|
token |
токен бота (одинаков на всём парке) |
mode |
dm (как сейчас) или board |
board_chat_id |
chat_id группового чата-доски |
allowed_uid |
CSV разрешённых user_id (уже есть) |
security_token |
опциональный секрет (уже есть) |
poll_interval_s |
период опроса (уже есть) |
last_seq |
курсор — последний обработанный body.seq |
Адресат команды <device-id> — берётся из уже существующего поля
«Имя устройства» (ui/dev_name, ADR не нужен — добавлено ранее).
Рекомендация операторам: короткие имена без пробелов (box-7).
2. MaxBotClient — новый метод¶
getChatMessages(chat_id, count) → GET /messages?chat_id=X&count=N,
парсит массив messages[] в список структур:
{ seq, mid, text, sender_user_id, sender_name, is_bot }.
3. Board-цикл опроса (в bot_manager/maxbot)¶
Раз в poll_interval_s:
1. GET /messages?chat_id=board_chat_id&count=N (N ≈ 50).
2. Отсортировать по seq по возрастанию, обработать seq > last_seq.
3. Для каждого сообщения:
- is_bot == true → пропустить (ответы бота и чужих ботов);
- распарсить <CMD>@<device-id> [args]; если @<device-id> ≠ наше
имя и ≠ @all → пропустить;
- sender_user_id нет в allowed_uid → отклонить, записать в лог;
- security_token задан и не совпал → отклонить;
- распарсить аргументы → CommandRegistry::execute();
- POST /messages ответ: [<имя устройства>] <результат>.
4. last_seq = max(seq), сохранить в NVS.
Первый запуск (last_seq == 0): принять max(seq) за базу
без выполнения — свежепрошитый контроллер не должен переигрывать
всю историю команд.
4. Парсинг аргументов команд¶
Сейчас dispatch() передаёт обработчику только text/from, поэтому
команды с аргументами (DISPENSE с номером канала) через бот не
работают. Board-режим парсит позиционные аргументы в params:
STATUS@box-7→STATUS, без аргументов;DISPENSE@box-7 2→DISPENSE,params.ch = 2;PROP@box-7 2 45→params.ch = 2,params.value = 45;GET@box-7 all→GET,args=["all"](специальная форма — вернуть параметры по всем 3 каналам одним сообщением);SET@box-7 2 prop=45 mode=pulse→SET,args=["2","prop=45","mode=pulse"](мульти-ключевая форма).
Управляющие команды (PROP/MODE/DISPENSE/STOP/SET) и GET <ch>
на выключенном канале отвечают «канал N выключен или отсутствует на
этом контроллере». Исключение — SET с enable=1: она сама включает
канал, поэтому проверка пропускается. GET all показывает все 3 канала,
выключенные помечает (off).
Грамматика и набор команд — в task-доке реализации.
5. Ответы¶
POST /messages?chat_id=board_chat_id, тело {"text": "[<имя>] ..."}.
Префикс имени устройства уже реализован (botDevicePrefix()).
6. Web-UI¶
В карточку MaxBot — переключатель режима (dm/board) и поле
board_chat_id. Остальное (токен, whitelist, имя устройства) уже есть.
Требование к развёртыванию¶
Бот обязан быть администратором группового чата — иначе
GET /messages по группе возвращает пусто (проверено). Это шаг
настройки, как и для канала-варианта.
Что получаем¶
- Удалённое управление парком 20–30+ контроллеров за CGNAT — без белого IP, без VPN, без хаба, без своего сервера, без железа на площадке. Точка встречи — сам MAX, которым и так пользуются.
- Масштаб — ограничен не архитектурой, а rate-limit'ами MAX и
размером группы (операторов). 30 контроллеров при
poll=120сдают ~0.25 rps наGET /messages— мизер. - Авторизация по пользователю + аудит — whitelist по
user_id, имя оператора в логе. - Store-and-forward — история группы и есть очередь команд: бокс был offline → вернулся → прочитал и выполнил пропущенное. Оператор видит ответ-подтверждение.
- Двусторонний канал — команды и подтверждения в одном месте.
- Переиспользование — HTTP/TLS-клиент,
CommandRegistry, имя устройства,csvContains, префикс — всё уже в прошивке. - Нагрузка на heap — как у текущего бота: один TLS-handshake на опрос, без постоянного соединения. Новой постоянной TLS-арены нет.
- Оператор работает из обычного приложения MAX — без спец-софта.
Ограничения и последствия¶
- Латентность входящих команд =
poll_interval_s(не realtime). Для смены параметров дозатора приемлемо. - Зависимость от MAX. Умер бот/аккаунт/API — удалённое управление недоступно. Но дозирование полностью локально (параметры в NVS, ESP32 автономен): отказ MAX = «временно рулим на месте по локальному web-UI», а не остановка моек. Точка отказа вынесена и дёшева.
- Создание бота в MAX требует верифицированного юрлица РФ (бизнес-режим с 08.2025).
- Ретенция истории группы — команды старше срока хранения пропадают; при регулярном опросе не проблема.
- Команды — открытый текст в группе: всё, что видят операторы, видят все участники группы. Участники — только доверенные операторы.
- Идемпотентность выполнения — дедуп по
seq;last_seqсохранять в NVS сразу после обработки, чтобы reboot между «выполнил» и «сохранил» не привёл к повторному выполнению. - Канал MAX остаётся документированным запасным вариантом (если потребуется анонимность вместо аудита) — но по умолчанию групповой чат.
Проверочные артефакты¶
test/maxbot_queue_probe.sh— доказал single-consumer уgetUpdates.test/maxbot_messages_probe.sh— доказал идемпотентностьGET /messages, наличиеsenderв группе (бот=админ), работуPOST /messagesв чат.
Скрипты оставлены в репозитории как dev-инструменты для повторной проверки при изменениях MAX API.