AIS Vessel Tracking
Heimdall tracks nearby vessels from two coexisting sources:
- aisstream.io — Heimdall's own aisstream.io client (
AisClient) subscribes to a bounding box around the current GPS position for global coverage. - Forward-ingest (Apollon) — a local AIS receiver station (Apollon) forwards vessel deltas over the existing
/v1/wsWebSocket for near-range local coverage.
Both sources feed one shared, Arc-owned VesselStore (local entries take priority over aisstream for the same MMSI). Vessel data is broadcast live to WebSocket clients, and — new — persisted: static vessel data (name, callsign, ship type, dimensions) is queryable via REST and GraphQL. Live positions are never persisted to Postgres — they exist only in Redis (hot cache) and the WebSocket broadcast; there is no position history or track endpoint.
How It Works
aisstream.io client
AisClient(crates/heimdall-websocket/src/ais.rs) connects towss://stream.aisstream.io/v0/stream.- It subscribes with a bounding box centered on the latest GPS position (
radius_km), relocating every 60 seconds if the vessel moved more than 1 km. - Two message types are processed:
PositionReport(position/speed/heading) andShipStaticData(name/type/dimensions). - Every decoded message is delegated to
VesselStore::apply_delta()—AisClientno longer owns its own vessel map. - Exponential backoff (3s → 60s max) on stream errors/disconnects.
Forward-ingest (Apollon)
Apollon (the local AIS receiver) connects to the existing GET /v1/ws endpoint — no new route — with a Bearer token (system API key with ais:write, or any key granted ais:write) and an X-Device-ID header identifying its receiver station. It does not subscribe to a channel; instead it sends typed JSON frames:
{ "type": "vessels", "action": "spawn", "vessel": { /* full vessel object, snake_case */ } }
{ "type": "vessels", "action": "update", "vessel": { /* full vessel object, snake_case */ } }
{ "type": "vessels", "action": "remove", "mmsi": 244125253 }
The gate is is_system_key() || can("ais", "write") (same pattern as the audit-ingest path, different wire arm). Each frame is deserialized into a delta and applied to VesselStore tagged source: "local". A malformed frame is logged and dropped — the connection stays open.
Shared VesselStore
VesselStore (crates/heimdall-websocket/src/vessel_store.rs) is the single owner of the in-memory vessel map (HashMap<mmsi, Vessel>), used by both sources:
- Local-priority merge (TTL-based): while a vessel has a Local-sourced position within
local_priority_ttl_secs(default 120s) of the current time, aisstream position reports for that MMSI are suppressed — the nearer, higher-rate local receiver keeps ownership. Handoff back to aisstream is automatic once the TTL elapses with no new local fix. Aisstream static-only reports (ShipStaticData, no position) are never suppressed — they always enrich, regardless of source ownership. A localremovedrops the entry (aisstream will re-populate it on its next report if still visible). - Broadcast: every applied delta is fanned out immediately (no throttling) to both the
"vessels"and"ais"WebSocket channels. - Persistence: every applied delta is also pushed onto a bounded
mpscqueue to a writer task, decoupling the live path from the database. On queue-full, the newest frame is dropped and counted — the WS actor never blocks. - Stale-sweep: a periodic task (every 30s) removes vessels whose
last_updateexceedsstale_timeout_secs(default 1800s / 30 min) and broadcasts aremovedelta for each.
Storage Model
Static data only is persisted to Postgres. Positions are transient — Redis + WebSocket only, never Postgres. There is no position history and no vessel tracks.
| Store | What | When written |
|---|---|---|
Postgres "Vessel" (registry) | Static fields: mmsi, name, callsign, ship_type_code, length, beam, source, last_device_id, first_seen, last_seen | Upserted only when the vessel is new or a static field changed (not on every position tick). Row is kept on remove (the directory is durable; live presence is transient). |
TimescaleDB "VesselStaticHistory" (append-only hypertable, 7-day compression — same pattern as AuditEvent) | Same static fields + device_id + recorded_at | One row per real static-data change (rename, dimension correction, ship-type change), diffed against the persisted registry so API restarts don't create duplicate rows |
Redis ais:vessel:latest:{mmsi} | The full current vessel (static + position: lat/lng/heading/course/speed_kmh/nav_status) | Written on every applied delta; deleted (DEL) on remove |
Migrations: crates/heimdall-db/migrations/20260728000000_vessel_registry.up.sql (main PG), crates/heimdall-db/tsdb_migrations/20260728000300_vessel_static_history.up.sql (TimescaleDB hypertable).
Configuration
Cross-cutting settings live in [ais]; provider-specific settings nest under [ais.<provider>] (currently [ais.aisstream]) so further AIS providers can be added as sibling sub-tables (mirrors Apollon).
[ais]
enabled = false # MASTER switch; env: HEIMDALL__AIS__ENABLED
ignore_mmsi = [] # env: HEIMDALL__AIS__IGNORE_MMSI (comma-separated MMSI blocklist)
stale_timeout_secs = 1800 # env: HEIMDALL__AIS__STALE_TIMEOUT_SECS
local_priority_ttl_secs = 120 # env: HEIMDALL__AIS__LOCAL_PRIORITY_TTL_SECS
[ais.aisstream] # aisstream.io provider
enabled = false # env: HEIMDALL__AIS__AISSTREAM__ENABLED
api_key = "" # env: HEIMDALL__AIS__AISSTREAM__API_KEY
radius_km = 30.0 # env: HEIMDALL__AIS__AISSTREAM__RADIUS_KM
[ais]is cross-cutting:enabledis the master switch (when false, no AIS source runs);ignore_mmsiapplies to every source;stale_timeout_secsgoverns the stale-sweep inVesselStore(both sources);local_priority_ttl_secsis the local-priority merge window described above.[ais.aisstream]configures the aisstream.io client only. It starts when both the master[ais].enabledand[ais.aisstream].enabledare true andapi_keyis non-empty. The Apollon forward-ingest path is independent (gated byais:write, not this config).
WebSocket Channels
Subscribe over the WebSocket API:
"vessels"— public, no authentication required. Serves the internal map and any future public traffic map (AIS positions are public data)."ais"— gatedais:read. Same data, for developers/API-key consumers who need identifiable, meterable, revocable access.
Both channels carry the same per-delta envelope the ingest path uses (symmetric protocol):
{ "type": "vessels", "action": "spawn", "vessel": { /* full vessel object */ } }
{ "type": "vessels", "action": "update", "vessel": { /* full vessel object */ } }
{ "type": "vessels", "action": "remove", "mmsi": 244125253 }
{ "type": "vessels", "action": "snapshot", "vessels": [ /* full vessel objects */ ] }
A snapshot frame is sent to a session right after it subscribes to "vessels" or "ais", seeding its initial state. Clients must merge by mmsi (upsert on spawn/update, delete on remove) rather than replacing their state wholesale.
Vessel object (wire, snake_case)
interface WireVessel {
mmsi: number;
name: string;
ship_type: number | null; // numeric ITU code
nav_status: number | null; // numeric ITU-R M.1371 code
length: number | null;
beam: number | null;
callsign: string | null;
lat: number;
lng: number;
heading: number | null; // null when unavailable (AIS code 511)
course: number | null;
speed_kmh: number; // converted from knots
source: 'local' | 'aisstream';
last_seen: string; // ISO 8601
}
Query API (REST + GraphQL)
Persisted vessel data (the registry + Redis hot cache + static-history change-log) is queryable, gated ais:read:
- REST:
GET /v1/vessels(directory),GET /v1/vessels/{mmsi}(detail),GET /v1/vessels/{mmsi}/history(static-history change-log) - GraphQL:
vessels(filter, page, limit),vessel(mmsi),vesselHistory(mmsi, page, limit)
Full reference — endpoint params, field mapping, response shapes, error codes, GraphQL types (GqlVessel/VesselFilterInput/PaginatedVessel/GqlVesselHistoryEntry/PaginatedVesselHistory) — is documented in Vessels API. In short: the directory always returns null position fields (registry is static-only); the detail endpoint reads Redis first (live position) and falls back to the registry (position null) on a cache miss; vesselHistory/GET .../history is paginated per-MMSI and logs static-data changes only (name/callsign/ship-type/length/beam) — never positions, since those are never persisted. The webapp vessel detail page (/ais/vessels/{mmsi}) renders this as a "Static History" table.
There is no separate write endpoint — vessel data is written internally by the ingest pipeline (aisstream client + Apollon forward-ingest), not through this API.
Receiver Stations
An AIS receiver (e.g. an Apollon station) is modeled as a Device with category = "ais_receiver" — there is no separate AisReceiver model. device_type is just a free label the receiver's owner can set (e.g. hardware/model), not a discriminator. Authentication for forwarding is: the station's Device.id (sent as X-Device-ID) + a system API key bound to that device with ais:write.
Manage receiver stations in the backend console under AIS → Receivers (/ais/receivers), which reuses the existing device create/update flow and API-key device-binding UI (gated devices:read/devices:write).
Permissions
Resource ais, actions read / write (migration 20260728000200_ais_permissions):
| Permission | Grants |
|---|---|
ais:read | Subscribe to the "ais" WS channel; query GET /v1/vessels, GET /v1/vessels/{mmsi}, GraphQL vessels/vessel |
ais:write | Forward vessel deltas over /v1/ws (Apollon / receiver stations) |
role_moderator and role_api_read_only are seeded with ais:read. No human/ops role is seeded with ais:write — it's a machine-ingest capability for receiver-station keys (is_system_key() also satisfies the gate). The public "vessels" channel requires no permission.
Frontend Integration
Use the useVessels hook and VesselMarker component from @elcto/ui:
import { useVessels, VesselMarker } from '@elcto/ui';
const { vessels, connected } = useVessels(wsUrl);
useVessels subscribes to the public "vessels" channel and maintains client-side state by merging spawn/update/remove/snapshot deltas by mmsi (no bounding-box parameter — the delta protocol is not bbox-scoped).
VesselMarker renders a color-coded dot on the map:
- Orange — moving vessel
- Gray — stationary or moored
The marker includes a directional arrow (heading) and a cycling info badge (name, speed, ship type). It takes human-readable ship_type/nav_status label strings — translate the numeric wire codes with translateShipType()/translateNavStatus() (@elcto/ui) before passing them in.
Vessel Data Notes
- Speed is stored and transmitted in km/h (converted from AIS knots:
knots × 1.852). - Heading value
511in the AIS protocol means "unavailable" — mapped tonull. - Ship type and navigation status are numeric ITU codes on the wire; labels are derived client-side via
translateShipType()/translateNavStatus()utilities in@elcto/ui. - Apollon sends numeric
ship_type_code/nav_status_codedirectly; if absent, Heimdall attempts a best-effort reverse-map from Apollon's label strings to Heimdall's ITU tables, but the vocabularies differ (e.g."Under Way"vs."Under way using engine") so this mostly yieldsnullfornav_status.
Reconnection
The aisstream.io client uses exponential backoff (3s → 60s max) on disconnect. It handles both Text and Binary WebSocket frames and replies to server Ping with Pong (keepalive), so the connection is not dropped for missing pongs. A GPS move past the 1 km threshold re-subscribes the new bounding box on the same open socket (in place) rather than reconnecting, so relocation never thrashes the stream or loses in-flight traffic. Apollon's forward-ingest connection is stateless across reconnects — there is no server-side session cleanup or replay; on reconnect Apollon simply resumes sending deltas.
Observability (aisstream client)
Individual received vessels are not logged at INFO — only connection lifecycle is (connect / subscribe / relocate / reconnect). So an empty INFO log does not mean no data is arriving. To see throughput and liveness:
-
Periodic summary (
INFO, every 60s while connected) — emitted even at zero traffic, so an idle-but-connected stream is visible:AIS: 42 msgs in last 60s (37 position, 5 static), 12 vessels liveFields:
received/position_reports/static_reports(deltas in the window),live_vessels(current map size),total_received(cumulative).received = 0with a healthy connection means aisstream.io has no vessels in the bounding box (e.g. inland coverage gaps) — not a bug. -
Per-vessel detail (
DEBUG) — run withRUST_LOG=heimdall_websocket::ais=debugto log each ingestedPositionReport/ShipStaticData(MMSI + position) and every parse failure.
Persistence does not discriminate by source: a vessel first seen by aisstream lands in the registry as source = "aisstream" exactly like a local one. If the registry only shows source = "local", the aisstream client simply isn't receiving vessels for the current bounding box (verify with the summary log above), not a filtering issue. To check the split directly: SELECT source, count(*) FROM "Vessel" GROUP BY source;.