Read the APRS channel: who is out there, and what they said

A third section, alongside the aircraft and the weather sensors, and for the
same reason as both: a scan stops on a signal, records it and moves on, while
APRS is a two-second transmission every few minutes from a hundred stations
sharing one frequency.  A sweep catches whichever happened to key up as it
passed.  `bandsaunter aprs` parks on the channel and catches all of them;
`bandsaunter packets` reads a log back.

Four layers, three of them new.

The link layer was already here, opportunistically, in the generic decoder --
a correlator, NRZI, HDLC and a checksum, run on whatever a scan happened to
record.  It is now a receiver.  What had to change is the state that survives
a block boundary: the tail of the audio so the correlators see no edge, the
phase of the sampling loop so a bit is not lost where one block meets the
next, the tone the line was last at, and the bits themselves.  A packet is
most of a second and a block is about one, so frames straddling the boundary
are not an edge case, they are most of them.

Above that, the APRS information field, which is not one format but about
twenty, chosen by the first character and accreted over thirty years.
Positions uncompressed and compressed; Mic-E, which every Kenwood and Yaesu
mobile sends and which hides the latitude inside the destination callsign
because in 1995 those six bytes were carrying the word "APRS" and nothing
else; weather with a position and without; messages, acknowledgements,
rejections and bulletins; objects and items; status; telemetry; third-party
traffic, credited to whoever originally sent it rather than to the gateway.
Course and speed, altitude, power and antenna height, range and the precision
extension, all of which ride in the comment.  Every one has a writer beside
its reader, so a packet goes in and the same packet comes out.

Above that the section: a registry of who is out there and what each last
said of each kind, distances and bearings from --at, a log keeping the whole
frame under whatever was made of it, a spreadsheet, a map, and a channel full
of stations that are not there for --simulate.

One rule is worth naming because it is the difference between a decoder and a
liar.  A packet whose format does not match what its first character promised
comes back as unparsed with its text intact.  Thirteen characters of a
*malformed* uncompressed position are perfectly good base-91, so trying one
format and falling back to the other does not fail on a bad packet -- it
succeeds, as a confident and completely different place, usually a thousand
miles away.  The specification makes the two unambiguous, a leading digit
always meaning uncompressed, and the rule is read rather than guessed at.

Two faults found by building it, both by measurement rather than by reading
the code again.  The framer handed back frames it had already reported,
because it trimmed its buffer to before them rather than after -- every packet
counted twice, for ever, which only shows up once the same signal is read
across more than one block.  And the invented channel truncated a
transmission at the end of the block it began in rather than carrying the
remainder over, which was invisible for as long as the simulated clock
advanced in exact seconds and put every transmission at a block boundary; the
moment it was paced against a real clock, nothing decoded at all.

204 new tests against seven deliberately broken builds, one of which survived
until a test was written for the case it actually breaks.  Full suite 2597
passed.  Built as 2026-09-20_02.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-20 19:36:30 -07:00
parent 0f7e47e55e
commit 2b653c2c3e
16 changed files with 5543 additions and 17 deletions

225
README.md
View file

@ -7,10 +7,11 @@ built-in US band plan — and it sweeps them, stops on anything above the noise
floor, records it, and works out what kind of signal it was. CW/Morse is
decoded to text.
Two things get sections of their own, because neither fits through a scan:
[the aircraft overhead](#aircraft) on 1090 MHz, drawn on a moving map, and
Three things get sections of their own, because none of them fits through a
scan: [the aircraft overhead](#aircraft) on 1090 MHz, drawn on a moving map;
[the weather sensors](#weather-sensors-on-433-mhz) on 433 MHz, which you can
give names to as they arrive.
give names to as they arrive; and [APRS](#aprs-on-144-mhz) on 144 MHz, where
amateur stations report where they are and talk to each other.
Two programs: `bandsaunter` scans, and
[`saunterbrowse`](#browsing-what-you-recorded) reads back what it collected —
@ -247,6 +248,7 @@ bandsaunter scan -r 144M-148M -r 420M-450M # your own ranges
bandsaunter scan -b 2m --simulate # try it without hardware
bandsaunter adsb --window # aircraft overhead, on a map
bandsaunter weather # the weather sensors on 433 MHz
bandsaunter aprs # amateur packet on 144.39 MHz
```
## Two ways to drive it
@ -262,15 +264,19 @@ table of settings, so neither can offer something the other cannot.
4 Saved settings and profiles
5 Aircraft (ADS-B) listen on 1090 MHz, draw where they went
6 Weather sensors listen on 433 MHz, name what is out there
7 APRS (144 MHz packet) positions, weather and messages from amateurs
h Help
s Start scanning
q Quit
```
Items 5 and 6 are sections of their own, with their own options and their own
menus, because neither fits through a scan: ADS-B is a megabit a second and a
scan channel is 12.5 kHz wide, and a weather sensor message is a burst of a
carrier switched on and off that a scan would record as clicks.
Items 5, 6 and 7 are sections of their own, with their own options and their
own menus, because none of them fits through a scan: ADS-B is a megabit a
second and a scan channel is 12.5 kHz wide; a weather sensor message is a burst
of a carrier switched on and off that a scan would record as clicks; and APRS
is a two-second transmission every few minutes from a hundred stations sharing
one frequency, of which a sweep would catch whichever happened to key up as it
passed.
Settings are grouped, show their current value against the built-in default,
and carry their own help:
@ -2250,6 +2256,211 @@ decoder problem apart: a capture that yields nothing on replay yields nothing
for anybody, and a capture that yields readings on replay but not on the air
is a setting.
## APRS on 144 MHz
One channel, one frequency, everybody. 144.390 MHz across North America and a
different number in every other region, carrying position reports, weather,
messages, objects and telemetry from every amateur station within earshot —
and from every hilltop digipeater repeating them onward, which is most of what
you will actually hear.
```bash
bandsaunter aprs listen, and show who is out there
bandsaunter aprs --region europe ...on 144.800 instead
bandsaunter aprs --simulate a channel full of stations that are not there
bandsaunter packets --kml turn a log into something Google Earth opens
```
It is a section of its own for the same reason the other two are: a scan stops
on a signal, records it and moves on, and APRS is a two-second transmission
every few minutes from a hundred stations sharing one frequency. A sweep
catches whichever one happened to key up while the sweep was pointed there.
Unlike the others this is a conversation rather than a broadcast. Stations
address each other, acknowledge each other and relay for each other, so what
is worth showing is not only who is out there but what was said. And nothing
here has to be named: a weather sensor broadcasts a number out of a hat, but
an APRS station broadcasts a callsign issued by a government, which is already
the name.
```
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ 144.39 MHz 6 stations 12 seen 148 packets 9 messages 41:02 aprs_2026-09-20.jsonl │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
station what said away signal pkts ago
KU0W-9 car 47.5570N 122.2783W ENE 54 km/h mobile 2 km ENE 38 dB 41 0:08
KB1XYZ weather station 47.5900N 122.2700W temperature 15.0 C 5 km NNE 34 dB 12 0:22
humidity 62% pressure 1013.2 hPa
W1AW-1 digipeater 47.6062N 122.3322W Seattle wide digi 7 km NNW 37 dB 4 1:14
EVENT1 fire 47.5900N 122.3300W 5 km NNW 36 dB 1 3:40
```
### Which channel
The frequency is agreed between amateurs rather than allocated, so it differs
by region and **there is no way to discover it from the air**: on the wrong one
you hear silence, not a bad signal. `--region` covers the common ones —
`north-america` (144.390), `europe` (144.800), `australia` (145.175), `japan`,
`brazil`, `thailand` — and `--frequency` takes a number for anything else.
### What it reads
The information field of an APRS packet is not one format. It is about twenty,
chosen by the first character, accreted over thirty years, and several of them
exist only because a particular radio shipped with them.
| what | how it arrives |
|---|---|
| Position | uncompressed, or compressed into thirteen characters of base-91 |
| Mic-E | every Kenwood and Yaesu mobile, with half the position hidden in the destination callsign |
| Weather | attached to a position, or positionless |
| Messages | to a station, acknowledged, rejected, or broadcast as a bulletin |
| Objects and items | something placed on the map that is not the station placing it |
| Status | what the operator typed |
| Telemetry | five analogue channels and eight bits |
| Third-party traffic | a packet relayed in from another network, credited to whoever originally sent it |
Riding in the comment: course and speed, altitude, transmitter power and
antenna height, pre-computed range, direction-finding reports, and the
precision extension.
**Mic-E deserves a note**, because it is a quarter of everything on the channel
and it is the least readable thing in amateur radio. In 1995 the destination
address of an APRS frame carried nothing but the word "APRS", and somebody
noticed that six bytes is exactly enough for a latitude. So a Mic-E packet puts
the latitude, the north/south bit, the east/west bit, a hundred degrees of
longitude and a three-bit status message into *the callsign it is addressed
to*, and the rest into the information field as characters chosen so the whole
thing survives being typed into a logbook. It is also the reason APRS fits in a
two-second transmission.
### Refusing to guess
A packet whose format does not match what its first character promised comes
back as unparsed, with its text kept, rather than as a position.
That sounds like a nicety and is not. Thirteen characters of a *malformed*
uncompressed position are perfectly good base-91, so a decoder that tries one
format and falls back to the other does not fail on a bad packet — it succeeds,
as a confident and completely different place, usually a thousand miles away.
The specification makes the two unambiguous (a leading digit always means
uncompressed, which is why the compressed format writes a numeric overlay as a
letter), so the rule is read rather than guessed at.
`--unparsed` is on by default: a packet that defeats every parser here still
arrived, still came from a real station, and showing it is how the next format
gets added.
### Off the air
Four things happen between the aerial and a frame.
**The tones become a soft symbol.** APRS is Bell 202 — 1200 Hz for a mark,
2200 Hz for a space, twelve hundred a second, inside an ordinary FM
transmission. Two correlators, one at each tone, and the difference between
them. A correlator rather than a frequency discriminator because the tones are
less than an octave apart and radio audio is distorted enough that
instantaneous frequency wanders badly.
De-emphasis is turned off, which matters and is easy to miss: voice FM lifts
the high frequencies on transmit and drops them again on receive, and packet
radio takes its audio from the discriminator *before* that happens. Dropping
the highs would take four decibels off the 2200 Hz tone and leave the 1200 Hz
one alone, which is exactly the difference being measured.
**The soft symbol becomes bits**, sampled once a bit at an instant held in the
middle of the bit by a loop nudged at every zero crossing. The loop carries its
phase from one block of audio to the next, which is what makes this a receiver
rather than a decoder of recordings — a packet is most of a second and a block
is about one, so frames straddling the boundary are not an edge case, they are
most of them.
**The bits become a frame.** NRZI first, where a zero is a change of tone and a
one is no change — which makes the whole thing immune to being wired up
backwards, since inverting the stream decodes to identical data. Then HDLC:
frames delimited by 01111110, with a zero stuffed after every five ones so the
flag cannot occur inside one.
**The frame is believed or it is not.** Sixteen bits of CRC, and nothing
without a correct one is reported. That is what makes it safe to leave running
for hours with the squelch open: a frame either checks out or it never existed.
A frame whose callsigns are unprintable is refused as well — on a band this
busy, sixteen bits is strong but not infinite.
It decodes down to about 8 dB of signal-to-noise and finds nothing at all in
pure noise.
### Afterwards
Three tables, because they answer different questions. **Stations heard** is
about the band and the aerial: where each was, how far off, how many packets,
how many of those arrived *directly* rather than through a digipeater, and how
strongly. **What they said** is the weather, the speeds and the status lines.
**What passed between them** is the messages, in order — the only part of APRS
that is a conversation.
`--at LAT,LON` turns on the distance and bearing columns. `--direct-only`
leaves out anything that reached you through a digipeater, which is a much
shorter list and is the honest measure of what your aerial can actually reach.
`--csv` writes a row per packet with position, speed and weather in their own
columns. `--kml` writes a pin where each station was last heard and a line for
anything that moved. Both can be made later from a log with `bandsaunter
packets --csv --kml`, and `--station CALL` narrows either to one callsign.
The log keeps the whole AX.25 frame in hexadecimal under whatever was made of
it. That is not a hypothetical precaution: the list of APRS formats is still
growing, so a packet this version cannot read is on the disk in full for a
version that can.
### Every APRS option
| option | flag | default | what it does |
|---|---|---|---|
| Receiver | `--device` | 0 | which receiver, when more than one is plugged in |
| Gain | `--gain` | auto | tuner gain in dB, or automatic |
| Sample rate | `--rate` | 240 kS/s | 96 kS/s is the least that holds the channel |
| Region | `--region` | north-america | which APRS channel to listen on |
| Listen on | `--frequency`, `--freq` | 144.390 MHz | the exact frequency, if the region's is not what you want |
| Invent a channel | `--simulate` / `--no-simulate` | no | stations that are not there |
| Listen for | `--seconds` | until stopped | how long before stopping |
| Write a log | `--log` / `--no-log` | yes | one line of JSON per packet |
| Print every packet | `--packets` / `--no-packets` | no | a stream of lines instead of a table |
| Keep on screen for | `--hold` | 3600 s | how long a station stays after its last packet |
| Receiver at | `--at` | blank | where the aerial is, for distances |
| Show readings in | `--units` | metric | metric or imperial, for the display and the export |
| Show what cannot be read | `--unparsed` / `--no-unparsed` | yes | list packets in unknown formats |
| Include relayed packets | `--digipeated` / `--direct-only` | yes | count what reached you through a digipeater |
| Report at the end | `--report` / `--no-report` | yes | print what was heard |
| Also write a spreadsheet | `--csv` / `--no-csv` | no | CSV beside the log |
| Also write a map | `--kml` / `--no-kml` | no | KML beside the log |
Saved in `aprs.yaml` beside the other settings, from the menu's **s** or by
hand.
### Without an aerial
`--simulate` puts a dozen stations on a channel that does not exist — cars
moving, a weather station, a digipeater, objects, messages passing between
them — keyed as real Bell 202 audio on a real FM carrier, through the real
demodulator and the real parsers. Nothing touches the receiver.
### If nothing is heard
**Check the region first.** It is the one fault that looks like a dead aerial
and is not: on the wrong channel there is silence rather than a bad signal.
Then give it time. A fixed station beacons every twenty or thirty minutes and a
mobile every minute or two, so five minutes of an ordinary suburb might be
three packets and an hour is a fair picture. If you are somewhere without a
digipeater in range you may genuinely hear nothing — APRS coverage is built
from volunteers' hilltops, and it has holes.
A quarter-wave whip for 144 MHz is 49 cm, which is longer than the aerial most
dongles ship with; the stock telescopic one extended properly does well.
`--packets` shows each frame as it arrives, which is the thing to watch while
moving an aerial about.
## Meters on 900 MHz
A scan of 902–928 MHz that turns up a burst gets it named rather than reported