# PROTOCOL.md — MQTT-контракт устройства O3PRO IoT **Версия 1.2 · 2026-10-24 · публичный документ** Изменения 1.1: анонимный доступ закрыт; добавлены provisioning-секрет, mTLS, ACL по топикам, расписания и уведомления. Изменения 1.2: контракт OTA — задание содержит `id` и `model`, статус возвращается с тем же `id`; при успехе облако обновляет версию устройства. Контракт для подключения устройств и сторонних модулей связи к платформе O3PRO IoT. Производитель стороннего модуля сам отвечает за соответствие эксплуатационным требованиям (ТЗ раздел 10) и за корректные значения датчиков. --- ## 1. Транспорт и аутентификация - Broker: **EMQX 6.x**, порт `1883` (TCP, логин/пароль) и `8883` (TLS, mTLS). - QoS: **1** для телеметрии, статусов, аварий и команд. - Retain: **запрещён** для всех топиков устройств. - ClientId: `device-{serial_number}` — уникален. - Анонимный доступ **закрыт**: брокер спрашивает логин и пароль у API платформы на каждом подключении. Два равноправных способа: 1. **Provisioning-секрет.** Логин — `serial_number`, пароль — секрет, выданный один раз при регистрации устройства (в БД хранится bcrypt-хеш, восстановить секрет нельзя). Секрет перевыпускается администратором производителя или платформы. 2. **mTLS.** Клиентский сертификат X.509 с `CN = serial_number`, выпущенный CA платформы. Порт `8883` требует сертификат (`verify_peer`, `fail_if_no_peer_cert`); пароль при mTLS не нужен. Отозванный сертификат (и истёкший) доступа не даёт. ### 1.1. Получение доступа (provisioning) ``` POST /api/v1/devices/{id}/provisioning-key → { mqtt: { username, password, url, tlsUrl } } POST /api/v1/devices/{id}/certificates → { certificate, privateKey, ca, fingerprint, expiresAt } ``` Оба ответа содержат секрет/ключ **один раз** — прошивка обязана сохранить их в защищённом хранилище. Перевыпуск аннулирует предыдущий секрет; старые сертификаты остаются в истории и перечисляются в `GET /api/v1/devices/{id}/certificates`. Пример подключения: ```js mqtt.connect("mqtts://mqtt.example.com:8883", { clientId: `device-${serial}`, ca, cert, key, // mTLS: сертификат устройства }); // либо mqtt.connect("mqtt://mqtt.example.com:1883", { clientId: `device-${serial}`, username: serial, password, // provisioning-секрет }); ``` ### 1.2. Права на топики (ACL) Устройство работает только внутри своего дерева: | Действие | Разрешённые топики | |---|---| | publish | `devices/{свой id}/telemetry`, `/status`, `/alarm`, `/command/ack`, `/ota/status` | | subscribe | `devices/{свой id}/command`, `/ota` | Публикация в чужой `device_id`, подписка на `#` и `$SYS` запрещены: брокер спрашивает разрешение у API и молча отбрасывает запрещённое сообщение (`deny_action = ignore`). Логин и пароль отозванного устройства не работают после перевыпуска. ## 2. Топики Все сообщения двигателя — в пространстве `devices/{device_id}/...`. ``` devices/{device_id}/telemetry устройство → облако: телеметрия devices/{device_id}/status устройство → облако: статус; LWT → "offline" devices/{device_id}/alarm устройство → облако: событие аварии devices/{device_id}/command облако → устройство: команда devices/{device_id}/command/ack устройство → облако: подтверждение/отказ devices/{device_id}/ota облако → устройство: задание загрузки прошивки devices/{device_id}/ota/status устройство → облако: прогресс OTA ``` `device_id` платформа выдаёт при регистрации; до его получения устройство запрещено публиковать. ## 3. Форматы сообщений Все сообщения — JSON **UTF-8**. Времена: Unix-секунды (`ts`). ### 3.1. Telemetry → `…/telemetry` ```json { "ts": 1699999999, "firmware": "1.2.3", "metrics": { "ozone_ppm": 12.5, "temp_c": 42.1, "flow_m3h": 3.2, "power_w": 85 }, "errors": [] } ``` - `firmware` обязателен: сверяется платформой с дистрибутивами, отличия попадают в мониторинг расхождения прошивок. - `metrics` — объект пар «имя»: число/строка/boolean/null. - `errors` — массив `{ "code": "E001", "message": "опционально" }`, [] если нет. ### 3.2. Unit-конвенция имён метрик Имя = `<сущность>_<единица>`; единица — из фиксированного списка. Числа — десятичные, SI. | Суффикс | Единица | |---|---| | `_ppm` | ppm (концентрация озона) | | `_c`, `_f`, `_k` | °C, °F, K (температура) | | `_m3h` | м³/ч (производительность / поток) | | `_w` | Вт (мощность) | | `_v` | В (напряжение) | | `_a` | А (ток) | | `_kpa` | кПа (давление) | | `_percent` | % (влажность, заряд, дроссель) | | `_s` | секунды | Тот же список машинно-проверяем в `packages/protocol` (`parseMetricName`). Метрика без известного суффикса → предупреждение облака, в графики не попадает. ### 3.3. Статус → `…/status` ```json { "status": "online" } ``` или просто строкой `"offline"` (такой payload посылает брокер по LWT). Варианты: `online | offline | error | maintenance`. Если устройство не публиковало ничего более 30 минут — облако ставит `offline` خود (по `last_seen_at`). ### 3.4. Команда ← `…/command` ```json { "id": "cmd-uuid", "action": "set_mode", "payload": { "mode": "auto", "target_ppm": 15 }, "ts": 1699999999 } ``` Стандартные действия (MVP): | action | payload | Замечание | |---|---|---| | `power_on` | `{}` | **запрещено при небезопасных условиях** (см. §5) | | `power_off` | `{}` | обычное отключение | | `emergency_stop` | `{}` | **вне rate-limit**; модуль обязан подчиниться немедленно | | `set_mode` | `{ "mode": "auto" \| "manual", "target_ppm": N }` | | | `set_timer` | `{ "duration_s": N }` | | | `set_offline_behavior` | `{ "behavior": "stop" \| "continue" \| "safe_mode" }` | что делать при потере связи | | `apply_desired` | `{ "desired": {…}, "version": N }` | **системная команда** синхронизации состояния; через API пользователя не принимается | ### 3.5. Подтверждение → `…/command/ack` ```json { "id": "cmd-uuid", "status": "acked", "ts": 1699999999 } ``` или: ```json { "id": "cmd-uuid", "status": "failed", "error": "no_flow_detected", "ts": 1699999999 } ``` Ack должен придти по каждой команде. Если нет ack и нет крытой причины в течение 30 секунд — облако помечает команду `timeout` ### 3.6. Синхронизация состояния (Device Shadow) Желаемое состояние устройства (`desired`) хранится в облаке и переживает потерю связи. Устройство отчитывается о фактически принятом состоянии (`reported`) телеметрией и подтверждением `apply_desired`. Облако публикует системную команду: ```json { "id": "cmd-uuid", "action": "apply_desired", "payload": { "desired": { "power": true, "mode": "auto", "target_ppm": 20, "timer_s": 0, "offline_behavior": "stop" }, "version": 7 }, "ts": 1699999999 } ``` Правила прошивки: 1. `desired` — **полный** набор известных полей состояния. Устройство применяет его целиком, а не отдельным полем. 2. Ключи `desired`: `power` (bool), `mode` (`auto|manual`), `target_ppm` (0..500), `timer_s` (0..86400), `offline_behavior` (`stop|continue|safe_mode`). Неизвестные ключи игнорировать. 3. `version` — целое, растёт на каждом изменении желаемого. Устройство обязано вернуть его в ack: ```json { "id": "cmd-uuid", "status": "acked", "ts": 1699999999, "version": 7 } ``` 4. Ack без `version` облако принимает как «принял, но версию не подтвердил» и пишет предупреждение: reported не фиксируется. Ack со старой версией (`version < текущей`) отбрасывается — две команды подряд не откатывают состояние и не дают «мигание» реле. 5. В каждой телеметрии устройство публикует фактические значения ключей `power`, `mode`, `target_ppm`, `timer_s`, `offline_behavior` вместе с физическими метриками. По расхождению с `desired` облако повторяет `apply_desired` (не чаще 1 раза в 5 секунд на устройство). 6. Офлайн-устройству команда не публикуется: `desired` сохраняется и уходит при возврате в сеть (первый `status: online`). 7. Если расхождение уже закрыто (`reported` совпал с `desired`), команда не отправляется. **Safety override.** Команда от облачной логики безопасности приходит с верхнеуровневым полем: ```json { "id": "cmd-uuid", "action": "power_off", "payload": {}, "ts": 1699999999, "safety_override": true } ``` Устройство обязано исполнить её немедленно, даже если идёт конфликтующее локальное состояние. Служебный ключ `__safety_override` в `payload` не передаётся — это внутренняя пометка облака. При критической аварии (`no_flow`, `overheat`, `ppm_overlimit`) облако само выставляет `desired.power = false` и блокирует включение; снять блокировку может оператор после квитирования аварии. ### 3.7. Аварии → `…/alarm` ```json { "ts": 1699999999, "type": "no_flow", "severity": "critical", "value": { "metric": "flow_m3h", "threshold": 0.5, "actual": 0.0 } } ``` `type` — словарь: `no_flow`, `overheat`, `ppm_overlimit`, `sensor_fault`. (offline-тип `offline_long` генерирует облако.) Облако дедуплицирует активные аварии по паре (device_id, type), но слишком частые повторения — тоже сигнализирует как проблему. ## 4. OTA (обновление прошивки) Облако публикует задание: ```json { "id": "", "version": "1.4.0", "model": "OzoneLab S-500", "url": "https://…presigned…", "checksum": "sha256-…", "ts": 1699999999 } ``` `id` — идентификатор задания (FirmwareUpdate), по нему модуль отчитывается. Ссылка подписанная (7 дней) и перевыпускается облаком перед отправкой, поэтому её не нужно кэшировать между попытками. Модуль возвращает прогресс в `…/ota/status`: ```json { "id": "", "status": "downloading", "ts": 1699999999 } { "id": "", "status": "failed", "error": "checksum mismatch" } ``` Статусы: `downloading → installing → success | failed | rolled_back`. Модуль обязан проверять `checksum` до установки и сворачивать на предыдущую версию при неудачной. При `success` облако обновляет версию в карточке устройства. Задание не считается доставленным, пока не пришёл статус. ## 5. Безопасность озонатора (обязоряжение модуля) Никакие команды не отменяют локальную логику безопасности: 1. Работа без потока (`flow` = 0 при `power_on`) → авария `no_flow` (critical) + немедленное отключение нагревателя, независимо от команд облака. 2. Перегрев (температура выше предела модели) → `overheat` + остановка. 3. Аварийный стоп (`emergency_stop`) всегда исполняется в первую очередь, даже при активном offline-режиме. 4. Offline-поведение задаётся через `set_offline_behavior`; по умолчанию — **stop** (остановка). Изменение значения должно переживать reboot. 5. Устройство НЕ принимает команды от тенанта, который не является его владельцем: облако авторизует publish в топик команд. 6. Rate-limit: не более **60** любых команд/минут на устройство. `emergency_stop` не считается в лимите и исполняется всегда. ## 6. Расписание работы Планировщик живёт в облаке (`schedules`, ТЗ 6.6) и в нужное время публикует команды в `…/command`. Со стороны модуля специальная поддержка не обязательна; при синхронизации времени NTP не нужен — `ts` из команды облака берётся как истинная. Правила: - `cron` — стандартное пятипольное выражение, время **UTC**: `минута час день месяц день_недели`. - Действие и payload проверяются теми же правилами, что команда из API (`power_on`, `power_off`, `set_mode`, `set_timer`, `set_offline_behavior`); невалидное правило не отправляется и остаётся в списке с предупреждением. - Команда из расписания помечается в журнале `issued_by = null` («система»), проходит те же статусы `pending → sent → acked | timeout`. - Дубли исключены: отметка `lastRunAt` ставится на момент срабатывания, а не на «сейчас», поэтому рестарт сервиса не повторяет уже прошедшее окно. - Системные команды ограничены теми же 60/минуту на устройство. ## 7. Уведомления о событиях (REST/WebSocket) Уведомления не входят в MQTT-контракт модуля, но устройству полезно знать, что происходит: платформа создаёт уведомление владельцу устройства и администраторам производителя при аварии (`alarm`) и при уходе устройства в `offline` (нет данных 30 минут). - `GET /api/v1/notifications` — список, `PATCH …/{id}/read` — отметка, `POST …/read-all` — прочитать всё. - `GET|PUT /api/v1/notifications/settings` — отключение email, аварий, offline-событий. - `WS /ws/notifications?token=` — доставка в реальном времени. - Дедупликация: одна авария — одно уведомление; offline — не чаще раза в час на устройство. ## 8. Изменение контракта Меняется только с минорным повышением версии документа; вещи, что ломают совместимость (переименование топиков, изменение формата), — только с удвоенной major-версией и выдачей новых прошивок. Модули, не отвечающие последнему контракту, облако помечает `needs_upgrade` и блокирует OTA поверх нового контракта.