Skip to main content

AIS Vessel Tracking

Heimdall tracks nearby vessels from two coexisting sources:

  1. aisstream.io — Heimdall's own aisstream.io client (AisClient) subscribes to a bounding box around the current GPS position for global coverage.
  2. Forward-ingest (Apollon) — a local AIS receiver station (Apollon) forwards vessel deltas over the existing /v1/ws WebSocket 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​

  1. AisClient (crates/heimdall-websocket/src/ais.rs) connects to wss://stream.aisstream.io/v0/stream.
  2. 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.
  3. Two message types are processed: PositionReport (position/speed/heading) and ShipStaticData (name/type/dimensions).
  4. Every decoded message is delegated to VesselStore::apply_delta() — AisClient no longer owns its own vessel map.
  5. 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 local remove drops 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 mpsc queue 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_update exceeds stale_timeout_secs (default 1800s / 30 min) and broadcasts a remove delta 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.

StoreWhatWhen written
Postgres "Vessel" (registry)Static fields: mmsi, name, callsign, ship_type_code, length, beam, source, last_device_id, first_seen, last_seenUpserted 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_atOne 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: enabled is the master switch (when false, no AIS source runs); ignore_mmsi applies to every source; stale_timeout_secs governs the stale-sweep in VesselStore (both sources); local_priority_ttl_secs is the local-priority merge window described above.
  • [ais.aisstream] configures the aisstream.io client only. It starts when both the master [ais].enabled and [ais.aisstream].enabled are true and api_key is non-empty. The Apollon forward-ingest path is independent (gated by ais: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" — gated ais: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):

PermissionGrants
ais:readSubscribe to the "ais" WS channel; query GET /v1/vessels, GET /v1/vessels/{mmsi}, GraphQL vessels/vessel
ais:writeForward 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 511 in the AIS protocol means "unavailable" — mapped to null.
  • 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_code directly; 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 yields null for nav_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 live

    Fields: received/position_reports/static_reports (deltas in the window), live_vessels (current map size), total_received (cumulative). received = 0 with 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 with RUST_LOG=heimdall_websocket::ais=debug to log each ingested PositionReport/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;.