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

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-20" "bandsaunter 2026-09-20_01" "User Commands"
.TH BANDSAUNTER 1 "2026-09-20" "bandsaunter 2026-09-20_02" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -99,6 +99,17 @@ below.
.B sensors
List every weather sensor heard, and give them names.
.TP
.B aprs
Listen to the APRS channel on 144 MHz: positions, weather, messages, objects
and telemetry from amateur stations. See
.B APRS
below.
.TP
.B packets
Read an APRS log back: the report, a spreadsheet and a map. See
.B APRS
below.
.TP
.B analyze
Identify a signal in an already-recorded file, decode Morse from it, or write
out the picture it turns out to be.
@ -2566,6 +2577,231 @@ Also write a spreadsheet \[em] write the readings as CSV beside the log.
.br
Setting name \fBcsv\fR, default \fBno\fR.
.PP
.SH APRS
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 \[em] which is most of what
will actually be heard.
.PP
A mode of its own for the same reason the other two are: a scan stops on a
signal, records it and moves on, and this 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 it was pointed there.
.PP
Unlike the others it is a conversation rather than a broadcast, so what is
shown is not only who is out there but what was said. And nothing here needs
naming: a station broadcasts a callsign issued by a government, which is
already the name.
.SS 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
there is silence, not a bad signal.
.B \-\-region
covers north-america (144.390), europe (144.800), australia (145.175), japan,
brazil and thailand, and
.B \-\-frequency
takes a number for anything else.
.SS What it reads
Positions, uncompressed and compressed into thirteen characters of base-91;
Mic-E, which every Kenwood and Yaesu mobile sends; weather, attached to a
position or without one; messages, acknowledgements, rejections and bulletins;
objects and items; status reports; telemetry; and third-party traffic 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.
.PP
Mic-E deserves a note, being a quarter of everything on the channel and 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 \[em] 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.
It is also why APRS fits in a two-second transmission.
.SS 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. 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 \[em] 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, so the rule is read rather than
guessed at.
.SS Getting it off the air
APRS is Bell 202: 1200 Hz for a mark and 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 \[em] a correlator rather than a frequency
discriminator, the tones being less than an octave apart and radio audio
distorted enough that instantaneous frequency wanders.
.PP
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.
.PP
The soft symbol is sampled once a bit, at an instant held in the middle of the
bit by a loop nudged at every zero crossing, and that loop carries its phase
from one block of audio to the next \[em] a packet is most of a second and a
block is about one, so frames straddling the boundary are most of them. Then
NRZI, 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; then HDLC framing with its bit
stuffing; then sixteen bits of CRC, and nothing without a correct one is
reported. That last is what makes it safe to leave running for hours with the
squelch open.
.SS Afterwards
Three tables. 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, which is the only part of APRS that is a conversation.
.PP
.BI \-\-at " LAT,LON"
turns on the distance and bearing columns.
.B \-\-direct\-only
leaves out anything relayed, which is a much shorter list and the honest
measure of what an aerial can reach.
.B \-\-csv
writes a row per packet and
.B \-\-kml
a pin per station with a line for anything that moved; both can be made later
from a log with
.BR "bandsaunter packets" ,
which also takes
.BI \-\-station " CALL"
to narrow either to one callsign.
.PP
The log keeps the whole AX.25 frame in hexadecimal under whatever was made of
it, because the list of APRS formats is still growing and a packet this
version cannot read should be on the disk in full for a version that can.
.SS If nothing is heard
Check the region first: it is the one fault that looks like a dead aerial and
is not. Then give it time \[em] 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. A quarter-wave whip for 144 MHz is 49 cm, which
is longer than the aerial most dongles ship with.
.B \-\-packets
shows each frame as it arrives, which is what to watch while moving an aerial
about.
.SH APRS OPTIONS
Every option the APRS side takes, in the four groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree.
.SS Receiver
.TP
.B --device
Receiver \[em] which receiver to use, when more than one is plugged in.
.br
Setting name \fBdevice\fR, default \fB0\fR.
.br
Accepts: at least 0.
.TP
.B --gain
Gain \[em] tuner gain in dB, or automatic.
.br
Setting name \fBgain\fR, default \fBauto\fR.
.TP
.B --rate
Sample rate \[em] how fast to sample; 96 kS/s is the least that holds the channel (Hz).
.br
Setting name \fBrate\fR, default \fB240 kHz\fR.
.br
Accepts: at least 96000.
.TP
.B --region
Region \[em] which APRS channel to listen on.
.br
Setting name \fBregion\fR, default \fBnorth-america\fR.
.br
Accepts: one of: north-america, europe, australia, japan, brazil, thailand.
.TP
.B --frequency --freq
Listen on \[em] the exact frequency, if the region's channel is not what you want (Hz).
.br
Setting name \fBfrequency\fR, default \fB144.39 MHz\fR.
.br
Accepts: at least 1e+06, at most 2e+09.
.TP
.B --simulate / --no-simulate
Invent a channel \[em] put imaginary stations on an imaginary band.
.br
Setting name \fBsimulate\fR, default \fBno\fR.
.RS
.PP
Turn this on to see what the whole thing does without an aerial. Turn it off to hear real stations.
.RE
.PP
.SS Listening
.TP
.B --seconds
Listen for \[em] how long to listen before stopping (0 = until interrupted) (s).
.br
Setting name \fBseconds\fR, default \fBuntil stopped\fR.
.br
Accepts: at least 0.
.TP
.B --log / --no-log
Write a log \[em] write every packet down as it arrives.
.br
Setting name \fBlog\fR, default \fByes\fR.
.TP
.B --packets / --no-packets
Print every packet \[em] one line per packet instead of a table that updates in place.
.br
Setting name \fBpackets_seen\fR, default \fBno\fR.
.TP
.B --hold
Keep on screen for \[em] how long a station stays on the display after its last packet (s).
.br
Setting name \fBhold\fR, default \fB3600 s\fR.
.br
Accepts: at least 1.
.TP
.B --at
Receiver at \[em] where the aerial is, as latitude,longitude (blank = no distances).
.br
Setting name \fBlocation\fR, default \fBblank\fR.
.PP
.SS Showing
.TP
.B --units
Show readings in \[em] metric or imperial, for the display and the export.
.br
Setting name \fBunits\fR, default \fBmetric\fR.
.br
Accepts: one of: metric, imperial.
.TP
.B --unparsed / --no-unparsed
Show what cannot be read \[em] list packets whose format this does not understand.
.br
Setting name \fBunparsed\fR, default \fByes\fR.
.TP
.B --digipeated / --direct-only
Include relayed packets \[em] count packets that reached here through a digipeater.
.br
Setting name \fBdigipeated\fR, default \fByes\fR.
.RS
.PP
Leave it on for a picture of the network; turn it off to find out what you can actually hear.
.RE
.PP
.SS Afterwards
.TP
.B --report / --no-report
Report at the end \[em] print what each station said when the listening stops.
.br
Setting name \fBreport\fR, default \fByes\fR.
.TP
.B --csv / --no-csv
Also write a spreadsheet \[em] write the packets as CSV beside the log.
.br
Setting name \fBcsv\fR, default \fBno\fR.
.TP
.B --kml / --no-kml
Also write a map \[em] write the stations and their tracks for Google Earth.
.br
Setting name \fBkml\fR, default \fBno\fR.
.PP
.SH FILES
.TP
.I ~/.config/bandsaunter/config.yaml
@ -2581,6 +2817,9 @@ The weather options, as saved from the menus.
What each weather sensor is called. The only file here holding anything a
person typed; safe to edit by hand.
.TP
.I ~/.config/bandsaunter/aprs.yaml
The APRS options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
@ -2594,6 +2833,13 @@ Every weather sensor message heard in one listening session, with a
.I .csv
of the readings beside it where one was asked for.
.TP
.IR aprs_ * .jsonl
Every APRS packet heard in one listening session, with a
.I .csv
and a
.I .kml
beside it where they were asked for.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output

View file

@ -70,6 +70,13 @@ def weather_section() -> list[str]:
return options_section(wx)
def aprs_section() -> list[str]:
"""Every APRS option, from the same table again."""
from bandsaunter import aprs as ap
return options_section(ap)
def options_section(air) -> list[str]:
"""One section's options, written out from the table the program uses.
@ -208,6 +215,17 @@ below.
.B sensors
List every weather sensor heard, and give them names.
.TP
.B aprs
Listen to the APRS channel on 144 MHz: positions, weather, messages, objects
and telemetry from amateur stations. See
.B APRS
below.
.TP
.B packets
Read an APRS log back: the report, a spreadsheet and a map. See
.B APRS
below.
.TP
.B analyze
Identify a signal in an already-recorded file, decode Morse from it, or write
out the picture it turns out to be.
@ -1557,6 +1575,117 @@ Every option the weather side takes, in the four groups the menu shows them
in. Each is a flag here and a line in the menu, and both come from one table
in the program, so they cannot disagree.
.WEATHER_OPTIONS_HERE
.SH APRS
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 \[em] which is most of what
will actually be heard.
.PP
A mode of its own for the same reason the other two are: a scan stops on a
signal, records it and moves on, and this 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 it was pointed there.
.PP
Unlike the others it is a conversation rather than a broadcast, so what is
shown is not only who is out there but what was said. And nothing here needs
naming: a station broadcasts a callsign issued by a government, which is
already the name.
.SS 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
there is silence, not a bad signal.
.B \-\-region
covers north-america (144.390), europe (144.800), australia (145.175), japan,
brazil and thailand, and
.B \-\-frequency
takes a number for anything else.
.SS What it reads
Positions, uncompressed and compressed into thirteen characters of base-91;
Mic-E, which every Kenwood and Yaesu mobile sends; weather, attached to a
position or without one; messages, acknowledgements, rejections and bulletins;
objects and items; status reports; telemetry; and third-party traffic 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.
.PP
Mic-E deserves a note, being a quarter of everything on the channel and 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 \[em] 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.
It is also why APRS fits in a two-second transmission.
.SS 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. 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 \[em] 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, so the rule is read rather than
guessed at.
.SS Getting it off the air
APRS is Bell 202: 1200 Hz for a mark and 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 \[em] a correlator rather than a frequency
discriminator, the tones being less than an octave apart and radio audio
distorted enough that instantaneous frequency wanders.
.PP
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.
.PP
The soft symbol is sampled once a bit, at an instant held in the middle of the
bit by a loop nudged at every zero crossing, and that loop carries its phase
from one block of audio to the next \[em] a packet is most of a second and a
block is about one, so frames straddling the boundary are most of them. Then
NRZI, 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; then HDLC framing with its bit
stuffing; then sixteen bits of CRC, and nothing without a correct one is
reported. That last is what makes it safe to leave running for hours with the
squelch open.
.SS Afterwards
Three tables. 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, which is the only part of APRS that is a conversation.
.PP
.BI \-\-at " LAT,LON"
turns on the distance and bearing columns.
.B \-\-direct\-only
leaves out anything relayed, which is a much shorter list and the honest
measure of what an aerial can reach.
.B \-\-csv
writes a row per packet and
.B \-\-kml
a pin per station with a line for anything that moved; both can be made later
from a log with
.BR "bandsaunter packets" ,
which also takes
.BI \-\-station " CALL"
to narrow either to one callsign.
.PP
The log keeps the whole AX.25 frame in hexadecimal under whatever was made of
it, because the list of APRS formats is still growing and a packet this
version cannot read should be on the disk in full for a version that can.
.SS If nothing is heard
Check the region first: it is the one fault that looks like a dead aerial and
is not. Then give it time \[em] 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. A quarter-wave whip for 144 MHz is 49 cm, which
is longer than the aerial most dongles ship with.
.B \-\-packets
shows each frame as it arrives, which is what to watch while moving an aerial
about.
.SH APRS OPTIONS
Every option the APRS side takes, in the four groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree.
.APRS_OPTIONS_HERE
.SH FILES
.TP
.I ~/.config/bandsaunter/config.yaml
@ -1572,6 +1701,9 @@ The weather options, as saved from the menus.
What each weather sensor is called. The only file here holding anything a
person typed; safe to edit by hand.
.TP
.I ~/.config/bandsaunter/aprs.yaml
The APRS options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
@ -1585,6 +1717,13 @@ Every weather sensor message heard in one listening session, with a
.I .csv
of the readings beside it where one was asked for.
.TP
.IR aprs_ * .jsonl
Every APRS packet heard in one listening session, with a
.I .csv
and a
.I .kml
beside it where they were asked for.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output
@ -1698,6 +1837,7 @@ def main() -> int:
"\n".join(aircraft_section()))
text = text.replace(".WEATHER_OPTIONS_HERE",
"\n".join(weather_section()))
text = text.replace(".APRS_OPTIONS_HERE", "\n".join(aprs_section()))
text = text.replace("\n\n", "\n") # troff dislikes blank lines
target = Path(sys.argv[1] if len(sys.argv) > 1
else Path(__file__).parent / "bandsaunter.1")