24 KiB
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, если процесс жив.
{
"status": "ok",
"version": "0.1.0",
"uptime_sec": 12345.678
}
1.2 GET /api/v1/stats
Все внутренние счётчики и gauges. Полезно для мониторинга и диагностики.
{
"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 (собственное судно).
{
"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>}.
{
"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).
[
{ "mmsi": 2570001, "ts": 1700000000.0, "lat": 59.9, "lon": 10.7, "epfd": 1 }
]
1.7 GET /api/v1/atons
Aids-to-Navigation (msg 21).
[
{
"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).
{
"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 Гц).
{
"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. |
[
{
"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 | Сколько последних строк. |
[
{
"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пуст.
[
{
"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) — одна из двух форм:
{ "payload": "!AIVDM,1,1,,A,15MwkT001s8rFfwJh<dC<mNf0<0Q,0*2F" }
или
{ "nmea": [
"!AIVDM,1,1,,A,...,0*2F",
"$GPRMC,...,*7A"
] }
Response (200)
{
"queued": 1,
"rejected": [],
"queue_depth": 3
}
Response (400) — невалидный JSON или нет payload/nmea.
Response (503) — outbox переполнен:
{
"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 одной строкой:
{ "type": "<event_type>", "ts": 1700000000.123, "data": { ... } }
2.2 Первое сообщение после коннекта — state.snapshot
{
"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-сериализованный
Eventenvelope (такой же, как в WS, но безstate.snapshot). - Максимальный размер датаграммы:
publish.udp_events.max_datagram_bytes(1400 по умолчанию). Превышение → drop +stats.counters.udp_events_oversize. - Подходит для локальных читателей (BLE-сервер, SPI bridge), которым не нужен full-duplex.
Пример одной датаграммы:
{"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
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:
- Загрузить
GET /api/v1/vessels(для первичного рендера карты). - Открыть
/ws— получитьstate.snapshot, заменить состояние им. - Применять
target.update/ownship.update/и т.д. поверх. signal.updateиradio.update(RSSI) — для графиков / индикатора сигнала.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/размерениями сразу.