diff --git a/doc/device-integration.md b/doc/device-integration.md new file mode 100644 index 0000000..5d66622 --- /dev/null +++ b/doc/device-integration.md @@ -0,0 +1,88 @@ +# Подключение устройства к IoT (Nubes, тест-стенд) + +Проверено end-to-end 2026-10-11 на развёрнутом стенде +(`https://iot-api.containerk8s.dev.nubes.ru`, `wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt`). + +## 1. Регистрация устройства (HTTP API, JWT HS256) + +JWT подписывается `JWT_HMAC_SECRET` (HS256), claim `sub` = ваш namespace, +`exp` обязателен; `namespace` в пути **обязан совпадать** с `sub`, иначе `403`. + +```bash +curl -X POST "$API/v1/namespaces/$NS/iot/devices" \ + -H "Authorization: Bearer $JWT" -H 'Content-Type: application/json' \ + -d '{"name":"dev1","device_id":"d-01"}' +``` + +Ответ `201` содержит **единственный раз** выдаваемый пароль: + +```json +{"name":"dev1","namespace":"e2e-nubes","device_id":"d-01","enabled":true, + "mqtt_username":"e2e-nubes_d-01","mqtt_password":"", + "telemetry_topic":"e2e-nubes/telemetry/d-01"} +``` + +`GET …/devices/{name}` пароль **не** возвращает. Ограничения идентификаторов: +`^[a-zA-Z0-9][a-zA-Z0-9-]*$`, namespace ≤ 56, name/device_id ≤ 64 символов +(иначе `400`). Дубликаты name/device_id → `409`. + +## 2. Подключение по MQTT + +- URL: `wss://exqx-iot.containerk8s.dev.nubes.ru/mqtt` (WebSocket, subprotocol `mqtt`); + внутри кластера — `ws://containerk8s..svc.cluster.local:8083/mqtt`. +- username = `namespace_device_id`, пароль — из ответа на создание. +- Публикация: топик `namespace/telemetry/device_id` (`telemetry_topic`). + +ACL: устройству разрешён **только свой** топик. Публикация/подписка куда-либо ещё +(чужой namespace, другое устройство, wildcard `+`/`#`) → `deny_action=disconnect`, +соединение рвётся. Также запрещены: retained-публикации +(`retain_available=false`), топики глубже 10 уровней, subscribe на +`+/telemetry/+` (это привилегия бриджа). + +Коды CONNACK: `0` — успех, `4` — неверный username/пароль, `5` — не авторизован +(в т.ч. устройство выключено через `PATCH {"enabled": false}`). + +## 3. Требование к payload — ВАЖНО + +Дедупликация выполняется по **содержимому сообщения**: +`message_id = SHA256(namespace | device_id | topic | payload)` +(`internal/service/bridge/handler.go`). Поэтому: + +- **два побайтово одинаковых payload подряд считаются дубликатом и сохраняются + один раз** (это защита от повторной доставки SQS at-least-once); +- ⇒ в payload обязателен **timestamp или счётчик**, иначе легитимные повторные + одинаковые показания (например, `{"t":21.0}` каждую минуту) будут отброшены. + +Пример корректного payload: + +```json +{"temp": 21.5, "ts": 1791737028, "seq": 4211} +``` + +Payload валидный JSON сохраняется как JSON; не-JSON (текст, бинарь) оборачивается +в строку. Лимиты: MQTT-пакет ≤ 1 МБ (`max_packet_size`), envelope для SQS ≤ 250 КБ +(`maxSQSBodyBytes`) — сообщения больше отбрасываются мостом. + +QoS: `QoS0` — «выстрелил и забыл»; для гарантии доставки используйте `QoS1` +(дождитесь PUBACK перед закрытием соединения). + +## 4. Чтение телеметрии + +```bash +curl "$API/v1/namespaces/$NS/iot/telemetry?device_id=d-01&limit=50&offset=0" \ + -H "Authorization: Bearer $JWT" +``` + +`limit` — дефолт 50, потолок 1000. Ответ: +`{"count":N,"items":[{"id":…,"device_id":…,"ts":…,"payload":…}]}`. + +⚠️ Чтение отдаёт payload целиком, поэтому `limit=1000` при крупных payload'ах +дорого для клиента и сервиса — для больших объёмов берите меньше и +используйте `offset`. + +## 5. Наблюдаемое поведение (e2e, 2026-10-11) + +Задержка «MQTT → видно в API» недетерминирована и доходит до ~20 с: потребитель +SQS работает long-poll окном (`SQS_LONG_POLL_SECONDS=20`), батч пишется в PG и +только потом удаляется из очереди. Для тестов/интеграции — опрашивать с +таймаутом ≥ 30 с, а не фиксированной паузой 1–5 с.