FleetSync / Kenwood Signalling
GopherTrunk decodes FleetSync — the analog in-band FFSK burst Kenwood two-way radios key at the start of a transmission. It carries the radio’s fleet number and unit ID (ANI — automatic number identification) on otherwise-analog conventional VHF / UHF voice channels, so on a Kenwood fleet that is just FM voice it tells you which radio is talking. Both the original FleetSync frame and the error-corrected FleetSync II frame are decoded.
Added in response to #437 and verified against the on-air captures in #1184.
Modulation
FleetSync is a 1200-baud FFSK burst using the CCIR tones mark = 1200 Hz (binary 1) and space = 1800 Hz (binary 0), carried inside the narrowband-FM voice channel — the same modulation class as MDC1200, so the DSP frontend reuses the same building blocks. The line code is plain NRZ, sliced at a fixed zero threshold (a tracked threshold measurably hurts here: a FleetSync II frame can open with ~68 symbols of continuous space tone).
Pipeline
IQ chunks (Fs Hz, complex64)
→ FM demod (internal/dsp/demod.FM)
→ real resampler to 9600 Hz (1200 baud × 8 oversample)
→ FFSK tone discriminator (mark 1200 Hz / space 1800 Hz)
→ Mueller-Müller symbol-timing recovery (8 sps → 1 sample/symbol)
→ zero-threshold NRZ slicer
→ 24-bit preamble + 16-bit sync framer (internal/radio/fleetsync.Framer)
→ FleetSync I block check, then FleetSync II ECC (internal/radio/fleetsync)
→ events.KindFleetSyncMessage on the bus
→ storage.FleetSyncLog → fleetsync_log SQLite table
→ GET /api/v1/fleetsync/messages → /fleetsync web panel
After the alternating preamble and the 16-bit sync word 0xA23E, the
frame carries two 32-bit data words. A FleetSync frame validates
them with a 16-bit block check (a bit-serial LFSR, polynomial
0x6815); a FleetSync II frame protects the same words with four
64-bit single-error-correcting blocks and is tried when the FleetSync I
check fails. The fleet is word 1’s second byte plus 99; the unit ID is
word 1’s third byte and the high nibble of word 2’s first byte, plus
- The sync hunt accepts the bit-complemented sync word so a flipped FM discriminator (inverted tone sense) still decodes.
Configuration
Each entry pins one SDR to a conventional analog voice channel:
fleetsync:
channels:
- serial: "uhf-antenna"
frequency_hz: 462_562_500 # the analog voice channel to monitor
baud_hz: 1200 # 0 / omitted = 1200; 2400 also accepted
drop_bad_crc: false # true to drop block-check-failed bursts
Leave drop_bad_crc false to see block-check-failed bursts on the
panel (flagged with crc_ok=false); flip it on for noisy channels.
Like the other message decoders, FleetSync needs storage.path set —
without it the receiver runs but nothing is persisted, the REST endpoint
answers 503 and the panel stays empty (gophertrunk doctor warns).
On a conventional scanner channel (no dedicated SDR)
A channel the conventional scanner already monitors can run the decoder on its own IQ instead (issue #1220):
scanner:
conventional:
- label: "Kenwood fleet"
frequency_hz: 462_562_500
mode: nfm
decoders: [fleetsync] # and/or mdc1200
The scanner decimates its IQ to one ~48 kHz channel (behind a ±8 kHz channel filter) before the decoder sees it, so the decoder works the same at any SDR sample rate. It runs on every chunk that opens the channel’s squelch and on the whole dwell; a burst whose sync word has been heard holds the channel until it is framed, so the scanner neither hops away nor ends the call on hangtime mid-burst. The CTCSS/DCS gate does not apply: a burst carries its own sync word and check, so it is logged even when the tone gate stays shut. Bursts are stamped with the scanner’s SDR serial and the channel frequency (the panel’s Channel column). A burst sent while the scanner is on another channel is missed — that is the trade against pinning an SDR.
What’s surfaced
- Bus event —
events.KindFleetSyncMessage(fleetsync.messageover SSE / WS), payloadstorage.FleetSyncMessage:fleet,unit,fs2,crc_ok,raw_hex,body,received_at. - Storage — the
fleetsync_logSQLite table (fleet, unit, fs2, body, raw hex, crc_ok), indexed by time and by fleet + unit; swept byretention.log_dayswith the other decoder logs. - REST —
GET /api/v1/fleetsync/messages?limit=N(default 200, max 5000); 503 when the daemon runs withoutstorage.path. - Web — the
/fleetsyncpanel polls every 5 s and shows fleet, unit, the channel that produced the ID (frequency and SDR serial of thefleetsync.channelsentry, alsoserial/frequency_hzon the event and REST rows), the FleetSync / FleetSync II variant, the raw words and the block-check result. Hide it withweb.tabs.fleetsync: false.
A receiver that cannot tune or stream its dongle at start-up (an RTL-SDR
control-pipe stall on the first PLL write, a pump that dies later) is
retried with backoff for the daemon’s lifetime rather than skipped — the
log shows fleetsync: SetCenterFreq failed — retrying and then
SetCenterFreq recovered. Only a serial that is not in the pool at all is
skipped.
Verifying a capture offline
TestFleetSyncReplay runs a recording through the production chain and
prints a per-burst timeline; it reads SDR# baseband WAVs (32-bit float
I/Q), cs16 / f32 raw I/Q and mono discriminator audio, finds the carrier
in a wideband capture, and sweeps 1200 / 2400 baud:
GT_FLEETSYNC_IQ=FleetSync-II.wav go test ./cmd/gophertrunk -run 'TestFleetSyncReplay$' -v
Two channelized slices of the #1184 captures are committed under
internal/radio/fleetsync/afsk/testdata as real-air regression
fixtures (Fleet 107 / Unit 1772).
Licensing note
The FleetSync protocol facts implemented here (sync word, frame layout, block-check polynomial, FleetSync II parity tables, ANI field layout) are public protocol details. This is a clean-room Go implementation; no third-party decoder source — including the GPL-licensed reference decoders — is incorporated, keeping the decoder under the project’s Apache-2.0 license.