WebSocket API
The Apollon API provides a WebSocket endpoint for real-time data broadcasts from the Peplink GPS router. Clients subscribe to named channels and receive structured JSON messages.
Endpoint
GET /ws?token=<token>
Upgrades to a WebSocket connection. Requires a valid API token as query parameter. Returns 503 Service Unavailable if the server is shutting down.
Channels
| Channel | Mode | Description |
|---|---|---|
speed | Polled | Speed in 4 units (km/h, m/s, mph, knots) |
gps | Polled | Location, heading, and DOP (dilution of precision) |
trips | Event-driven | Trip lifecycle events (started, completed, cancelled) |
vessels | Event-driven | AIS vessel positions and static data (per-vessel delta protocol: snapshot/spawn/update/remove/bbox) |
watchlist | Event-driven | Watchlist proximity alerts for tracked vessels |
Connection Flow
Client Server
| |
| --- WebSocket Upgrade (?token=xxx) --> |
| <-- 101 Switching Protocols ---------- |
| |
| --- Subscribe ["speed","gps"] -------> |
| <-- Ack { subscribed: [...] } -------- |
| |
| <-- Speed message ------------------- | (on change, at interval)
| <-- GPS message --------------------- | (on change, at interval)
| <-- Trip event ---------------------- | (when trip state changes)
| |
| <-- Ping ---------------------------- | (every 30 seconds)
| --- Pong ---------------------------> |
| |
| --- Unsubscribe ["speed"] ----------> |
| <-- Ack { unsubscribed: [...] } ----- |
| |
| --- Close --------------------------> |
| <-- Close --------------------------- |
Subscribe / Unsubscribe Protocol
Subscribe
Send a JSON message to subscribe to one or more channels:
{
"action": "subscribe",
"channels": ["speed", "gps"]
}
The server responds with an acknowledgement:
{
"type": "subscribed",
"channels": ["speed", "gps"]
}
Wildcard Subscribe
Subscribe to all available channels at once:
{
"action": "subscribe",
"channels": ["*"]
}
Unsubscribe
{
"action": "unsubscribe",
"channels": ["speed"]
}
The server responds:
{
"type": "unsubscribed",
"channels": ["speed"]
}
Error Handling
If a client sends an invalid message or references an unknown channel, the server responds with an error:
{
"type": "error",
"message": "Unknown channel: invalid_channel"
}
Errors do not close the connection. The client can continue sending valid messages.
Message Formats
Speed Channel
Broadcast when the speed value changes.
{
"type": "speed",
"data": {
"speed_kmh": 45.2,
"speed_mps": 12.56,
"speed_mph": 28.09,
"speed_knots": 24.41
}
}
| Field | Type | Description |
|---|---|---|
speed_kmh | f64 | Current speed in km/h |
speed_mps | f64 | Current speed in meters per second |
speed_mph | f64 | Current speed in miles per hour |
speed_knots | f64 | Current speed in knots |
GPS Channel
Broadcast when the GPS position or heading changes.
{
"type": "gps",
"data": {
"latitude": 52.5200,
"longitude": 13.4050,
"altitude": 34.5,
"heading": 182.3,
"pdop": 1.2,
"hdop": 0.9,
"vdop": 0.8,
"timestamp": "2026-06-10T12:34:56Z"
}
}
| Field | Type | Description |
|---|---|---|
latitude | f64 | Latitude in decimal degrees |
longitude | f64 | Longitude in decimal degrees |
altitude | f64 | Altitude in meters above sea level |
heading | f64 | Heading in degrees (0-360) |
pdop | f64 | Position dilution of precision |
hdop | f64 | Horizontal dilution of precision |
vdop | f64 | Vertical dilution of precision |
timestamp | string | ISO 8601 timestamp of the GPS fix |
Trips Channel
Event-driven messages when trip state changes.
{
"type": "trips",
"data": {
"event": "started",
"tripId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"trip": {
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "ACTIVE",
"senderId": "...",
"receiverId": "..."
}
}
}
| Field | Type | Description |
|---|---|---|
event | string | Event type: started, completed, cancelled |
tripId | string | UUID of the affected trip |
trip | object | Trip snapshot at the time of the event |
Vessels Channel
The vessels channel is a per-vessel delta protocol. Instead of re-sending the full vessel list on
every change, the backend broadcasts a small delta for the one vessel that changed (spawn/update/remove),
a bbox delta when the map circle relocates, and a full snapshot as the greeting for new subscribers. This
lets the frontend move, add, and remove individual markers (and play a spawn fade-in) instead of re-rendering
everything on each tick.
Fed by two independently toggled sources — AISstream.io ([ais.aisstream]) and/or a local NMEA/AIS
receiver ([ais.local]). Both converge on the same vessels channel and payload shape, so subscribers
don't need to know which source produced a vessel — though each vessel carries a source field
("local" / "aisstream") if they want to distinguish. When both sources run (dual mode), the local
receiver takes priority for vessels in antenna range and aisstream is secondary; see
Dual-source handoff below.
The message shape changed from nested ({"type":"vessels","data":{"vessels":[…],"bbox":…}}) to
flat, discriminated by an action field ({"type":"vessels","action":"snapshot","vessels":[…],"bbox":…}).
There is no backward compatibility — an old client reading msg.data.vessels sees nothing. Backend and
frontend must deploy together, and long-lived overlay / OBS / kiosk tabs must be reloaded (cache-busted)
to pick up the new client.
Actions
Every vessels message carries an action discriminator:
action | When | Payload |
|---|---|---|
snapshot | Greeting on subscribe/reconnect | full vessels[] + bbox |
spawn | A new vessel appears | one vessel object |
update | An existing vessel changed (new position, static/name enrichment, or a manual edit via PUT /v1/vessels/{mmsi} / updateVessel) | one vessel object |
remove | A vessel went stale / left range (from cleanup), or was manually deleted via DELETE /v1/vessels/{mmsi} / deleteVessel | mmsi only |
bbox | The map circle relocated (GPS center moved) | bbox only, no vessel |
{"type":"vessels","action":"snapshot","vessels":[ … Vessel objects … ],"bbox":{ … }}
{"type":"vessels","action":"spawn","vessel":{ … Vessel object … }}
{"type":"vessels","action":"update","vessel":{ … Vessel object … }}
{"type":"vessels","action":"remove","mmsi":211234567}
{"type":"vessels","action":"bbox","bbox":{ … }}
Each delta carries the full vessel object (not a field diff) — the client replaces the vessel keyed by
mmsi. An update for an unknown MMSI is treated as an add (self-heal for a missed spawn).
Snapshot / spawn example
{
"type": "vessels",
"action": "snapshot",
"vessels": [
{
"mmsi": 211234567,
"name": "MS Harmonie",
"callsign": "DA1234",
"ship_type": "Cargo",
"ship_type_code": 70,
"nav_status": "Under Way",
"nav_status_code": 0,
"length": 110,
"beam": 11,
"latitude": 48.5734,
"longitude": 7.7521,
"heading": 182.3,
"course": 180.1,
"speed_kmh": 18.52,
"speed_mps": 5.14,
"speed_mph": 11.51,
"speed_knots": 10.0,
"source": "local",
"last_seen": "2026-07-27T12:34:56+00:00"
}
],
"bbox": {
"sw": [48.4734, 7.6521],
"ne": [48.6734, 7.8521]
}
}
| Field | Type | Description |
|---|---|---|
action | string | Message discriminator: snapshot / spawn / update / remove / bbox |
vessels | array | (snapshot) all currently tracked vessels near the GPS position |
vessel | object | (spawn/update) the single vessel that changed |
mmsi | number | (remove) MMSI of the vessel that left / went stale |
bbox | object|null | (snapshot/bbox) map bbox circle bounds (sw/ne corners), or null when no bbox applies |
Vessel Fields
| Field | Type | Description |
|---|---|---|
mmsi | number | Maritime Mobile Service Identity |
name | string | Vessel name (empty if not yet received) |
callsign | string|null | Radio call sign, if known (from static enrichment) |
ship_type | string | Ship type label (e.g. Cargo), Unknown if not available |
ship_type_code | number|null | Raw numeric AIS ship-type code (e.g. 70 = Cargo, 80 = Tanker, 99 = Other), alongside the ship_type label. null if unknown |
nav_status | string | Navigational status label (e.g. Under Way, At Anchor), Unknown if not available. Speed-corroborated: a stationary status like Moored/At Anchor is reported as Under Way when the vessel's SOG is ≥ ~1 knot (0.514 m/s) — AIS nav_status is manually set and often stale, so a reliable GPS-derived speed overrides it. |
nav_status_code | number|null | Raw ITU-R M.1371 navigational-status code (0–15), alongside the nav_status label. Not speed-corrected — this is the value straight off the air. null if unknown |
length | number|null | Length in meters, if known |
beam | number|null | Beam in meters, if known |
latitude / longitude | f64 | Current position |
heading | f64|null | True heading in degrees |
course | f64|null | Course over ground in degrees |
speed_kmh / speed_mps / speed_mph / speed_knots | f64 | Speed in all 4 units (same rounding/conversion factors as the speed channel) |
source | string | Effective tracking source for this vessel: local (physical NMEA/AIS receiver) or aisstream (AISstream.io). In dual mode the value can change when a vessel is handed off between sources. Lets the frontend distinguish / style vessels by origin. |
last_seen | string | RFC 3339 timestamp of when the vessel was last heard from (any AIS message). Drives staleness / removal from the map. |
stale | bool | Present and true only for cold-start-seeded vessels whose position may be minutes old; omitted otherwise. Cleared by the first live position (update). |
Display labels vs. raw codes: ship_type / nav_status are coarse English category
labels for convenience — clients should derive their own display labels from the raw codes
(ship_type_code / nav_status_code), which carry the full ITU-R M.1371 granularity
(Hazardous A–D, Reserved, HSC/WIG, …). The Apollon web frontend does this via
shipTypeLabel(code) / navStatusLabel(code) in @elcto/apollon-api (German, full 0–99 / 0–15
coverage); the live map uses shipTypeLabelMap(code), a map-specific variant with shorter/colloquial
labels. nav_status is additionally speed-corrected (see above), the raw nav_status_code is not.
Speed units: vessel speed is decoded once as AIS-native knots and converted once to m/s in the
vessel tracker; the channel then derives all 4 units (speed_kmh, speed_mps, speed_mph, speed_knots)
from that, mirroring the speed channel's GpsData conversion factors.
bbox: the aisstream source's bbox reflects its [ais.aisstream] radius_km subscription area. The
local source derives bbox from the optional [ais.local] reception_range_km — if unset, no bbox is
broadcast (bbox is null) and received vessels are never filtered by range (a physical VHF receiver has
no fixed radius). In dual mode the aisstream subscription bbox is used.
Greeting (snapshot): When a client subscribes (or reconnects), the current full state is sent
immediately as one snapshot message — the client doesn't wait for the next delta.
Dual-source handoff
When both [ais.aisstream] and [ais.local] are enabled, the local receiver has priority. A vessel
heard live via the local antenna is tracked from local and is not double-tracked from aisstream —
while its local feed is fresh, incoming aisstream positions for that MMSI are suppressed. Local always wins
for in-range vessels.
Once a vessel leaves antenna range and goes silent on the local feed for longer than
[ais] local_priority_ttl_secs (default 120 s), the local hold expires and aisstream resumes
tracking it (handoff) — the vessel's source flips to aisstream on the next update. Vessels aisstream
covers that were never heard locally are tracked from aisstream throughout. Each vessel's source field
always reflects the source currently driving its position, so a handoff is visible to subscribers.
Cold-Start Persistence
The greeting snapshot is also persisted to Redis (key ais:vessels:snapshot, TTL
[redis] vessel_snapshot_ttl_secs, default 900 s / 15 min) and re-seeded on startup. After a backend
restart the map is therefore populated immediately from the last known snapshot — moored or slow vessels
don't sit invisible for minutes waiting for fresh AIS. Seeded vessels are marked "stale": true (their
position may be minutes old); the first live position for each clears stale via an update delta. Redis
save/load errors are logged and swallowed — a missing or unreachable Redis simply means an empty start (the
previous behavior), never a broken AIS loop. See the [redis] configuration.
AIS Static Enrichment
Over the raw AIS air (the local receiver), position frames (every 2–10 s) and static frames
(name, ship type, dimensions, call sign — only every ~6 min, often split across fragments) arrive
separately. A freshly seen vessel can otherwise sit for minutes as a nameless dot, and static learned
before its first position — or across restarts — used to be lost. A two-tier static registry fixes this
by accumulating the static fields per MMSI and enriching every position from it.
Two-tier store
| Tier | Backend | Role |
|---|---|---|
| Source of truth | PostgreSQL vessels table | Durable, MMSI-keyed registry — survives restarts, SQL-queryable |
| Cache (L1) | Redis (ais:static:{mmsi}, TTL) | Read-through / write-through accelerator in front of Postgres |
| Audit | TimescaleDB vessels_history hypertable | Append-only log of every real static-field change |
Only the static fields are cached (name, ship_type, length, beam, callsign). Dynamic values
(lat/lon/speed/course/heading) are never cached — they are seconds-fresh and a stale copy would be wrong.
Flow
- On a static frame: the registry is always written (even before any position for that MMSI — this fixes the static-before-position drop). Fields merge field-by-field, so a fragment carrying only the name never clobbers one carrying type/dimensions, and vice versa.
- On a position frame: if the vessel is not yet enriched, the registry is read once (Redis hit → else Postgres → then Redis is populated) to fill name/ship type/dimensions/call sign; live position data is never overwritten.
Compare-then-write — writes are not blind. Gaps are filled (no audit), identical values are skipped, and a real change is handled by two policies:
- Policy A (audit): every real field change is appended to
vessels_history(best-effort — the audit insert never blocks the registry update). - Policy B (clear-on-name-change): when the name changes (typical of an MMSI being reassigned to a different vessel), the other static fields are reset instead of merged — and the Redis entry is cleared — to avoid a "Frankenstein" record mixing the old and new vessel.
Graceful degradation: Postgres is always present, so the registry is always functional. If Redis is
unreachable, enrichment runs Postgres-only (slower, still complete); any registry error is logged and never
propagated into the AIS/position path. See the [redis] configuration.
AIS Forwarding to Heimdall
Apollon can forward its local-receiver vessels to a sister project (Heimdall)
so Heimdall's map gains the near-/inland-range coverage a physical A027+ antenna sees but AISstream may miss.
Enabled via [ais.forward] — Apollon opens one persistent outbound
WebSocket to Heimdall (as a client) and pushes vessel deltas onto it.
- Local only. Only vessels with
source == "local"are forwarded.aisstreamvessels are never forwarded — Heimdall runs its own aisstream client, so the two coexist: Apollon's local feed supplements Heimdall's global map (local-priority merge recommended on Heimdall's side). - Same wire protocol. Frames reuse the
vesselsdelta protocol — a subset:spawn/update/removeonly (nosnapshot, nobbox), each{"type":"vessels","action":…,"vessel"|"mmsi":…}. Thevesselobject is the same snake_case shape broadcast to Apollon's own map, includingship_type_codeandnav_status_code. - Auth.
Authorization: Bearer {token}on the upgrade request, plus an optionalX-Device-IDheader ([ais.forward] token/device_id). - Fire-and-forget. No queue and no snapshot/greeting on connect — Apollon just starts streaming deltas. On disconnect it reconnects with exponential backoff; a fresh connection simply resumes streaming.
| Config | Env | Default | Description |
|---|---|---|---|
ais.forward.enabled | APOLLON__AIS__FORWARD__ENABLED | false | Enable forwarding of local vessels |
ais.forward.url | APOLLON__AIS__FORWARD__URL | (none) | Heimdall forward-AIS WebSocket endpoint (e.g. wss://<heimdall>/v1/ws) |
ais.forward.token | APOLLON__AIS__FORWARD__TOKEN | (none) | Bearer token |
ais.forward.device_id | APOLLON__AIS__FORWARD__DEVICE_ID | (none) | Optional X-Device-ID header |
The full wire contract (frame shapes, exact field schema, nullability) lives in
docs/superpowers/specs/2026-07-28-heimdall-forward-ais-receiver-brief.md.
Watchlist Channel
Broadcast when a tracked vessel's proximity status changes. The message contains all currently active alerts, sorted by highest stars first, then closest distance.
{
"type": "watchlist",
"data": {
"alerts": [
{
"mmsi": 211234567,
"name": "MS Harmonie",
"category": "Freighter",
"stars": 3,
"distanceKm": 4.2,
"starColor": "#EF4444",
"soundTrigger": {
"type": "alert",
"soundKey": "sonar",
"volume": 80
},
"displayType": "stars",
"soundEnabled": true,
"overlayEnabled": true,
"overlayIcon": null
}
],
"topAlert": {
"mmsi": 211234567,
"name": "MS Harmonie",
"category": "Freighter",
"stars": 3,
"distanceKm": 4.2,
"starColor": "#EF4444",
"soundTrigger": null,
"displayType": "stars",
"soundEnabled": true,
"overlayEnabled": true,
"overlayIcon": null
}
}
}
| Field | Type | Description |
|---|---|---|
alerts | array | All active alerts, sorted by stars desc then distance asc |
topAlert | object|null | Highest-priority alert (most stars, closest) |
Alert Fields
| Field | Type | Description |
|---|---|---|
mmsi | number | MMSI number of the vessel |
name | string | Display name |
category | string|null | Category name (if assigned) |
stars | number | Star count (0-5) based on distance thresholds |
distanceKm | number | Current distance in km |
starColor | string | Hex color for star display |
soundTrigger | object|null | Sound to play (type: alert/danger, soundKey, volume) |
displayType | string | Display mode: stars or custom |
soundEnabled | boolean | Whether sound is enabled for this alert |
overlayEnabled | boolean | Whether overlay display is enabled |
overlayIcon | string|null | Lucide icon name (for custom display type) |
Greeting: When a client subscribes, the last known watchlist state is sent immediately with soundTrigger set to null on all alerts (no sounds on reconnect).
Configuration
WebSocket behavior is configured via the websocket section in the config file or APOLLON__WEBSOCKET__* environment variables:
| Config Key | Env Var | Default | Description |
|---|---|---|---|
websocket.path | APOLLON__WEBSOCKET__PATH | /ws | WebSocket endpoint path |
websocket.speed_interval_ms | APOLLON__WEBSOCKET__SPEED_INTERVAL_MS | 1000 | Speed broadcast interval in milliseconds |
websocket.gps_interval_ms | APOLLON__WEBSOCKET__GPS_INTERVAL_MS | 1000 | GPS broadcast interval in milliseconds |
JavaScript Client Example
const ws = new WebSocket('ws://localhost:3000/ws?token=YOUR_API_TOKEN');
ws.onopen = () => {
console.log('Connected to Apollon WebSocket');
// Subscribe to channels
ws.send(JSON.stringify({
action: 'subscribe',
channels: ['speed', 'gps', 'trips', 'watchlist'],
}));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'subscribed':
console.log('Subscribed to:', message.channels);
break;
case 'speed':
console.log(`Speed: ${message.data.speed_kmh} km/h`);
break;
case 'gps':
console.log(`Position: ${message.data.latitude}, ${message.data.longitude}`);
break;
case 'trips':
console.log(`Trip ${message.data.event}: ${message.data.tripId}`);
break;
case 'watchlist':
console.log(`Watchlist: ${message.data.alerts.length} active alerts`);
break;
case 'error':
console.error('Server error:', message.message);
break;
}
};
ws.onclose = (event) => {
console.log(`Disconnected: code=${event.code}`);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
Architecture
PeplinkRouter / MockProvider
|
v
GPS Provider (shared)
| |
v v
SpeedBroadcaster GpsBroadcaster (poll GPS at interval)
| |
v v
speed::broadcast gps::broadcast (tokio broadcast channels, capacity: 256)
| |
+------+------+ TripBroadcaster (event-driven)
| |
v v
WsServer ---- trips::broadcast (tokio broadcast channel)
| |
Client Client (each subscribes to selected channels)
^
|
MergeConsumer (single tracker owner) ---> vessels::broadcast + watchlist::broadcast
|
run_aisstream_producer (WebSocket) AND/OR run_local_producer (apollon-ais-nmea, TCP)
| (each toggled independently; both = dual mode, local priority)
WatchlistMatcher (distance thresholds → star alerts)
SpeedBroadcaster
Runs as a tokio task that polls the GPS provider at the configured interval, compares the new speed to the last broadcast, and only sends if the value changed. Stores the last broadcast for greeting new subscribers.
GpsBroadcaster
Runs as a tokio task that polls the GPS provider at the configured interval, compares the new position/heading/DOP to the last broadcast, and only sends if any value changed. Stores the last broadcast for greeting new subscribers.
TripBroadcaster
Publishes trip lifecycle events (started, completed, cancelled) to the trips broadcast channel. Events are fired by the trip management endpoints when trip state changes.
WsServer
Manages client connections, channel subscriptions, and broadcast distribution. Rejects new connections during shutdown (503). Each client connection spawns a tokio task that listens for broadcasts on subscribed channels, pings, and client messages concurrently via tokio::select!.