# ais_hub API reference Полный контракт всех публичных интерфейсов `ais_hub` для веб-морды / SPI-bridge / BLE-сервера. - **Base URL (HTTP/WS):** `http://:8081` (порт из `publish.http.port`). - **Авторизация:** нет (сервис привязан к localhost / доверенной сети). - **Кодировка:** `UTF-8`. Все JSON — одна строка либо pretty, клиент не должен полагаться на форматирование. - **Timestamps:** все поля `ts` — **Unix epoch в секундах**, `float`. Поля в миллисекундах (от AIS-catcher) явно называются `*_unix_ms`. - **Nullability:** любое поле с неизвестным значением = `null`, даже если тип в таблице int/float. - **Стабильность:** REST пути версионированы (`/api/v1/*`). Новые поля могут добавляться без breaking change — клиент должен игнорировать неизвестные ключи. --- ## 1. HTTP REST ### 1.1 `GET /api/v1/health` Liveness probe. Всегда 200, если процесс жив. ```json { "status": "ok", "version": "0.1.0", "uptime_sec": 12345.678 } ``` --- ### 1.2 `GET /api/v1/stats` Все внутренние счётчики и gauges. Полезно для мониторинга и диагностики. ```json { "uptime_sec": 12345.678, "started_at": 1700000000.123, "counters": { "ais_udp_datagrams": 10423, "ais_udp_malformed": 2, "ais_reports": 9812, "ais_msg_1": 4200, "ais_msg_3": 1800, "ais_msg_5": 410, "ais_msg_18": 3200, "ais_msg_24": 202, "ais_fragment_errors": 0, "ais_fragment_timeouts": 0, "parser_errors": 1, "parser_checksum_errors": 0, "state_ownship_updates": 1234, "state_targets_new": 120, "state_targets_updates": 9500, "state_base_station_updates": 0, "state_aton_updates": 0, "state_rssi_updates": 45200, "state_slot_bitmap_updates": 12, "state_slot_detail_updates": 12, "state_signal_events": 8800, "state_warmup_vessels": 42, "storage_batches": 950, "storage_rows": 31200, "storage_errors": 0, "udp_events_sent": 123456, "udp_events_oversize": 3, "supervisor_restarts_ingest_ais_udp": 1, "ws_clients_total": 4 }, "gauges": { "ws_clients": 1, "storage_last_batch": 200 } } ``` **Ключевые counters для UI** - `ais_msg_` — сколько сообщений каждого типа декодировано (1/2/3/4/5/18/19/21/24/27 и т.д.). - `ais_fragment_*` — проблемы мультифрагментной сборки. - `state_targets_new` — количество впервые увиденных MMSI. - `state_warmup_vessels` — сколько судов поднято из SQLite при старте (persistence). - `supervisor_restarts_*` — если не 0, какой-то subsystem падает. --- ### 1.3 `GET /api/v1/ownship` Последний GPS-fix (собственное судно). ```json { "ts": 1700000000.123, "lat": 59.123456, "lon": 10.987654, "sog": 5.4, "cog": 128.5, "alt": 12.3, "fix_quality": 1, "sats": 9, "hdop": 0.8 } ``` | Поле | Тип | Описание | | --- | --- | --- | | `ts` | float | Unix-время последнего обновления. 0 если fix ещё не пришёл. | | `lat`, `lon` | float \| null | Широта/долгота в градусах (WGS-84). | | `sog` | float \| null | Speed over ground, узлы. | | `cog` | float \| null | Course over ground, градусы от истинного севера. | | `alt` | float \| null | Высота над эллипсоидом, метры (GGA). | | `fix_quality` | int \| null | NMEA GGA fix quality (0=нет, 1=GPS, 2=DGPS…). | | `sats` | int \| null | Количество спутников в решении (GGA). | | `hdop` | float \| null | Horizontal dilution of precision. | --- ### 1.4 `GET /api/v1/vessels` Все известные MMSI, отсортированные по `last_seen` убыванию. **Query-параметры** | Параметр | Тип | По умолчанию | Описание | | --- | --- | --- | --- | | `since` | float (unix ts) | — | Вернуть только target'ы с `last_seen >= since`. | | `limit` | int | без лимита | Срез первых N (после сортировки). | **Response** — массив объектов `MergedTarget` (см. 1.5). --- ### 1.5 `GET /api/v1/vessels/{mmsi}` Один target. `mmsi` в URL — целое число. **Коды ответа** - `200` — найдено; - `400` — невалидный MMSI; - `404` — `{"error": "not found", "mmsi": }`. ```json { "mmsi": 506140446, "name": "CELESTIAL STAR", "callsign": "V7AB3", "imo": 9123456, "ship_type": 70, "dims": { "a": 180, "b": 30, "c": 16, "d": 16 }, "voyage": { "eta": "05-20 12:00", "draught": 7.5, "destination": "ROTTERDAM" }, "dynamic": { "lat": 59.912345, "lon": 10.754321, "sog": 12.4, "cog": 210.3, "heading": 208, "nav_status": 0, "rot": -1.2 }, "signal": { "last_db": -72.5, "last_ts": 1700000000.123, "last_slot": 1487, "last_channel": "A" }, "last_static_ts": 1699990000.0, "last_dynamic_ts": 1700000000.123, "last_seen": 1700000000.123, "sources": ["ais_udp:0.0.0.0:5005:127.0.0.1"], "msg_types": [5, 18, 24] } ``` | Блок | Поле | Тип | Описание | | --- | --- | --- | --- | | root | `mmsi` | int | MMSI (9 цифр для судов, другие диапазоны — base stations / AtoN / SAR и т.д.). | | root | `name` | str \| null | Имя судна (type 5 / 24A). Trim + trailing `@` удалены. | | root | `callsign` | str \| null | Callsign (type 5 / 24B). | | root | `imo` | int \| null | IMO номер (type 5). `0` нормализуется в `null` при наличии. | | root | `ship_type` | int \| null | Ship/cargo type 0–99 (всегда плоский int, не enum). | | `dims` | `a/b/c/d` | int \| null | Расстояния от GPS-антенны: `a`=нос, `b`=корма, `c`=левый борт, `d`=правый борт; метры. Длина = a+b, ширина = c+d. | | `voyage` | `eta` | str \| null | ETA в формате `MM-DD HH:MM` (как отдаёт pyais). | | `voyage` | `draught` | float \| null | Осадка, метры. | | `voyage` | `destination` | str \| null | Порт назначения (до 20 символов). | | `dynamic` | `lat`, `lon` | float \| null | Позиция, градусы WGS-84. | | `dynamic` | `sog` | float \| null | Скорость, узлы (0…102.2). | | `dynamic` | `cog` | float \| null | Course over ground, градусы (0…359.9). | | `dynamic` | `heading` | float \| null | Heading, градусы (0…359, 511=unknown → `null`). | | `dynamic` | `nav_status` | int \| null | Navigational status (0–15). | | `dynamic` | `rot` | float \| null | Rate of turn, град/мин. | | `signal` | `last_db` | float \| null | Уровень сигнала последнего декода (от AIS-catcher decode events, port 10114). | | `signal` | `last_ts` | float \| null | Unix-время последнего decode event. | | `signal` | `last_slot` | int \| null | TDMA-слот (0…2249). | | `signal` | `last_channel` | str \| null | `"A"` или `"B"`. | | root | `last_static_ts` | float | Последний приход type-5 / 24 для этого MMSI. 0 если не было. | | root | `last_dynamic_ts` | float | Последний приход type-1/2/3/18/19/27. | | root | `last_seen` | float | `max(last_static_ts, last_dynamic_ts, last_signal_ts)`. | | root | `sources` | str[] | Отсортированный список source-тегов, с которых приходили данные (см. формат в разделе "Источники"). | | root | `msg_types` | int[] | Отсортированный список типов AIS-сообщений, наблюдавшихся для этого MMSI. | --- ### 1.6 `GET /api/v1/base_stations` AIS base stations (msg 4/11). ```json [ { "mmsi": 2570001, "ts": 1700000000.0, "lat": 59.9, "lon": 10.7, "epfd": 1 } ] ``` --- ### 1.7 `GET /api/v1/atons` Aids-to-Navigation (msg 21). ```json [ { "mmsi": 992570001, "ts": 1700000000.0, "lat": 59.9, "lon": 10.7, "type": 3, "name": "FLAKFORTET", "virtual": false } ] ``` | Поле | Тип | Описание | | --- | --- | --- | | `type` | int \| null | AtoN type (1–31). | | `virtual` | bool \| null | `true` = виртуальный AtoN. | --- ### 1.8 `GET /api/v1/slots` Снапшот TDMA-слотов и occupancy (наполняется из AIS-catcher, порты 10111/10112). ```json { "per_channel": {}, "occupancy": { "A": { "ts": 1700000060.0, "utc_minute": 28333334, "slots_total": 2250, "occupied_count": 437, "occupied_fraction": 0.1942, "slot0_unix_ms": 1700000040000, "first_occupied_unix_ms": 1700000041123 }, "B": { ... } }, "detail": { "A": { "ts": 1700000060.0, "utc_minute": 28333334, "slot0_unix_ms": 1700000040000, "entries": [ { "slot": 42, "level_db": -72.5 }, { "slot": 1487, "level_db": -80.1 } ] } } } ``` - `per_channel` — зарезервировано (пусто в текущей версии). - `occupancy` — загрузка эфира. Обновляется раз в минуту. - `detail` — список занятых слотов с уровнем сигнала. Обновляется раз в минуту. Может отсутствовать или быть с пустым `entries`. --- ### 1.9 `GET /api/v1/radio` Мгновенная мощность каналов A/B (от AIS-catcher RSSI, порт 10113, ~10 Гц). ```json { "power": { "ts": 1700000000.123, "power_a_db": -42.25, "power_b_db": -55.75 } } ``` Если RSSI ни разу не приходил — `"power": {}`. --- ### 1.10 `GET /api/v1/logs` Service logs tail из in-memory ring buffer (не raw NMEA). **Query-параметры** | Параметр | Тип | По умолчанию | Описание | | --- | --- | --- | --- | | `level` | str | — | Фильтр: `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL` (точный match). | | `limit` | int | 200 | Сколько последних записей вернуть. | | `since` | float | — | Unix-ts; вернуть записи с `ts >= since`. | ```json [ { "ts": 1700000000.123, "level": "INFO", "logger": "ais_hub.app", "message": "warm start: 42 vessels, 0 base_stations, 0 atons from sqlite" }, { "ts": 1700000001.234, "level": "WARNING", "logger": "ais_hub.ingest.ais_udp", "message": "ais_udp: cannot bind 0.0.0.0:5005 — already in use..." } ] ``` --- ### 1.11 `GET /api/v1/nmea/tail` Raw NMEA tail — отдельно от `/logs`. Сперва отдаёт in-memory ring buffer (всегда ведётся), при необходимости фоллбэчит в SQLite `raw_nmea` (если `storage.store_raw_nmea: true`). **Query-параметры** | Параметр | Тип | По умолчанию | Описание | | --- | --- | --- | --- | | `source` | str | — | Фильтр: `ais`, `gps`, `radio`. | | `limit` | int | 200 | Сколько последних строк. | ```json [ { "ts": 1700000000.123, "source": "ais_udp:0.0.0.0:5005:127.0.0.1", "kind": "ais", "line": "!AIVDM,1,1,,A,15MwkT001s8rFfwJh:8081/ws` **Subprotocol:** нет. **Heartbeat:** 30 секунд (aiohttp ping, клиент должен отвечать на pong). **Направление:** publish-only от сервера; сообщения от клиента игнорируются. ### 2.1 Формат конверта Все сообщения — JSON одной строкой: ```json { "type": "", "ts": 1700000000.123, "data": { ... } } ``` ### 2.2 Первое сообщение после коннекта — `state.snapshot` ```json { "type": "state.snapshot", "ts": 1700000000.123, "data": { "ownship": { ... }, // см. GET /api/v1/ownship "vessels": [ ... ], // см. GET /api/v1/vessels "base_stations": [ ... ], // см. GET /api/v1/base_stations "atons": [ ... ], // см. GET /api/v1/atons "slots": { ... }, // см. GET /api/v1/slots "stats": { ... } // см. GET /api/v1/stats } } ``` Клиент инициализирует своё состояние из снапшота, затем применяет живые updates. ### 2.3 Живые события | `type` | Когда публикуется | `data` | | --- | --- | --- | | `ownship.update` | На каждый GPS-fix с новыми полями | Объект, идентичный `GET /api/v1/ownship`. | | `target.update` | На каждое AIS dynamic/static для MMSI, которое что-то изменило | Полный `MergedTarget` (см. 1.5). | | `base_station.update` | Type 4/11 | `{mmsi, ts, lat, lon, epfd}`. | | `aton.update` | Type 21 | `{mmsi, ts, lat, lon, type, name, virtual}`. | | `radio.update` (JSON) | Радиотелеметрия в JSON (port `radio_udp`) | `{channel, rssi, snr, slot, raw}`. | | `radio.update` (RSSI) | От AIS-catcher RSSI (10113, ~10 Гц) | `{source: "aiscatcher_rssi", power_a_db, power_b_db}`. Отличайте по `data.source`. | | `slots.update` | Bitmap (10111, ~1/мин) | `{channel, ts, utc_minute, slots_total, occupied_count, occupied_fraction, slot0_unix_ms, first_occupied_unix_ms, bitmap_hex}`. | | `slots.detail` | Detail (10112, ~1/мин) | `{channel, ts, utc_minute, slot0_unix_ms, entries:[{slot, level_db}]}`. | | `signal.update` | Пачка decode events (10114) | `{channel, events:[{unix_ms, slot, mmsi, level_db}]}`. | | `stats.update` | Раз в 5 секунд | Полный snapshot stats (см. 1.2). | **Правила апдейта** клиента: - `target.update`: merge по `mmsi`; не терять предыдущие поля (новый пакет с `null` в static не перезаписывает старые имя/IMO — backend этим занимается через `COALESCE` в SQLite и `merge.apply`). - `signal.update`: обновлять sparkline сигнала; `target.update` уже содержит `signal` блок, но `signal.update` приходит чаще (с каждой успешной декодой пакета). - Порядок событий не гарантируется быть идеально хронологическим между разными типами, но в пределах одного `type` он монотонный. ### 2.4 Обратное давление У каждого WS-клиента внутренняя очередь размера `queues.ws_client` (по умолчанию 512). Медленный клиент приводит к drop-oldest; счётчик — `stats.counters.ws_dropped`. --- ## 3. UDP интерфейсы (localhost only) Все UDP-сокеты слушают/шлют только на `127.0.0.1`. ### 3.1 UDP JSON events — `127.0.0.1:7001` (publish, out) - Контракт: **одно событие = одна UDP-датаграмма**. - Формат: JSON-сериализованный `Event` envelope (такой же, как в WS, но без `state.snapshot`). - Максимальный размер датаграммы: `publish.udp_events.max_datagram_bytes` (1400 по умолчанию). Превышение → drop + `stats.counters.udp_events_oversize`. - Подходит для локальных читателей (BLE-сервер, SPI bridge), которым не нужен full-duplex. Пример одной датаграммы: ```json {"type":"target.update","ts":1700000000.123,"data":{"mmsi":506140446,...}} ``` ### 3.2 UDP raw NMEA fan-out — `127.0.0.1:6007` (publish, out) - Контракт: **одна NMEA-строка = одна UDP-датаграмма**, завершённая `\r\n`. - Формат: как было получено по UDP/UART (после валидации структуры, но сохраняется исходная `*HH`). - Используется SPI bridge'ем для сквозного форвардинга в MCU. Пример одной датаграммы: ``` !AIVDM,1,1,,A,15MwkT001s8rFfwJh` - Порт в конфиге (по умолчанию 4001, у вас на устройстве 5005 — совпадает с AIS-catcher). - Формат входа: **одна или несколько NMEA-строк в одной датаграмме**, разделитель `\r\n`/`\n`. - Сервис декодирует AIVDM/AIVDO и мультифрагментные. ### 3.5 Ingest Radio UDP (JSON) — `` - Порт по умолчанию 4010. - Формат: одна датаграмма = один JSON-объект с полями `{channel, rssi, snr, slot, ...}` — любые ключи сохраняются в `raw`. ### 3.6 Ingest AIS-catcher binary — `` Четыре независимых UDP-листенера (все BE, binary, подробные layout'ы — в `src/ais_hub/parser/aiscatcher.py`): | Порт по умолчанию | Что | Длина | | --- | --- | --- | | 10111 | Slot bitmap occupancy (per minute) | 315 Б | | 10112 | Slot detail (per-slot levels) | 15 + 6×N | | 10113 | RSSI IQ pair (~10 Гц) | 8 Б | | 10114 | Decode events (mmsi/slot/level) | 3 + 18×N | В REST/WS эти потоки отображаются как `/api/v1/radio`, `/api/v1/slots`, `radio.update`, `slots.update`, `slots.detail`, `signal.update`. --- ## 4. Источники (`source` / `sources`) Формат строки источника в `RawFrame.source`, `sources` array у target'а, в `GET /api/v1/nmea/tail`: | Prefix | Пример | Смысл | | --- | --- | --- | | `ais_udp::` | `ais_udp:0.0.0.0:5005:127.0.0.1` | NMEA пришёл по UDP. Sender-порт намеренно не включается (ephemeral, скачет). | | `gps_uart:` | `gps_uart:/dev/ttyS1` | NMEA с UART. | | `radio_udp::` | `radio_udp:0.0.0.0:4010:127.0.0.1` | JSON radio telemetry. | | `ownship` | `ownship` | Используется в WriteJob для `gps_fix` в БД. | --- ## 5. Коды ответов | HTTP | Когда | | --- | --- | | `200` | OK | | `400` | Невалидные query-параметры / JSON-тело / MMSI. | | `404` | Unknown MMSI в `/api/v1/vessels/{mmsi}`. | | `500` | SQLite query провалился. | | `503` | Storage disabled (`history/positions`) или TX outbox full. | --- ## 6. Пример клиента на TypeScript ```ts type Event = { type: string; ts: number; data: any }; async function loadInitial() { const [ownship, vessels, stats] = await Promise.all([ fetch("/api/v1/ownship").then(r => r.json()), fetch("/api/v1/vessels?limit=500").then(r => r.json()), fetch("/api/v1/stats").then(r => r.json()), ]); return { ownship, vessels, stats }; } function connectWs(onEvent: (ev: Event) => void) { const ws = new WebSocket(`ws://${location.host}/ws`); ws.onmessage = (m) => onEvent(JSON.parse(m.data)); ws.onclose = () => setTimeout(() => connectWs(onEvent), 2000); return ws; } ``` Стандартная схема для web UI: 1. Загрузить `GET /api/v1/vessels` (для первичного рендера карты). 2. Открыть `/ws` — получить `state.snapshot`, заменить состояние им. 3. Применять `target.update`/`ownship.update`/и т.д. поверх. 4. `signal.update` и `radio.update` (RSSI) — для графиков / индикатора сигнала. 5. `slots.update` / `slots.detail` — для визуализации загрузки эфира. --- ## 7. Persistence при рестарте При старте сервис автоматически загружает из SQLite: - `vessel_static` → все `MergedTarget.`, а также `last_static_ts`. Dynamic (lat/lon/sog/cog/heading/nav_status/rot) не восстанавливается — придёт с первым новым type-1/2/3/18. - `base_station` → `State.base_stations`. - `aton` → `State.atons`. В логе при успешном warm start: ``` INFO warm start: 42 vessels, 0 base_stations, 0 atons from sqlite ``` И счётчики `stats.counters.state_warmup_*`. Таким образом, сценарий «принял 5-е и 24-е, перезапустил, принял 18-е» теперь отдаст target с заполненными именем/IMO/размерениями сразу.