AIS / Marine

GopherTrunk decodes the Automatic Identification System messages commercial vessels broadcast on marine VHF channels 87B / 88B (161.975 / 162.025 MHz). AIS is the “transponder for ships” — every SOLAS-covered vessel (passenger ships, tankers, cargo > 300 GT) transmits Class A position reports continuously, and recreational vessels increasingly carry Class B units. The data is useful for: marine-coast monitoring, traffic deconfliction inside shipping lanes, search-and-rescue coordination, port arrival / departure tracking, and as a free positional ground-truth for receivers that want a known-good wide-area data feed.

This page documents the pipeline scaffolding: what’s wired, what’s persisted, what’s queryable, and what the web panel renders. The DSP layer (9600 Bd GMSK demod → HDLC framer → message parser) is tracked separately under “What’s pending” below.

What’s wired

Events bus

  • events.KindAISMessage — published once per decoded AIS message off the air. Payload is a storage.AISMessage carrying MMSI + message type + (for position-bearing types) lat/lon/COG/SOG/ heading + (for static types) vessel name / callsign / destination / ship type / IMO.

Storage

  • New SQLite table vessel_log (append-only migration alongside aprs_log, pager_log, etc.):
    • received_at (nanoseconds), mmsi, type, body, latitude, longitude, sog, cog, heading, has_position, vessel_name, callsign, destination, ship_type, imo, raw_hex, fcs_ok
    • Indexes on (received_at) and (mmsi, received_at)
  • storage.VesselLog bus subscriber writes one row per KindAISMessage event. Mirrors the APRSLog lifecycle — subscribes at construction so events published before Run() begins aren’t lost.

REST

  • GET /api/v1/ais/vessels?limit=N — most recent messages, newest first. Default 200, max 5000. Returns 503 when the storage layer isn’t wired (daemon started without storage.path).

Web panel

  • /ais — live list with columns:
    • Received (HH:MM:SS, daemon clock)
    • MMSI (with vessel name on a second line for static types)
    • Type (position-a / position-b / static-voyage / static-b / base-station / aid-to-nav / …)
    • Body (one-line summary)
    • Lat / Lon (em-dash for static-only messages)
    • SOG / COG (knots / degrees, em-dash when not applicable)
  • Polls every 5 s. Messages with fcs_ok = false highlight in yellow as a marginal-signal indicator.

Protocol layer (internal/radio/ais)

Pure-Go AIS message parser turning the bit-stream payload into the events.KindAISMessage payload the bus / log / REST / UI scaffolding above expects.

  • 6-bit ASCII (M.1371-5 Table 47) — packed-text fields (vessel name, call-sign, destination) decode via the standard 64-entry character table.
  • Bit-field readersreadBitsUint and readBitsInt pull MSB-first signed / unsigned integers from the unpacked bit stream. Signed-field sign-extension handles the spec’s signed lat/lon fields (28-bit longitude, 27-bit latitude, resolution 1/600000 minute = ~0.18 m).
  • Type dispatch (M.1371-5 §3.3) — bytes [0..5] are the 6-bit message-type tag, [6..7] the repeat indicator, [8..37] the 30-bit MMSI. Type-specific layouts:
    • 1, 2, 3 — Class A position report (nav status, SOG, lat/lon, COG, heading, timestamp). 168 bits.
    • 4 — base-station report (UTC + lat/lon). 168 bits.
    • 5 — static + voyage data (IMO, call-sign, vessel name, ship type, dimensions, ETA, draught, destination). 424 bits.
    • 18 — Class B position report. 168 bits.
    • 19 — Class B extended position report (Class B with vessel name + ship type appended). 312 bits.
    • 24 — Class B static data report — Part A (vessel name) or Part B (call-sign + ship type + dimensions).
  • “Not available” sentinel handling — lat 91° / lon 181° collapses HasPosition to false; SOG / COG not available values pass through as their raw spec sentinels.

Spec references:

  • ITU-R M.1371-5 (Recommendation, 2014) — bit-by-bit layout for every message type.
  • https://gpsd.gitlab.io/gpsd/AIVDM.html — the de-facto reference parser docs (gpsd’s AIVDM decoder), cross-checked against real on-the-air payloads.

Bit-stream pipeline (internal/radio/ais/receiver)

The orchestrator. Threads bits through the HDLC framer (reused from aprs/hdlc), validates the trailing CRC-CCITT (same polynomial 0x8408 / init 0xFFFF / final XOR 0xFFFF AX.25 uses — AIS inherits the HDLC link-layer conventions verbatim per ITU-R M.1371-5 §4.2), unpacks the payload bytes into the MSB-first bit slice the AIS message parser expects, and publishes one events.KindAISMessage per successfully-parsed message.

  • internal/radio/ais/receiverPush(bit byte) consumes one LSB-first wire bit. Bus payload is storage.AISMessage carrying MMSI + type + (for position-bearing types) lat/lon + COG / SOG + heading + (for static types) vessel name + callsign
    • destination + ship-type + IMO. Options expose DropBadFCS (silent-drop CRC-failed messages; default false) and DropNonPosition (silent-drop static / base-station chatter; default false). The receiver counts frames in / parsed / CRC-failed / emitted / too-short for future /metrics surfacing.

DSP frontend (internal/radio/ais/gmsk)

The IQ-to-bits layer. One gmsk.Receiver per configured AIS channel; the daemon subscribes each to its assigned SDR’s iqtap broker. Pipeline:

IQ chunks (Fs Hz, complex64)
  → FM demod (internal/dsp/demod/fm.FM)
  → real resampler down to 76,800 sps audio
  → GFSK matched filter (BT = 0.4, span 4 symbols)
  → Mueller-Müller symbol-timing recovery (8 sps → 1 sample/symbol)
  → zero-threshold slicer (raw NRZI bit)
  → NRZI decode (transition = 0, no transition = 1)
  → ais/receiver.Push(bit)
  → events.KindAISMessage on the bus

Stats() surfaces IQ-samples-seen + bits-emitted counters for /metrics. The bit-stream layer’s own Stats() (frames in / parsed / CRC-failed / emitted / too-short) is reachable via Inner().

Configuration

ais:
  channels:
    - serial: "marine-antenna"
      frequency_hz: 161_975_000      # 87B (or 162_025_000 = 88B)
      drop_bad_fcs: false            # default false; publishes CRC-failed
                                     #  messages with FCSOK=false
      drop_non_position: false       # default false; static-data and
                                     #  base-station messages still surface

A misconfigured entry surfaces as a startup warning and is skipped — same non-essential treatment as paging.pocsag and aprs.channels.

What’s pending

  • Real-fixture validation. The synthetic IQ end-to-end test for the GMSK frontend is deferred to the same follow-up that drops captured samples/ais/ recordings; the receiver code is exercised end-to-end by the bit-stream synthetic in internal/radio/ais/receiver/receiver_test.go (AIVDM type-1 sample → buildAISFrame → HDLC wrap → Receiver.Push → bus event with expected MMSI + lat/lon).
  • Multi-slot frame reassembly. Several message types span two AIS slots when transmitted (type 5 + type 19 + type 26). The current parser handles the single-slot variants; the multi-slot path needs a per-MMSI buffer plus the channel-A / channel-B re-orderer.

    Live map

Position-bearing rows (Class A / B position reports, base station, SAR aircraft) light up the shared Leaflet map at the top of /ais — cyan markers plotted on the OpenStreetMap tile layer with vessel name (or MMSI) on hover, camera auto-fits to the current rowset. The same <PositionMap> component renders on /aprs, /dsc, and /adsb.

References

  • ITU-R M.1371-5 (2014) “Technical characteristics for an automatic identification system” — https://www.itu.int/rec/R-REC-M.1371
  • AIVDM/AIVDO protocol decoding — https://gpsd.gitlab.io/gpsd/AIVDM.html