closed TG-1; git was inited;

This commit is contained in:
2026-05-04 08:13:38 +03:00
commit bcf20fcb04
105 changed files with 6592 additions and 0 deletions
+630
View File
@@ -0,0 +1,630 @@
# ais_hub API reference
Полный контракт всех публичных интерфейсов `ais_hub` для веб-морды / SPI-bridge / BLE-сервера.
- **Base URL (HTTP/WS):** `http://<host>: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_<N>` — сколько сообщений каждого типа декодировано (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": <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 099 (всегда плоский 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 (015). |
| `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 (131). |
| `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<dC<mNf0<0Q,0*2F"
},
{
"ts": 1700000000.456,
"source": "gps_uart:/dev/ttyS1",
"kind": "gps",
"line": "$GPRMC,123519,A,4807.038,N,01131.000,E,022.4,084.4,230394,,,*7A"
}
]
```
---
### 1.12 `GET /api/v1/history/positions`
История AIS dynamic для конкретного MMSI из SQLite.
**Query-параметры**
| Параметр | Тип | Обязательный | Описание |
| --- | --- | --- | --- |
| `mmsi` | int | да | MMSI. |
| `from` | float (unix ts) | нет | Нижняя граница по `ts`. |
| `to` | float (unix ts) | нет | Верхняя граница по `ts`. |
| `limit` | int | нет | По умолчанию 1000, максимум 100000. |
**Коды**
- `200` — массив точек (может быть пустой).
- `400` — нет/невалидный `mmsi`.
- `503``{"error": "history storage disabled"}`, если `storage.path` пуст.
```json
[
{
"ts": 1700000000.123,
"lat": 59.912345, "lon": 10.754321,
"sog": 12.4, "cog": 210.3,
"heading": 208, "nav_status": 0, "rot": -1.2,
"msg_type": 18
}
]
```
Отсортировано по `ts` **убыванию**.
---
### 1.13 `POST /api/v1/tx`
Инжекция NMEA-строк в TX outbox (UDP 127.0.0.1:6010 → SPI bridge).
**Body (JSON)** — одна из двух форм:
```json
{ "payload": "!AIVDM,1,1,,A,15MwkT001s8rFfwJh<dC<mNf0<0Q,0*2F" }
```
или
```json
{ "nmea": [
"!AIVDM,1,1,,A,...,0*2F",
"$GPRMC,...,*7A"
] }
```
**Response (200)**
```json
{
"queued": 1,
"rejected": [],
"queue_depth": 3
}
```
**Response (400)** — невалидный JSON или нет `payload`/`nmea`.
**Response (503)** — outbox переполнен:
```json
{
"error": "outbox full",
"accepted": ["!AIVDM,..."],
"rejected": []
}
```
Каждая строка валидируется: структура NMEA + XOR-чексумма `*HH`. Невалидные попадают в `rejected: [{"line": "...", "reason": "invalid nmea or checksum"}]`, валидные — в outbox.
---
## 2. WebSocket `/ws`
**URL:** `ws://<host>:8081/ws`
**Subprotocol:** нет.
**Heartbeat:** 30 секунд (aiohttp ping, клиент должен отвечать на pong).
**Направление:** publish-only от сервера; сообщения от клиента игнорируются.
### 2.1 Формат конверта
Все сообщения — JSON одной строкой:
```json
{ "type": "<event_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<dC<mNf0<0Q,0*2F\r\n
```
### 3.3 UDP TX outbox — `127.0.0.1:6010` (publish, out)
- Контракт: **одна NMEA-строка = одна UDP-датаграмма**, `\r\n` в конце.
- Источник: `POST /api/v1/tx`, внутренние эмиттеры.
- Это **не ingest**: сервис только шлёт наружу. Для приёма NMEA использовать `127.0.0.1:5005` (AIS UDP) или UART.
### 3.4 Ingest AIS UDP — `<ingest.ais_udp>`
- Порт в конфиге (по умолчанию 4001, у вас на устройстве 5005 — совпадает с AIS-catcher).
- Формат входа: **одна или несколько NMEA-строк в одной датаграмме**, разделитель `\r\n`/`\n`.
- Сервис декодирует AIVDM/AIVDO и мультифрагментные.
### 3.5 Ingest Radio UDP (JSON) — `<ingest.radio_udp>`
- Порт по умолчанию 4010.
- Формат: одна датаграмма = один JSON-объект с полями `{channel, rssi, snr, slot, ...}` — любые ключи сохраняются в `raw`.
### 3.6 Ingest AIS-catcher binary — `<ingest.aiscatcher_udp>`
Четыре независимых 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:<listener>:<sender_ip>` | `ais_udp:0.0.0.0:5005:127.0.0.1` | NMEA пришёл по UDP. Sender-порт намеренно не включается (ephemeral, скачет). |
| `gps_uart:<device>` | `gps_uart:/dev/ttyS1` | NMEA с UART. |
| `radio_udp:<listener>:<sender_ip>` | `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.<name|callsign|imo|ship_type|dim_*|eta|draught|destination>`, а также `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/размерениями сразу.