Files
AISHub/docs/API.md
T
2026-05-04 08:13:38 +03:00

24 KiB
Raw Blame History

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: все поля tsUnix 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 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).

[
  { "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 (131).
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-сериализованный Event envelope (такой же, как в 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:

  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_stationState.base_stations.
  • atonState.atons.

В логе при успешном warm start:

INFO warm start: 42 vessels, 0 base_stations, 0 atons from sqlite

И счётчики stats.counters.state_warmup_*.

Таким образом, сценарий «принял 5-е и 24-е, перезапустил, принял 18-е» теперь отдаст target с заполненными именем/IMO/размерениями сразу.