Commit graph

34 commits

Author SHA1 Message Date
The Dust Council
2b653c2c3e 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
2026-09-20 19:36:30 -07:00
The Dust Council
0f7e47e55e Show a sensor's identity in both bases, and take either
Reported as bandsaunter using "completely different" identities from a script
that had been watching the same sensors for years.  They are the same
identities.  Their list against this program's:

    14645 = 3935      8677 = 21E5      14717 = 397D
    3542  = 0DD6      10011 = 271B

Five of their eight, matching exactly; the other three simply were not
transmitting during the three seconds of capture I had.  One base is
hexadecimal and the other decimal, and nothing anywhere said so.

The hexadecimal is not arbitrary -- the identity is a bit field with the
channel packed into the top two bits, and that shape is visible in hex and
invisible in decimal, which is why the decoder carries it that way.  But
rtl_433 and everything built on it prints these in decimal, so anybody who
comes to this with their own sensors already written down has the other form,
and being handed a list that looks unrelated to theirs is a poor welcome.

So both are shown wherever a person reads: the live display when there is room
for the column, the report, the sensor list and the spreadsheet.  `bandsaunter
sensors` is the table for correlating two lists and now has them side by side,
with a line saying which is which and why.

Either may be typed at --name.  One case needs care rather than cleverness:
"3935" is a valid identity in both bases and they are different sensors, so
when both are out there it says the identity is ambiguous and asks for the
whole key instead of picking whichever the code reaches first.

A first attempt at the lookup converted a decimal identity to hexadecimal and
matched on that as well as comparing decimals directly.  Both worked, so
neither could be tested apart from the other; the conversion was removed
rather than given a test written backwards from it.

Full suite 2391 passed, checked against five deliberately broken builds.
Built as 2026-09-20_01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-20 14:47:08 -07:00
The Dust Council
03d3600ecc Say how strongly each sensor is being heard, and how much of it arrives
Two numbers rather than one, because "how well is this sensor coming in" is
two questions and they can disagree in a way that is worth seeing.

The first is strength: how far the sensor's burst stood above the noise of the
second it arrived in, in decibels, on every reading and in the log and the
spreadsheet.  The burst detector already worked this out and threw it away --
it is the ratio the per-burst threshold is set from -- so this is carrying a
number through rather than measuring a new one.

What it is not is a power at the aerial, and the docstring says so where
somebody will read it.  A dongle has no reference level and, with the tuner
left on automatic, no fixed gain either; anything in dBm would be invention.
A ratio of two amplitudes off the same receiver in the same second is the
honest quantity, and it is enough for the three things anybody wants a signal
reading for: comparing two sensors now, watching one over an evening, and
pointing an aerial.  A fixed --gain makes it comparable between runs as well,
which the help now says.

It is coloured red, amber or green, it is on the live display as well as the
report, and it is kept on a narrow terminal when other columns are dropped --
because somebody moving a whip about while a number climbs is not doing it on
a wide window, and that is the most useful thing this does.

The second is the share of what a sensor sent that actually arrives, which
comes out of the timing for nothing.  These transmit on a fixed cycle, so the
shortest wait ever seen between two of a sensor's messages is that cycle, and
the average wait is the cycle divided by the fraction getting through: one
over the other is the fraction, with no need to know the model or how often it
is meant to speak.

Read together they say more than either does alone.  A strong signal with a
low share is interference or a collision rather than distance.  A weak signal
at a hundred per cent is a sensor at the edge that is getting through anyway
and is best left alone.

The strongest of the three copies of a message is the one reported, not the
first: they go out milliseconds apart and arrive at whatever the fading does
to each.  A reading with no strength -- an older log, a block with no
measurable noise floor to be a ratio to -- leaves the last known figure alone
rather than overwriting it with a zero.

Full suite 2369 passed, checked against five deliberately broken builds
including the one that reports decibels as a power ratio, which is off by a
factor of two and looks entirely reasonable.  Built as 2026-09-07_06.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-07 23:50:37 -07:00
The Dust Council
872eadac37 Read the parity the way the sensors write it
Their sensors are on the air, five of them, and every message was arriving
intact.  The bytes they sent, recovered from their own capture:

    A7 1B 44 A6 09 4B 00     channel B  id 271B   22.7 C  38%
    F9 35 44 2B 09 C9 6F     channel A  id 3935   22.5 C  43%
    21 E5 84 1E 0A 9F 51     channel C  id 21E5   31.1 C  30%  battery low
    F9 7D 44 28 09 50 3B     channel A  id 397D   23.2 C  40%
    0D D6 44 A9 09 CF A8     channel C  id 0DD6   23.1 C  41%

Every checksum correct, every message type 0x04, every reading plausible.  The
framing was right, the bit offset was right, the byte order was right, the
pulses had been recovered perfectly for days.  One bit of convention was
wrong: the parity in the top bit of each payload byte is even, and this
required it to be odd.  Twenty payload bytes across five independent messages,
every one of them even, which is not something twenty bytes do by chance.

That is the whole fault.  Everything else changed in this and the two commits
before it was real and worth doing, and none of it was why nothing decoded.

Three things follow.

The five messages are now a test, checked byte for byte against the weather
they carry.  They are worth more than everything else in that file put
together: every other test there puts a reading in through an encoder written
from the same description as the decoder, so the two agree by construction and
agree about anything they are both wrong about -- which is exactly what
happened.  An encoder tested against its own decoder cannot find a fault in
the description they share, and no amount of it would ever have found this.

The emptiness check earns its place now.  Odd parity rejects a byte of all
zeroes; even parity accepts one, so a run of silence read as zeroes satisfies
both the parity and a sum of zero, and the only thing standing between that
and a display full of sensors is the test that some byte is non-zero.  It was
there for tidiness and is now load-bearing; the comment says so.

And the readings of a burst are tried in order and the search stops at the
first that yields anything, rather than pooling them.  Half a dozen readings
at two byte orders is sixteen times the chances for a coincidence to satisfy a
twelve-bit check, and sensors that were not there began appearing in the
invented garden the moment the alternatives went in -- caught by the test that
asks whether everything heard is something that exists.  Stopping early costs
nothing: a burst that reads correctly the ordinary way never reaches the
alternatives, and one that does not reaches them exactly as before.

Full suite 2351 passed, checked against three more deliberately broken builds.
Sixty seconds of receiver noise yields nothing and eight hundred seconds of
the invented garden yields no sensor that is not there.  Built as
2026-09-07_05.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-07 23:03:16 -07:00
The Dust Council
dfea5cb2f2 Say which check failed, and read a byte from either end
Their diagnosis came back and the radio end is faultless.  Every burst is
textbook: four sync pulses at 612 microseconds, then 216 and 403 microsecond
data pulses with gaps that complete each bit period, six of them a second,
sliced cleanly at the right lengths.  The pulses this had been failing to read
were being recovered perfectly all along and the fault is in the arithmetic
after them.

Two things follow from that, and neither is a guess about their sensors.

The first is that "nothing framed" is not a diagnosis, it is the absence of
one, and it was all this could say.  A message whose checksum holds and whose
parity fails is a different fault from one where neither holds, and both
differ again from a burst that never lined up on a byte boundary -- three
faults, three fixes, one message.  So the diagnosis now reports the closest
framing it found, which of its checks held, and the bytes themselves in
hexadecimal, which is what any question about a format is actually about and
saves asking somebody to read numbers off a screen.

The second is that this still assumed something it had no business assuming:
which end of a byte goes down the air first.  Both orders are tried now and
the checksums say which, like everything else here.  That one has a
fingerprint worth knowing and worth having said in the manual: reversing the
bits of a byte does not change how many of them are set, so odd parity
survives it and a checksum does not -- a message read from the wrong end shows
every parity holding and every sum failing, on every copy, which is a
signature rather than a coincidence.

Also fixed, and found by building their burst from the timings they sent: a
real transmitter closes the last bit with a terminating pulse, so a message of
fifty-six bits arrives as sixty-one pulses rather than sixty.  Nothing here
had ever seen one, the simulator not sending it, and every test in this file
was therefore one pulse short of what comes off the air.  It happens to be
handled correctly, which is luck rather than design, so it is now what the
tests are written against.

Full suite 2339 passed.  Sixty seconds of receiver noise still yields nothing,
and four hundred seconds of the invented garden still yields no sensor that is
not there, both rechecked after adding the second byte order.  Built as
2026-09-07_04.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-07 21:36:35 -07:00
The Dust Council
706c632f47 Listen the way the tools that work on this band listen
Still nothing, with rtl-433 receiving the same sensors on the same aerial from
a different receiver.  That settles where the fault is not: not the aerial,
not the sensors, not the band.  So the sensible thing is to stop differing
from the configuration known to work on that aerial, and this differed from it
in three ways, every one of them mine.

It tuned a quarter of a megahertz to one side of 433.92 and shifted the signal
back in software, to keep the receiver's own spike off a signal that works by
being switched off.  That is a real effect and avoiding it this way is a bad
trade: at the sample rate this ran at, the shift is followed by a filter, and
a filter narrow enough to reject the spike is narrow enough to lose a
transmitter that has drifted -- or to lose the signal outright if a dongle
presents its samples the other way round, which is not a thing to depend on.
The spike is a steady addition to the envelope and a burst rises clear of it.
Tuning straight at the sensors now, which is what the established tools do.

It sampled at a megasample a second where a quarter of one is plenty: the
shortest pulse these send is two hundred microseconds, which is fifty samples
at the lowest rate a dongle will do.  The extra rate bought nothing but the
room for that filter to exist in.  At 250 kS/s nothing after the mixer is
narrower than the band, so the offset now does nothing at all whatever it is
set to, and says so.

And it turned on the RTL2832's digital gain control along with the tuner's.
The two pump: the gain winds up through the silence between one burst and the
next, lifting the noise towards the signal and squeezing the very difference
the burst detector works on.  It matters here in a way it does not for
aircraft, where a frame is found by correlating a preamble over microseconds
rather than by comparing a burst with the quiet around it.

Three more faults found while going over the rest of it, all the same mistake
in different clothes -- treating the middle of a distribution as though it
were the quiet part of one.

The check that skips an empty block measured the peak against the median.  A
recording that is mostly burst measures its own burst against its own burst,
finds no difference and is discarded as silence, which is what happened to
every short capture.  The gate's scatter had the same trouble one level down
and could come out above the peak, which is the one setting that cannot be
right, so it is now capped below it.

And the rule deciding where one message ends keyed on the middle gap in a
burst.  Where a one is drawn as a gap three times a zero and most of the bits
are zeroes, the middle gap is the short one, twice it still falls inside the
message, and every one-bit ended a burst -- the message coming apart into
pieces of three pulses.  It keys on the widest gap now, which is a fact about
the message rather than about the data it happened to carry.

The pieces of a message are also put back together after being sliced rather
than before.  Grouping has to be tight, because the group is what fixes the
threshold and a group holding two sensors of unequal strength fixes it on the
louder; but the gaps inside one message run from two hundred microseconds on
the newer sensors to four thousand on the oldest, and a grouping tight enough
for the first tears the second into a bit at a time.  So each piece is
measured at its own amplitude and joined to its neighbours afterwards, and
anything that comes out longer than the longest message there is gets cut at
its largest gaps.

Measured rather than argued: across four hundred and eighty combinations of
pulse and gap timing, 464 now read where 417 did; across twenty-one gap-keyed
combinations, 18 where 12 did; and eighty seconds of receiver noise still
yields nothing at all.

--from-iq FILE reads a saved capture instead of the receiver, so a recording
made where the aerial is can be worked on anywhere, as many times as it takes.
Everything downstream of the dongle is the real thing, which is what tells a
receiver problem and a decoder problem apart.  --save-iq writes the settings
beside the samples, a file of raw samples with no record of its rate being
unreadable by anything.  The invented garden now waits like a dongle instead
of running as fast as the machine allows, which it should have done from the
start: --seconds meant nothing against it and a capture came out fifty times
too large.

Full suite 2331 passed; this work checked against sixteen deliberately broken
builds, two of which it survived until the tests were made to catch them, and
one change was removed for being unable to earn a test at all.  Built as
2026-09-07_03.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-07 21:01:00 -07:00
The Dust Council
335d83f8a0 Hear every sensor in the garden, not only the loudest one
Reported as reading nothing at all with several sensors in range.  Two faults,
either of which is enough on its own, and both of them things I assumed rather
than checked -- this was written against messages I generated myself and never
against a sensor.

The first is the threshold.  Bursts were found by setting one level per second
of band, halfway between the noise floor and the loudest thing in that second.
That is the obvious way to write it and it is wrong: a sensor on the windowsill
and a sensor at the end of the garden differ by forty decibels, so a level set
halfway to the near one sits above everything the far one ever does.  The far
ones do not come through weakly, they vanish -- and vanish only while the near
one is transmitting, which is as confusing a symptom as radio produces.  A
block with one loud sensor in it yielded exactly one sensor however many were
out there.

Finding bursts is now two passes.  The first asks only where anything happened
at all and asks it against the noise -- the bottom fifth of the second, which
is noise however busy the rest was, and which does not move when something
loud arrives.  Whatever clears that is grouped into regions, and the second
pass re-thresholds each region against its own high and low.  Every sensor is
sliced at its own amplitude.  Six sensors spanning eighty times in strength
now all come back from one second of band.

The second fault is that the slicer knew how a bit is drawn.  It read a pulse
by comparing it with the gap that followed, which is right when the gap is the
complement of the pulse so that every bit takes the same time, and wrong when
the gap is a fixed spacer: a two-hundred-and-twenty microsecond pulse against
a two-hundred microsecond spacer is the longer of the two and reads as a one,
which is the wrong bit, and then every message fails its checksum having said
nothing about why.  Nothing is assumed now -- not which of the pulse and the
gap carries the bit, not whether the gap is a complement or a spacer, not
which of long and short means one.  The same burst is read half a dozen ways
and the checksums say which reading it was, at most one being able to satisfy
one.  Copies are counted per message rather than per reading, or two readings
of one burst would corroborate each other and the rule protecting the two
thinly-checked models would protect nothing.

Both were caught the same way: by measuring, rather than by reading the code
again.  A thousand seconds of the invented garden still yields no sensor that
is not there, and reception of the ones that are is up by a quarter, because
bursts that used to be masked now decode.

And, because none of the above should have needed me: `bandsaunter weather
--diagnose` prints each second taken apart stage by stage -- the noise, the
level a burst must clear, the loudest thing in the block, then every burst
with the lengths of its pulses and gaps and whatever was made of them.  Those
lengths are the useful part: a real message has two or three of them and
nothing in between, which says at a glance whether the trouble is the radio or
the arithmetic.  At the end it says which of five things it was: nothing
arriving, nothing above the noise, something never keyed, bursts that framed
as nothing, or messages that framed and arrived only once.  `--save-iq FILE`
keeps the raw samples for whatever that cannot settle.

Full suite 2286 passed; the new work checked against six deliberately broken
builds.  Built as 2026-09-07_02.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-07 18:22:53 -07:00
The Dust Council
65cc03b78d Read the weather sensors on 433 MHz, and let them be given names
A consumer weather station is two things.  The display on the kitchen wall is
one of them; the other is a plastic box on a fence post that says what it can
see every sixteen seconds, in the clear, to anyone who happens to be
listening.  This reads the box.

A section of its own, like the aircraft one, and for the same reason: it does
not fit through the scanner.  A sensor message is a burst of a carrier
switched on and off, a fifth of a second long, and the scan path is a squelch
and a recorder -- it would record the bursts as clicks in a WAV file and
decode nothing.  `bandsaunter weather` listens, `bandsaunter readings` reads
a log back, `bandsaunter sensors` says what is out there.  Item 6 in the main
menu is the same thing without a command line.

Five families: the Tower 592TXR, the 5-in-1, the 6045M lightning detector,
the 609TXC and the 606TX.  Temperature, humidity, wind speed and direction,
rainfall, strike counts, how far off the storm is, and battery state from all
of them.  Every one is implemented from its published description and checked
against frames built from the same description, which proves the framing, the
parity, the checksums and the arithmetic and is not the same as having held
one of each.

The naming is the point.  A sensor broadcasts an identity, and that identity
is a number that came out of a hat in a factory; it tells one sensor from
another and is no use at all for telling which is which.  So press n while
listening: the display comes down, the sensors are listed, you name one, and
it goes back up, with the receiver running throughout.  That is the moment it
is possible -- the sensor is on the screen saying 3.1 degrees, and the person
watching is the one who knows that the cold one is the shed.  An hour later it
is a list of hexadecimal again.  Names are written the instant they are given
rather than at exit, to a neighbouring file renamed over the old one, and one
given before a sensor has ever been heard waits under its identity and moves
across when the first message says which model it is.

Four things keep the neighbours' doorbells off the display.  The checks the
message carries; a second copy, for the two models that carry only one byte
of check between them; a plausibility range, because a checksum can be
satisfied by a message the hardware could not send; and where in the burst
the message sits.  That last one is the one that is easy to miss: a seven-byte
message read out of the front of a real eight-byte one is made of that
message's own payload bytes, whose parity is already correct, so the parity
bits contribute nothing and one byte of sum is all that is left -- and
corroboration cannot help, the three copies being identical.  What gives that
window away every time is that it ends a whole byte before the burst does.

The Atlas is nine bytes like the lightning detector and lays its payload out
differently, so every decoder insists on a message type it knows.  Anything
else that frames correctly is reported with its identity and no weather,
because wrong weather under somebody's sensor name is a worse answer than
none.

ism.py now delegates to this rather than keeping a second implementation of
the tower sensor, which fixes the channel letters -- A is 3, B is 2, C is 0,
and there is no D -- and the battery bit, which is set while the battery is
good.  The two thinly-checked models are not reported from a scan at all: a
scan hears one burst, and they need two.

The option menus are now handed the module that owns the options rather than
importing the aircraft one, so one set of screens drives both sections and
will drive a third.

169 new tests, checked against nineteen deliberately broken builds; two of the
tests were too weak to notice their own mutation and were rewritten.  Full
suite 2252 passed.  Built as 2026-09-07_01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-07 13:40:33 -07:00
The Dust Council
f01de4117f Make the aircraft breathe, and send a ring out from each of them
Two things that move on their own, on the window and in the animated
pictures alike, and both turned off with a flag.

An aircraft swells from bright to dim and back.  At the top of the swell it
burns: drawn in a set of peak colours and wearing a halo, which is what a
phosphor does when the beam sits in one place a little too long.  The halo
is the aeroplane's own shape spread outward a pixel at a time rather than a
circle drawn round it -- the first attempt did draw a circle and it looked
exactly like what it was, a ring of dots -- so the glow has the shape of the
thing casting it.  It goes only where the picture was still empty, over the
ground and the grid and the background and never over another aircraft,
since a halo is what light does to the dark around a thing.

And a ring leaves each aircraft and travels outward, growing and dimming as
it goes.  What a radar repeater does, and what the eye reads as this thing
is transmitting -- which is exactly what an aeroplane on this picture is
doing, twice a second, and is how it got on the picture at all.  One ring at
a time per aircraft: a new one leaves as the last reaches the end of its
reach, so the sky has one ring per aeroplane rather than a stack of them to
read through.

Five settings, in a group of their own because the others were already at
eight: whether to pulse, how long a swell takes, whether to echo, how long
between rings, and how far a ring gets.  Both times are seconds of watching
rather than of flying, so the rhythm looks the same whatever speed an
evening is being run through.

Two decisions worth naming.  Each aircraft is offset by its own address, so
a sky full of them swells and rings separately rather than beating as one,
which would read as a display flashing rather than as a lot of separate
things transmitting; the offset comes from the address, so an aeroplane
keeps its rhythm from one frame to the next and from one drawing of the same
log to the next.  And only an aircraft still being heard pulses: one that
has gone quiet is already fading, and a thing that is fading and beating at
once says two contradictory things about itself.

The window can blend and its swell is continuous.  The animation cannot, a
GIF being indexed colour, so there the swell is the steps a palette allows
-- which family of colours the aeroplane is drawn from, and how far its halo
reaches, which is six between them and reads as a swell at a couple of
seconds a cycle.  The peak has sixteen colours of its own, sixteen being
exactly what was left of the palette once 255 is set aside as the
transparent index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-06 17:03:16 -07:00
The Dust Council
8ed01f991f Cover every option in the help, the manual and the readme, and say what to install
An audit rather than a feature, prompted by wanting this fit to hand to
somebody else.

Five options had no command-line flag written down in the table the manual
is generated from -- location, hold, schedules, tile_url and speed_unit --
so five flags that exist were missing from the manual.  Four of them did
exist under other names and are now recorded; hold had no flag at all and
has one.

Four switches could be turned off from the command line and not back on:
--no-lookup, --no-basemap, --no-airports and --no-labels had no positive
halves, so an option turned off in the saved settings could not be turned on
again for one run.  All four now have both.

And adsb, which opens the window and draws a map when it stops, could not be
given any of the settings that decide what those look like: no --at, no
--radius, no --tiles, no --map-brightness, no --width, --fps, --trail,
--fade, --stale, --airports or --labels.  It takes all of them now.

The manual had no list of the aircraft options at all -- the ADS-B sections
were hand-written prose -- so five of them appeared nowhere in it.  It now
generates an AIRCRAFT OPTIONS section from the same table the menu and the
flags come from, and the readme carries a table of all thirty-four with
their flags and defaults.  Three tests hold the three of them together: one
that every option records its flag, one that every flag the table claims
actually exists on a command, and one that the readme names them all.

The installing instructions now list every dependency rather than only the
optional ones: the four Python packages with their names in Debian, Fedora
and Arch, and librtlsdr, which is a C library and therefore the one thing
pip cannot bring and a virtual environment cannot supply.  What reaches a
network is written down too -- which host, when, and which file it is cached
in -- since somebody installing this on a metered or air-gapped machine has
to be able to see that nothing is fetched behind their back.  build-repo.sh
needs dpkg-dev and apt-utils, which a minimal system does not have, and now
says so.

Verified rather than asserted: a clean virtual environment, pip install from
this tree, and a real log read back through the installed command.  It pulls
seven wheels rather than the four the page claimed, the other three being
what Rich brings with it.

Also: matplotlib is gone from the readme's dependency table, nothing having
imported it; ffmpeg and Qt are in it, both having been missing; ffmpeg is a
Suggests on the package; and resume.sh is ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-06 14:12:33 -07:00
The Dust Council
6d2436cde1 Let the map brightness reach the map, and put the options in groups
The map brightness setting could not make the map visible on a vector
theme, which is the one place it was needed.  Those themes want the ground
well out of the way -- a tinted photograph of a county behind the vectors is
the one thing that stops a vector display looking like one -- and that was
done by multiplying the setting by about a quarter.  A multiplier is a
ceiling: turned the whole way up, the setting still gave a map at a tenth
the brightness the default theme gives, which is to say invisible, and no
amount of turning it up did anything about that.

It is a curve now rather than a ceiling.  The theme raises the setting to a
power, so the middle of the range is still quiet -- seventy per cent lands
where the old quarter did, which is the look these themes are for -- and the
top of the range is a full-brightness map on every theme there is.  On the
green phosphor the setting now spans a luminance of six to seventy where it
used to stop at twenty-one.

And the options are in six groups rather than one list: receiver, listening,
aircraft, animation, the map, labels.  Thirty-three of them on one screen is
a wall rather than a menu.  A number opens a group and a number inside it
changes an option, with the numbers still being each option's place in the
whole list so that the same number means the same option wherever it is
typed -- which meant reordering the list so that every group is contiguous,
and there is a test that says so.

A group menu makes a known option harder to reach than a flat list did, so
the name works too: typing "map brightness" at the top goes straight to it,
and part of a name lists everything it could mean.  A name that matches
exactly wins outright, so "speed" reaches the setting called speed rather
than that one and every other whose description happens to mention the word.

One thing to know: a bare number at the top of the menu now opens a group
where it used to edit the option of that number.  The tests that drove the
menu that way would have gone on silently editing whatever option shared the
number, so they ask by name now, and one of them checks that a group number
changes nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-06 12:26:00 -07:00
The Dust Council
2da1d1f245 Tell the boxes from the flight paths, and say how far out things are
Three changes to what is on the picture, and both drawings got all three.

The line from an information box to its aircraft was drawn in the
aircraft's own colour, which made it the same colour as that aircraft's
trail: a straight solid line running out of an aeroplane, in the colour of
the path behind the aeroplane, reads as more path, and on a busy picture
that is a heading nobody flew.  It has its own colour now, and is dashed.
Qt measures a dash pattern in multiples of the pen's width, so each pass of
the glow divides the pattern by its own width; without that the halo's
dashes are three times the core's and the line comes out as beads.

The animation had no such line at all, which only came out when the two
were held up against each other: a label pushed into one of the outward
rings by a crowd had nothing tying it to the aeroplane it was about.  It has
one now, walked along the line's own length rather than along whichever axis
is longer, so that a nearly horizontal leader and a nearly vertical one get
dashes of the same length and neither runs past the aircraft it points at.

A red flag stands where the receiver was told it is standing, from the
coordinates in the settings.  The foot of the pole is the position and the
pennant flies up and to the right, so nothing the flag is made of covers the
place it points at.  It is pure red in every theme: that is the one mark on
the picture whose meaning must not change with the colours, and pure red is
both the brightest red there is and the one furthest from every altitude
colour -- a softer one sat close enough to a low aeroplane on the default
map, and to a mid-altitude one on the red theme, to be taken for one.  It is
drawn only where a position was actually given, since a middle worked out
from whatever flew past is not a place anybody is standing.

And the range rings: faint discs at a quarter, a half and three quarters of
the radius, concentric on the receiver and labelled with the distance.  They
are translucent and they stack, so the ground inside the innermost is lifted
three times and the outer once, which gives a sense of how far away a thing
is without measuring anything.  An indexed picture cannot blend, so
translucent there means moving the ground under the disc a step or two up
its own ramp of shades, which keeps the coastline and the roads visible
through it.  Separate settings for the two, because a picture is studied and
a window is glanced at.

Two bugs found by the tests rather than by looking.  The rings were built
across the whole canvas instead of the part of it the map is drawn on, so
they came out stretched by the height of the title bar -- four per cent,
which is invisible and wrong.  And a radius of zero killed the window: the
projection collapses, every pixel maps to one point, and the graticule asks
for lines across a span of nothing until Qt aborts.  The program never
passes a zero, but a caller could, and a crash is not an answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-06 11:49:48 -07:00
The Dust Council
2e20c48971 Mark the aerodromes in the window, and draw the lot on a vector display
The aerodromes were never on the window at all: only the animation drew
them, and the window's map had whatever airport glyphs the tiles happened to
carry.  They are drawn there now, in the same colour and the same square.

The first attempt at that had a bug worth naming, because it would have
looked like the feature simply not working.  They were fetched inside the
same pass of the fetching loop as a piece of map, so they queued behind a
hundred and twenty tiles coming off a network -- and once the map was in
hand there were no more passes, so they were never fetched again.  They are
their own question now, asked on the same thread but not behind the tiles,
and they arrive whether the tiles do or not.  An area that has been asked
about and has none in it is remembered as none, rather than asked about
again five times a second for the rest of the night.  And the thread now
starts if either the map or the aerodromes are wanted, so --no-basemap no
longer quietly takes the airports with it.

Then the themes, which change the window and the animated pictures together
because both read their colours out of the same palette.  night is what this
program has always drawn and is untouched.  digital, phosphor, amber and red
are the screens the phrase "air defence display" actually calls to mind: a
black tube, one phosphor, and thin bright vector lines with a halo round
them.

Three things follow from having one colour to spend, and they are
constraints rather than decoration.  Height becomes brightness, since hue is
no longer free -- low is dim and high burns, which is the trade those
displays made.  The map underneath drops to about a quarter of the
brightness asked for, because a tinted photograph of a county behind the
vectors is the one thing that stops a vector display looking like one.  And
a country is named in two letters rather than drawn as a flag, a flag being
half a dozen colours.

The glow is done twice, differently, because the two are different kinds of
picture.  The window lays each line down two or three times, wider and
fainter each pass, with the core last: trails, symbols, leader lines, box
borders and the aerodrome squares.  The animation cannot blend at all, a GIF
being indexed colour, so it dilates what it has drawn and fills the halo
with the dimmed copy of the colour underneath -- and the aircraft colours
already had dimmed copies, since those are the trail shades, so an aeroplane
glows into the colour its own trail is drawn in, which is the colour a
phosphor would have spread into.  The fixed colours get two rings each in
the palette for the purpose.  The halo goes over the map, the grid and the
background and over nothing else that was drawn, since a halo is what light
does to the dark around a line; where two rings meet the nearer wins.  It
costs about 55 ms a frame at 1400 by 1258 and the default theme skips the
pass entirely.

The palette is written over in place rather than replaced, because both
drawings and every one of their helpers hold a reference to that array and a
new one would leave half the program painting in the colours of the theme
before.  There is a test that every theme keeps the aerodrome colour more
than forty units of CIELAB from every altitude colour, stated as the
distance rather than as the colour, so that a new theme cannot quietly walk
an aircraft back into the airports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-05 00:39:20 -07:00
The Dust Council
f9b21f7e35 Give the aerodromes a colour of their own, and say what each aircraft is
Four things about the animated pictures, and the window kept in step with
them.

The aerodromes were amber, and so is an aeroplane at twelve thousand feet.
Sixteen units of CIELAB apart is not two colours, it is one: an aircraft low
over a field was drawn in the field's own colour and neither could be picked
out from the other.  They are magenta now, fifty-six units from the nearest
altitude colour, which is what the ramp leaves free once red, amber, green,
cyan and violet have gone on height -- and what an aeronautical chart marks
an aerodrome in anyway.  The test states that as the distance rather than as
the colour, so that changing the ramp cannot quietly walk an aircraft back
into the airports.

The height beside an aircraft was the flight level, which is shorter and is
what an aviator reads, but "376" is only a height to somebody who already
knows it is one.  It is feet with the unit on it now, rounded to the
twenty-five feet Mode S reports altitude in: a real reading is a multiple of
that and comes through untouched, while a moment interpolated between two
reports stops claiming to know the height to the foot.

The flag of the country of registration now comes off the address block
where no register answered.  Taking it from the register's answer alone left
the flag off exactly the aircraft that had nothing else beside them either.
Mexico was missing from the address table while we were in there, which a
receiver in the American southwest notices; two registers independently give
XA- registrations for that block.

And what sort of aircraft it is, which is two facts and not one.  What it is
comes off the air: every identification message carries three bits under its
type code saying whether it is light, large, heavy, a rotorcraft, a glider,
a drone or a van on the apron, and that is the only word about what an
aircraft *is* that needs no register.  They were being decoded and thrown
away.  They are inside the identification frame the log already writes down
in full, so every log this program has ever written has them, including the
ones written before anything here knew to look.

Whether it is military comes off no air at all -- a tanker calls itself
heavy exactly as an airliner does -- and is read from the address block
instead.  On one evening here AE07D3 broadcast "heavy" and sat in the United
States military block; the register, asked separately, came back with a
C-17A Globemaster III, tail 90-0534, United States Air Force.

Labels in an animation now behave as the boxes in the window do.  One keeps
its place for as long as that place still works and is moved only when
something takes it, which on a real log is about half as many moves as
deciding afresh every frame; the moves that are left are eased over half a
second of playback; and what the next label is laid out against is where a
moving one is going rather than where it has reached.  A still has no frame
before it and places its labels exactly as it always did.

The fading was only half done.  A label's name faded, because it is drawn in
the aircraft's own colour, but its rows and its flag were fixed colours and
stayed at full brightness -- so the brightest thing on that part of the
picture was the one aeroplane nothing had been heard from.  An indexed
picture cannot blend, so the row grey and all twelve flag colours now have
dimmed copies at each of the four fade steps, and the whole box goes
together.  A crowded frame, which drops back to the short label, drops the
class and the flag with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 22:45:41 -07:00
The Dust Council
8eb3bdbb86 Ask a service that knows which leg it is, and fetch a map worth the screen
A callsign is a flight number rather than a leg, and the free registers hold
one route per number, so an aircraft over Arizona kept being handed a hop
between two airports in Texas.  Nothing on the air settles it: ADS-B carries
no origin or destination.  A commercial schedule service does know, because
it holds the day's actual movements.

Four are wired up and all four are optional: FlightAware AeroAPI,
Flightradar24, OAG and Cirium.  Each is asked before the free databases and
each answers for the moment the aircraft was overhead rather than for the
flight number in general, so the leg chosen is the one that was in the air.
With no keys set nothing changes at all: a source with no key is skipped
rather than asked and refused, and the free databases answer as before.

Keys come from the environment and are never written to the settings file,
because a settings file is meant to be copied between machines and pasted
into a message asking for help, and an API key is not.  There is a test that
holds that line.

None of the four has been run against its live service, since each wants a
paid account.  They were written from the published response shapes and are
tested against those shapes, so each reader finds what it recognises and
returns nothing otherwise: a service that has changed since costs a route
rather than a scan.  Cirium's plain departureTime is local and carries no
offset, so the UTC field is preferred where it is there -- reading the local
one as UTC is up to half a day out, which is exactly far enough to pick the
wrong leg of the same number.  Reading now happens inside the same guard as
asking, as an answer shaped differently from the documented one is the
failure most likely to actually happen.

And the map.  The zoom is now chosen from how wide the picture is rather
than from the area alone, with half again over the width fetched and
averaged down, since a downscaled tile is sharp and an upscaled one is not.
The window fetches a little more world than it shows so panning does not
leave the ground blank, and now fetches that bigger piece at the bigger
piece's own size: rendering it into the window's own pixels and stretching
it back was a fifth of an upscale over the whole map, which is what a sharp
map looks like when it looks blurred.  The comment in the fetcher said the
opposite of what the code did, which is how it stayed hidden.

At 1920 by 1080 over a hundred miles the tiles now hold about 1.6 times the
pixels the window wants.  At 3840 by 2160 the tile budget is reached, the
zoom stops climbing and the map is enlarged after all; a smaller radius buys
the detail back, and somebody else's tile server is not a thing to fetch a
thousand tiles from for one picture.  The README says so rather than
implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 20:19:53 -07:00
The Dust Council
87b0954f0c A sharper map, and aircraft that fade rather than blink out
Three things asked for, and a fourth found while doing them.

The map looked like a photograph of a map, and did so twice over.  The
window fetched at its own pixel size but for a box a third larger in each
direction -- the margin added to stop the ground blinking -- and then cut
the middle out, so every pixel was enlarged by two thirds.  It now asks
for enough pixels to cover the bigger box at the window's own detail.
Underneath that, both the window and the animation took the nearest source
pixel: the tile mosaic is commonly half again the size of the picture, so
most of every tile was thrown away and what survived was the aliasing.
Both now average the source pixels that fall in each output cell, done as
the difference of a running total rather than a loop.

An aircraft that goes quiet now fades instead of vanishing.  Taking it off
between one frame and the next says it stopped existing; fading says it
stopped talking, which is what happened.  It fades where it was last
actually seen and never along a reckoned track, because the reason for
giving up on it is that where it would be by now is a guess.  --fade sets
how long, and it is in the menu.  The window has alpha and fades smoothly;
an indexed picture cannot blend, so the animation gained a fourth ramp at
a seventh of full and fades in four steps, which at a second apart reads
as a fade.  A trail fades with the aircraft it belongs to, and the box
goes before the symbol does.

The aircraft's country of registration carries a flag now as well as the
two ends of its route, from the register where one answered and from the
address block otherwise.

And the fourth: the window's header counted an aircraft that had gone
quiet as overhead, which was saying more than had been heard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 19:04:09 -07:00
The Dust Council
df080f6571 A window on the sky, and flags on the routes
The terminal board says what is overhead.  This says where: a real map
with the aircraft moving on it as the frames arrive, and beside each one a
box carrying everything known about the flight -- type and registration,
who operates it, where it came from and where it is going, height with a
rate of climb, speed and heading, how far away and on what bearing, its
position, how many frames it has sent and how long since the last one.

Qt is asked for and not required.  Four bindings are tried, the module
imports on a machine with none of them, and asking for the window without
one gets the instructions rather than a traceback -- before the receiver
is opened, since nothing is gained by taking the dongle for a window that
cannot be drawn.

In the menu, "listen now" is now "passive capture" with a realtime
display beside it.  Closing the window leaves exactly the files pressing
control-C leaves, because listen and watch share one read loop and one
finishing step; the receiver runs on its own thread, so a slow repaint
cannot cost a frame and a slow tile fetch cannot stall the picture.

The animation's labels grew to match: flight level and speed, type and
registration, and both ends of the route, each with a small flag of the
country its airport is in.  The flags are a table rather than a network --
twelve pixels by eight, where a flag is the arrangement that makes one
recognisable rather than a rendering of the real thing -- and a country
not in the table is named by its two letters, since a flag that is nearly
another country's is worse than none.  Where a route arrives as bare
codes the country comes from the ICAO prefix.

Four things found on the way.  The window ignored --seconds, so "listen
for ten minutes" meant something different with a window open; it closes
itself now.  The register was being asked twice per aircraft, once for
labels and once for airport positions.  Cached routes had no country in
them, so the first real redraw drew no flags at all -- routes are
versioned now.  And past fourteen aircraft on one frame the labels go
back to the callsign, the height and the speed, because five lines beside
each of three hundred aircraft is a page of overlapping text with a map
somewhere behind it.

Long names are folded rather than allowed to stretch a box, breaking at
the arrow of a route so the two ends stay whole; and the animation's
label placement gained the same ring search the window uses, having only
ever tried four spots.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 13:38:47 -07:00
The Dust Council
e50d43d6e2 Frame the map on the receiver, and throw out what never happened
A first real capture came back as a map spanning 240 degrees north to 20
south, with the aircraft an indistinguishable smudge in one corner.  Two
separate faults, one of them mine from the start.

A position is sent as half a position -- an even frame and an odd one --
and the pair only means anything while the aircraft has not moved between
them.  The registry kept the last of each forever and paired them
regardless of age, so an even frame from ten minutes ago decoded against
a fresh odd one to a place on the wrong side of the world.  Measured: a
pair 300 seconds apart puts the aircraft 2,566 nm from where it is, and
one night's log had it happening to two aircraft in three, with eleven
positions off the planet altogether.  A pair is now good for ten seconds,
the answer has to be on Earth, and the aircraft has to have been able to
reach it.

--radius, defaulting to a hundred, frames the picture on the receiver
rather than on whatever was heard, so the scale is the same from one
evening to the next.  In the same unit as the speeds.  The centre is the
median of everything heard, which a handful of wrong positions cannot
move, or --at LAT,LON says where the aerial is.

--recheck repairs a log recorded before all this: for each aircraft it
keeps the longest run of positions that could describe one aeroplane.
Not a forward walk dropping whatever disagrees with the last position
kept -- that lets one bad fix become the reference, and on the same log
it discarded a fifth of everything, most of it the truth.

Two calibrations came from the recording rather than from taste.  The
failures separate cleanly -- a hundred artefacts under a mile, twelve
hundred real errors over fifty, and nothing in between -- because
positions are stamped to the millisecond, so two a thousandth of a second
apart imply thousands of knots across a few yards.  Nothing under two
miles is called an error.  Afterwards the worst surviving jump is 513 kt.

One rendering bug the tighter frame exposed: an aircraft just off the top
left painted 743,774 pixels of an 844,200-pixel picture, because the dot
at a marker's centre clamped its near edge and left the far one alone, and
numpy reads a negative slice end as counting back from the far side.  And
a still of a whole evening was dead-reckoning every aircraft forward to
the final moment, which for a ten-hour log flew 291 of 335 clean off the
picture; a still now draws each where it was last actually heard.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 12:25:39 -07:00
The Dust Council
96fc21ac7d Aircraft, from the menus, on a live board, over a real map
Four things the ADS-B mode was missing, and one it was actively getting
wrong.

The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do -- and it records the
bursts as clicks in a WAV file and decodes nothing, silently.  Both the
scanner and the menus now say so, before the sweep starts, and name the
mode that does decode it.  It is not refused: looking at the raw spectrum
is a fair thing to want.

Menu 5, Aircraft (ADS-B), is the whole mode without a command line.  Every
option on one screen with a line saying what it does, ?N for the long
version and the flag it corresponds to, l to listen, m to draw a map from
any log, s to keep the options.  The listening and the drawing moved into
bandsaunter/aircraft.py so the menus and the command line run the same
code.

While it listens the screen is a live board: one line per aircraft in the
order first heard, the counter climbing as frames arrive, height coloured
low warm to high cold with an arrow for climb or descent, the age of the
last report going green to red, and the line removed once nothing has been
heard for --hold seconds, everything below moving up.  The registers are
asked while it runs, so registration, type, operator and route fill
themselves in as the answers arrive.

--speed-unit knots|mph|kph changes the heading of that board, the speed
beside every aircraft on the map and the speeds in the report, and moves
the distances with it so that one picture never carries two different
miles.  The log stays in knots, which is what the aircraft broadcast.

And there is a real map under the flight paths: {z}/{x}/{y} tiles fetched
once, cached in ~/.cache/bandsaunter/tiles, reprojected from Web Mercator
pixel by pixel, inverted and dimmed so the aircraft stay the brightest
thing on the picture.  The PNGs are decoded here -- zlib and the five row
filters from the specification, checked byte for byte against Pillow on
real tiles -- so nothing new is depended on.  Tiles are cached and never
re-fetched, every request says who is asking, and the attribution is drawn
onto the picture, because a GIF travels without its readme.

conftest now fails any test that reaches for a tile server or a register.
It caught four of these on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 00:05:32 -07:00
The Dust Council
4239635f74 Say what the terms are, and how to install under them
The program had no licence file at all, and pyproject claimed MIT into
the void.  It is now the GNU General Public License, version 3 or later:
LICENSE holds the text verbatim, pyproject declares it with the OSI
classifier, both .deb builds write /usr/share/doc/<pkg>/copyright in the
machine-readable format Policy requires, both manuals carry a COPYING
section, and --version prints the GNU notice on both programs.

INSTALL.md is the step-by-step: what you need, the Debian package, the
virtual environment for everywhere else, how to check it worked, every
optional dependency with what it buys and what happens without it, and
the errors people actually hit first -- PEP 668 at the top, because on
Debian a plain "pip install ." refuses and reads as a broken program.

Speech transcription gets its own four steps, because it is the only
part with a real download in it: the recogniser into the environment
bandsaunter runs from, checking it took, the model (base.en, 148 MB,
from Hugging Face into ~/.cache/huggingface, fetched deliberately rather
than in the middle of a scan), then turning it on.  With the sizes of
every model, the offline routes, and what to do when --engines says no
although pip says yes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-03 22:58:18 -07:00
The Dust Council
eae60cb04d Write the aircraft down, and draw where they went
ADS-B was a live table and nothing else: an aircraft was overhead for four
minutes and then gone, with nothing kept.  Now everything heard goes into
adsb_<time>.jsonl as it arrives -- one object per frame, the raw hex beside
what was read out of it, flushed per line because a listening session ends
with control-C -- with a readable report beside it.

flights.py asks who the aircraft are: adsbdb for the airframe and the
route, hexdb behind it, cached for a month.  What needs no website is
answered without one, because the ICAO address block says which country
registered the aircraft and the first three letters of an airline callsign
are its designator.  Nothing but the address and the callsign heard on the
air is ever sent.

  bandsaunter flights [LOG...] --out sky.gif

reads a log back and draws the evening as a map with the clock running.
Every frame is a moment: each aircraft is where it actually was then,
interpolated between the position reports either side of it and
dead-reckoned from its last speed and heading between them, and dropped
rather than guessed at once it has not been heard for --stale seconds.
The GIF is written here -- palette, LZW, frame differencing against a
transparent index -- so nothing but numpy is needed; ffmpeg writes an MP4
where it happens to be installed, and .png draws the whole evening at once.

The decoder needed 6.3 s to read a second of sky, so a live capture was
losing six frames in seven.  Reading the bits off a running total instead
of summing each window takes that to 0.6 s, with identical output.

--simulate flies six aircraft that are not there past a receiver that is
not there, through the real encoder, the real checksum and the real
decoder, so all of this can be tried without an aerial.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-03 22:22:49 -07:00
The Dust Council
a8a8548369 Never call a string of Morse tones "words"
A repeater identifying itself in CW over an FM carrier came back from the
recogniser as "2-2-2-3-3-5-2-7-0-5-9-7-0-8-1-0" -- one digit per tone,
sixteen characters of nothing, which cleared the five-character bar and
cost the capture its waterfall.

The rule now lives in one place, waterfall.is_readable, shared by the
scanner and the waterfall command: voice, no Morse, and more than a
handful of characters.

  bandsaunter waterfall --check-morse

runs the CW decoder over the recordings a sidecar calls readable, for
sidecars written before the decoder could hear an ident over an FM
carrier, and draws -- and records the ident in -- the ones that have one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-03 21:44:14 -07:00
The Dust Council
dee262e130 Draw the waterfall for everything that never spoke
Every capture that is not voice, or whose voice yields five characters
or fewer of transcript, now gets a PNG of the waterfall it would have
painted on screen: spectrogram from the IQ where it was kept, from the
demodulated audio otherwise, captioned and labelled either way.

The browser shows it in the picture panel, but only when there is no
transcript, Morse or decoded data to show instead.

  bandsaunter waterfall [PATH...] [--all] [--redraw] [--min-chars N]

draws them after the fact for recordings already on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-03 18:36:00 -07:00
The Dust Council
7e8b9b268d Read the Morse a station sends over its own carrier
A base station identifying itself in CW does not key its carrier. The
carrier stays up and the ident is an audio tone keyed inside it, which
a detector looking for a keyed carrier sees as a carrier that never
stops. On the land-mobile bands that is nearly all the Morse there is,
and none of it was being read: an ident of KSQ330 sat in the middle of
a 27-second capture on 154.369 MHz, cleanly keyed at 22 WPM, and the
capture was filed as voice with no Morse in it at all.

Three things were in the way, and each was found by measuring rather
than by reading.

The whole-recording decode ran only for captures recorded in cw mode.
An ident over FM is recorded in nfm, so it was never looked for. It
now runs for every capture.

The tone was sought in the first four seconds of the audio and nowhere
else, so a tone that had not started yet could not be found -- on the
capture above it locked onto the harmonic of something else. It is now
averaged over the whole clip.

And the steady tone either side of the ident was read as a character
the window had sliced, which dropped the first and last letter and,
through complete_text, the whole callsign: one word with no gap in it
to survive the drop. A mark far longer than any dash is not a
truncated element, it is the transmission the ident was sent over.

Even fixed, the decoder measures its tone and its key-down threshold
over the whole of whatever it is handed, so a half-minute recording
with five seconds of keying in the middle measures both from the other
twenty-five. So the audio is searched a few seconds at a time, plus
the whole capture -- that one matters for a beacon keying throughout,
where the longest window is the best one and leaving it out lost an
ident the decoder had always read.

Nothing was loosened. Every window is judged by is_morse exactly as a
whole capture is. Across 677 real captures the search claimed Morse in
four: KSQ330 and WNRS309, both FCC land-mobile callsigns and neither
seen before; a 20 WPM burst on 70 cm reading as E7HNN, plausible and
unverified; and noise on 445.5 MHz reading as "T T T E E E E E E E E".
That last one is the new rule -- E and T are the one-element
characters, so a decode of nothing but those can hardly be wrong,
because there is nothing in it to get wrong. With it the count is
three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-02 07:57:18 -07:00
The Dust Council
8a789e57e1 Hear the short replies, and read the other kind of callsign
Two things, both found by measuring rather than by reading the code.

The voice-activity filter inside the recogniser is off. It was costing
words: across a night of land-mobile captures it dropped 5-15% of what
the same model finds without it -- 491 against 507, 339 against 384,
263 against 310 -- because a single-word over between two
transmissions looks to a VAD exactly like the noise it exists to
remove, and on a scanner those short replies are the ones worth
having.

Turning it off has a cost, and the cost is that Whisper hands back
"You" for five seconds of hiss as confidently as it hands back a
sentence. So the whole capture is now asked once whether anything in
it rises above its own noise. Digital silence measures 0.0 dB of
contrast and hiss at any level 0.7, while the quietest real capture of
that night measures 8.9 and most measure 10-27; the bar sits at 3, an
order of magnitude clear of both. It can veto a capture but never trim
one, which is the whole difference between it and the filter it
replaces.

The second thing: callsigns like WQVF960 were being missed entirely.
The shape being matched was the amateur one -- prefix, district digit,
suffix -- and everything else the FCC licenses is written the other
way round, the letters first and then the digits. On the GMRS and
business channels that is most of what is said: nine callsigns across
five transcripts of one evening went by unrecognised, and now do not.

The shape is written as the three allocations that exist rather than
as "letters then digits", which claims KN95, WD40 and KC135. Its
letters are checked against the word list even when they arrive as a
single token, which the amateur shape does not need -- no English word
has a digit in the middle of it, but "west 120" and "word 100" fit
this one exactly.

Lookups now fall back to hamdb.org when callook has nothing. Not a
spare copy: callook holds United States amateur licences only, so
DL1ABC and VE3ABC are INVALID there and resolve perfectly well from
the other. And a GMRS callsign is not looked up at all -- every
database reachable without an account is an amateur register, so
reporting WQVF960 as "unlisted" would blame the callsign for the
absence of a source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-02 00:30:02 -07:00
The Dust Council
3d7f76118e Read the pictures, the aircraft, the meters and the sensors
Hexadecimal is a true answer to "what did that say" and not a useful one.
This is the work of turning the rest of what a receiver hears into
something a person can read, and most of it is pictures.

PICTURES

Three of the things on the air are images rather than sounds, and all three
arrive as the audio a scan already records:

  SSTV     14.230 and 144.5 MHz   Martin M1/M2, Scottie S1/S2/DX, Robot 36/72
  APT      137-138 MHz            the NOAA weather satellites
  HF fax   2-20 MHz, sideband     the marine weather charts

Each is written from its published specification, and the generators used to
test them are written from the same specification without reference to the
decoders -- so a picture that comes back matching the one that went in is
evidence about the format.  Every SSTV mode reproduces its published line
time exactly, which is worth failing a test over: a line a few milliseconds
long walks the picture off the screen inside ten lines.  Against synthetic
transmissions at 30 dB SNR, SSTV is 96-98% of pixels exact, APT correlates
at 0.97 and fax at 0.998; all three still read at 6-12 dB.

None of the three is guessed at, and that is what makes it safe to try them
on every recording.  SSTV needs its VIS header, APT needs both line syncs at
the right distance from each other, fax needs the phasing signal.  No false
pictures in 295 attempts over noise, tones, speech and swept whistles.

Two things had to be got right beyond the arithmetic.  A band-pass does not
switch between two tones, it slides between them, so every edge is measured
at the midpoint of the slide rather than at the first sample past a
threshold -- the earlier version was reading the coarse search stride back
as the edge and shifting Martin M1 sideways by a whole colour bar.  And a
picture now keeps its capture whatever the content check made of it: a
satellite is a steady tone with a wobble on it and SSTV is a whistle, so
both were being discarded as "no signal content" having already been
recognised.

PNG is written here rather than pulled in from Pillow.  A scanner that
cannot start because an imaging library is missing is worse than one that
cannot draw.

saunterbrowse marks a picture in the list, gives its path in full -- wrapped
rather than cut off, because half a path opens nothing -- and moves or
deletes the PNGs with the recording.  o prints the picture's path, not the
audio's.

GRIB is not a modulation and is not pretended to be one.  It is the format
weather models are published in and it travels by satellite link and by
e-mail; where a decoded byte stream begins with its magic number it is
named, and that is all.

AIRCRAFT

`bandsaunter adsb` parks the receiver on 1090 MHz and reads Mode S extended
squitter: address, callsign, altitude, position, speed.  A command of its
own because a megabit a second will not go through a channel twelve and a
half kilohertz wide.  Every frame carries a 24-bit checksum so there is no
threshold anywhere in it -- with one trap, which is that a frame of all
zeros satisfies that checksum and silence is exactly that.  Positions round
trip exactly through compact position reporting, and a pair straddling a
longitude-zone boundary is refused rather than resolved against two grids.

METERS AND SENSORS

Itron ERT utility meters on 900 MHz and AcuRite weather sensors on 433 MHz
are named rather than reported as hex, and neither is believed without its
own checksum -- BCH(255,239) for the meter, a checksum and four parity bits
for the sensor.  Both are implemented from published descriptions and
checked against frames built from the same descriptions, which proves the
framing and the arithmetic and is not the same as having held a meter.

HEX INTO WORDS

Everything else that decodes to bits now gets its fields named where the
shape is standard, its text read out where there is text, and its bytes laid
out in groups with the printable characters beside them.

The text search is where the care went, because printability is not
evidence.  Forty framings of each packet, and seven-bit values printable
three in four, meant a bar set on printability called 64% of random payloads
text.  Real text is nearly all one case where random letters are half and
half, two fifths vowels where random is a fifth, and mostly alphanumeric
where random draws punctuation one time in four.  Together: under 0.5%,
measured in the suite.

CALLSIGNS

The licensed address is recorded in full -- the street, not merely the town
-- and goes into the KML with everything else.  US amateur records are
public by law and carry it; holding it and not saying so is worse than
either showing it or not asking, and --no-lookup asks for none of it.

Also here: Morse is decoded again from the whole recording where the capture
was made in cw mode.  The first pass works from the classifier's buffer,
which holds a few seconds -- enough to say "this is Morse", not enough to
catch a callsign whole between two word gaps, so a beacon repeating every
eight seconds through an eight-second window was never identified.

And classify._psk_order took the logarithm of zero on a silent block.

1318 tests, up from 1161.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-08-29 19:38:37 -07:00
The Dust Council
b4718aa425 Read the stations that never say a word
Most of what identifies itself on the air identifies itself in Morse.  A
repeater, a beacon, an unattended transmitter: four to six characters, over
in a second or two, and no speech anywhere in the capture.  Every one of
those was being thrown away, in three separate places.

The callsign book and the map were built inside the transcription branch,
on the reasoning that callsigns come out of transcripts.  They also come out
of Morse and out of APRS headers, neither of which involves a speech
recogniser -- so a receiver with none installed found none of them, and a CW
ident reached the sidecar and stopped there.  Both are now built whenever
classification is on, and all three sources go through one place.

The CW decoder only ran where the classifier had already said cw, ook or
carrier.  A two-second ident is a fraction of a capture named after whatever
filled the rest of it.  Every capture is offered to it now, once it has
finished; a decode does not relabel a capture that plainly holds speech.

And the decoder's own gates were written for a paragraph.  Three characters,
eight elements, and any repeated character refused -- which read VVV, DE, AR
and K correctly and then discarded them.  Short is the normal case now, on a
second bar: perfect timing, nothing undecoded, and the keyed tone at least
20 dB over its band.  That last is not decoration.  With four elements the
dot length is fitted to those very elements, so noise lands on the grid as
neatly as keying does; a third of a second of white noise decodes as a
perfectly timed V.  Over 200 noise blocks the loudest bin never rose 13 dB
above the median while keying at 3 dB SNR sits above 40.  One keyed element
is still refused: a single pulse is an E or a T whether a person sent it or
the squelch opened on a click.  288 non-Morse cases, no false positives.

Feeding that text to a callsign lookup made truncation matter.  A capture
opens when the squelch does, halfway through an element as often as not, and
half a character is not a smaller reading -- a K missing its first dash is
an A.  So the sliced character is dropped, and so is the rest of its word,
because what is left can read as a whole one: K1AA caught halfway through is
K1A, which is somebody else.  Across 1805 truncated captures that is 107
invented callsigns down to none, with 550 correct ones still found.

Phonetics, which is the other half of the ask.  A recogniser has never heard
of the alphabet -- it writes what the words sounded like:

  Whiskey-One-Alpha-Whiskey    hyphenated
  WhiskeyOneAlphaWhiskey       run together
  Whiskey1AlphaWhiskey         and half in digits
  wiskey one alfa whisky       spelled the way it sounded
  whiskey one alpha, uh, whiskey   with the hesitation written down

All read back to W1AW now.  A word is only taken apart when it is phonetic
all the way through, which is what keeps it off "kilometre" and "victorious".

Two bugs found on the way, both of which invented a callsign:

 - Nothing is joined across a slash any more.  The beacon W1AW/B came back
   as W1AWB, which belongs to nobody, and W1AW-4 came back as nothing at all.
 - Nor across a gap the sender chose.  A transcript's spacing is the
   recogniser's guess and may be closed up; a word gap in Morse is seven dot
   units, so "KU0W K" is a station signing off, not a longer callsign.

Also here:

 - saunterbrowse gives Morse a panel of its own, with the licence under it,
   searchable with / and readable with t.
 - --simulate no longer looks anything up or writes a map.  The demo band is
   invented but W1AW is the ARRL's own station, and it would have been
   pinned to the same map a real scan writes.
 - classify._psk_order took the logarithm of zero on a silent block.
 - The demo band has a repeater ident in it, and its Morse no longer runs
   one repeat into the next.
 - conftest refuses a real licence lookup from any test.

1161 tests, up from 1014.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-08-29 14:59:25 -07:00
The Dust Council
d6ae6d0c22 Redraw cleanly when the window is resized
Resizing the terminal during a scan left the screen full of wreckage:
box corners in the middle of a line, borders twice the width of the
window, a "receiver" header printed eight times down the left edge.  Four
separate defects, which is why it looked so bad.

Live rendering works by moving the cursor back over the frame it drew
last time and overwriting it.  That is only correct while the frame is
still where it was put, and none of these programs noticed when it was
not.

1. Nothing detected a resize.  Both the scan display and saunterbrowse
   now compare the console size on every frame and clear the screen when
   it changes -- polled rather than handled as a signal, because the
   display is redrawn several times a second anyway and a signal handler
   that runs in the middle of a write has to be right about far more than
   this does.  Anything printed before the scan started scrolls away at
   that point, which the manual now says.

2. The layout's model of its own height was wrong, in two places that
   cancelled.  The sweep panel was counted as one line shorter than it
   is, the hit list as one line taller.  The sum came out right whenever
   both were drawn and wrong on a terminal too short for the hit list --
   where the frame then overflowed by one line on every refresh and the
   top of it marched down the screen.  That is what the eight headers
   were.  Each panel height is a named constant now, and a test checks
   every one of them against what is actually rendered.

3. Lines inside the panels could wrap.  A band name, a long status line
   or a decoded message made a panel a row taller than the arithmetic
   allowed for, with the same result.  Every one is drawn on a single
   line and ellipsised now.  The receiver panel drops its optional parts
   instead, keeping the tuner and the flags: "SIMULATED" disappearing off
   the end of a narrow line is how somebody comes to believe they are
   listening to the air.

4. saunterbrowse's full-screen views did not fill the screen.  Nothing
   erases the alternate screen between frames -- the cursor is sent home
   and the new frame written over the old one -- so pressing t or ? on a
   tall window left most of the recording list visible underneath.  Both
   are wrapped in a layout now, which fills the terminal exactly.

The layout also gives up the receiver panel on a very short terminal,
which it previously had no way to do: on eight rows the smallest frame it
could describe was nine lines.

Testing this by rendering to a wide Console and reading the text back
cannot work -- whether the cursor lands where it should is a property of
the terminal, not of the renderable.  So tests/terminal.py runs the
program in a pty, resizes the window underneath it the way a window
manager does, and feeds what it writes to a terminal emulator whose
screen is then read.  Every fix above has a test that fails without it,
checked by reverting each one in turn.  pyte is a dev dependency and
those tests skip without it; the arithmetic ones need nothing.

Also: t now opens the reader for a capture that carries decoded data
rather than speech, because the decoded panel already told the reader to
press it.

869 -> 949 tests.
2026-08-28 15:47:36 -07:00
The Dust Council
68b05a031c Decode data signals, starting with on-off keying
Much of what a scanner finds is not speech.  Doorbells, tyre-pressure
sensors, weather stations, remote controls, paging and packet radio all
carry something a receiver can read, and until now the answer was "OOK /
ASK data burst" and a WAV file.  Now the bits come out.

The observation the whole thing is built on is that whatever the
modulation, a data signal is the same shape once it has been sliced: a
train of alternating runs whose lengths carry the information.  On-off
keying gives that directly -- the carrier is up or it is down -- and
two-level FSK gives exactly the same thing from the discriminator, one
tone or the other.  So both reduce to a run-length train and everything
after that is shared.

What the runs mean is the line code, and it is worked out from the runs
alone rather than configured, because each code makes a different
prediction about which of the two histograms is the bimodal one: PWM
(EV1527, PT2262, and nearly every 433 MHz remote), PPM, Manchester, and
plain NRZ.  Four-level FSK is recognised as such and read as symbols
rather than sliced down the middle, which produces bits that mean
nothing; where a frame sync word appears the system is named outright.

Two protocols carry their own framing and checksums and so are read in
full.  POCSAG paging: all three rates tried because nothing in the signal
says which it is, every codeword checked and single-bit errors corrected
against the BCH code, and the address, function letter and message text
reported.  AX.25 as APRS uses it: the frame check has to come out right
before a frame is reported at all, and the sender's callsign goes onto
the map with everyone else's.

The hard half is refusing what is not data.  Noise sliced at a threshold
produces runs and runs produce bits, so three things guard against it:
the runs have to quantise to the line code's own grid; most of the bursts
in a capture have to decode the same way, because one lucky window in
eight is a coincidence and that is exactly what SSB voice produced; and,
much the strongest, the packet has to repeat, because bits that come back
identical six times did not come from noise.  A reading with none of that
behind it is reported as nothing at all rather than as a bit string with
a low number beside it that somebody will read anyway.  Across 27
recordings of speech, music, static, a bare carrier, Morse and PSK it
returns nothing 27 times.

A firm decode also outranks the content check, which is statistical: a
burst of keying demodulated as FM audio is a buzz and the speech detector
likes a buzz, but a frame whose own checksum came out right is not a
statistic.  Such a capture is kept and filed as data, not as voice.

What comes out is written to a _data.txt beside the recording, shown on
the live display and in the line-per-hit output, and takes the place of
the transcript at the top of saunterbrowse -- where it is searchable, so
"which page mentioned engine 4" is a question that can be asked.
`bandsaunter analyze` decodes a file you already have.

The simulator gained two honest transmitters to test against: a
pulse-width remote that repeats a real payload, and a pager that sends
real POCSAG batches with real BCH check bits.  Random keying exercises
the classifier but leaves a decoder nothing to get right.  The POCSAG
encoder lives next to the decoder rather than in the test helpers, so a
bug shared by both cannot hide.

Fixed along the way:

- Rich reads a square bracket as markup, and a decoded page is arbitrary
  text off the air.  "[/x]" in a message ended the live display with a
  MarkupError; so did typing "[/" at saunterbrowse's search prompt.
  Everything that did not come from this program is escaped now.

- Otsu returned the first bin of a plateau.  Two populations with nothing
  between them -- silence and full carrier, which is what on-off keying
  is -- make every threshold in the gap equally good, and taking the
  first put it hard against the lower population with the hysteresis band
  outside the data entirely, so nothing sliced at all.

- Estimating the symbol clock by counting along a cumulative grid is a
  fixed point: a unit two per cent small produces two per cent more
  symbols and reproduces itself exactly.  Rounding each run on its own
  converges instead, because every run votes independently.  The grid is
  then the right way to extract the bits, where rounding runs one at a
  time drifts.

- A clipped first repeat used to truncate every other repeat to its
  length.  The consensus is taken over the commonest length now.

761 -> 869 tests.
2026-08-28 12:55:37 -07:00
The Dust Council
fb2bb3344b Name the band beside every frequency, and map who was heard
Two additions, both about turning a number into something meaningful.

A band column.  Next to every frequency -- on the live display, in the
line-per-hit output, in saunterbrowse's list and details -- is the name of
the band it falls in.  421 MHz is the 70 cm amateur band, and being told
so is quicker than remembering where the edges are.

The names come from the existing preset table, so there is one band plan
to keep right rather than two, but naming is not the job that table was
shaped for: several presets cover any frequency, some of them whole-tuner
sweeps that say nothing.  So the candidates are ranked.  Sweeps and the
"-complete" duplicates are dropped outright.  The narrowest of what is
left wins, because it says the most -- 146.52 MHz comes back as the 2 m
simplex calling channel rather than as the whole 2 m band.  Two exceptions
where the narrowest would be the wrong answer: ISM yields to the
allocation it shares (433.92 is 70 cm first, 915 is 33 cm first), and
shortwave broadcast yields to amateur where the two overlap, because
3.9-4.0 and 7.2-7.3 MHz are Region 1 and 3 broadcast but Region 2 amateur,
and this plan is documented as Region 2.  6 MHz really is 49 m shortwave
and is left alone.

The name is written into each capture's sidecar, so it travels with the
recording and an edit to the plan later cannot rewrite history, and
saunterbrowse searches on it: /70 cm finds the band without anyone having
to remember 420-450 MHz.

A map.  A licence says where its holder is, so a list of callsigns is also
a map.  Callsigns heard during a scan are now looked up as the transcripts
come in, announced on the display, and written to callsigns.kml in the
output directory; saunterbrowse --kml builds the same file from recordings
already on disk, and the two continue one map rather than starting two.

One placemark per station, not one per transmission: the same repeater
heard twenty times in an evening is one operator, and twenty pins on one
rooftop would say less than one.  Each pin carries the callsign, the
licensee, the town, the grid square, and every frequency and time it was
heard on.  The file is read back on open and added to, so later scans
build it up rather than replacing it.

Where a licence has no coordinates the grid square's centre is used and
the placemark says so -- a square is kilometres across where an address is
a street.  A callsign with no licence at all is still recorded, in a
folder that starts switched off, because that a station was heard is worth
keeping even when nothing says where.  A file already there that is not
readable as KML is never overwritten.

Also fixed along the way:

- The hit list's "no signals recorded yet" placeholder was one cell short
  of its row, so it landed in the SNR column and wrapped, making the panel
  taller than the layout had budgeted for and scrolling the display off a
  short terminal.  The identification column can no longer wrap either,
  which is what _hit_capacity has always assumed.

- Licence lookups now record coordinates.  The cache is versioned so that
  entries written before this are asked about again, rather than pinning
  every station to its grid square for good.

- CallsignBook.wait dropped joined threads; an all-night scan calls it
  after every transcript and the list only ever grew.

- Tests redirect XDG_CACHE_HOME, so a run no longer reads or writes the
  real lookup cache.

676 -> 761 tests.
2026-08-28 11:02:56 -07:00
The Dust Council
739a2faaf4 Detect callsigns in transcripts, and say whose they are
Under the transcript, headed DETECTED CALLSIGNS:, every callsign heard in
it with the name and location on its licence.

Finding them is not one regular expression over the text as written.  A
speech recogniser is poor at callsigns -- they are not words, they are
said one character at a time -- so it breaks them wherever the speaker
paused and writes the phonetic alphabet down verbatim.  The recording that
prompted this has "Alright, KU 0W" in it, with a space; spelled out it
would have been "kilo uniform zero whiskey".  All three forms read back to
KU0W.

Not inventing them matters more.  A run of words is accepted only when
none of its parts is an ordinary English word: "or 3. Can you open 4" and
"CC1 boy", both from real transcripts here, fit the shape once the
punctuation is gone and are not callsigns.  A single token said in one
breath is still trusted, because W1BOY is a perfectly good callsign, and a
lone "a" or "i" cannot start a join or "a B4U player" becomes AB4U.
Across the 126 transcripts in the recordings directory that turns three
candidates into the one that was actually said.

Lookups use the FCC's own licence data at callook.info -- no account, no
key, the callsign the only thing sent.  They never delay the display: the
entry reads "looking up" and fills itself in, and results are cached under
~/.cache so a net recorded night after night is looked up once.
--no-lookup contacts nothing and still describes a callsign from its own
structure, the ITU prefix giving the country and the digit the US
district, which is also all there is to say for callsigns outside the US.
--callsigns prints everyone who identified themselves and where they were
heard.

Also asked: are transcripts appended to, or overwritten, when another
transmission arrives on the same frequency?  Neither could be shown from
reading the code alone, so there are now three tests that run real scans
and look at the files.  By default each transmission has a transcript of
its own -- the timestamp is in the name, so two overs cannot land on one
file.  With --combine there is one recording per frequency and therefore
one transcript, opened for append with the time of each over; a second
scan into the same directory adds to it rather than starting it over,
which is the case the last of the three tests covers.

The browser and callsign tests refuse to reach the network at all.  One
test did, quietly, and passed -- visible only because the assertion it
failed printed a real operator's address.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-08-22 15:41:02 -07:00
The Dust Council
cc317914e1 Add saunterbrowse, for reading back what a scan collected
A long scan leaves hundreds of recordings, each with a JSON sidecar of
measurements and, where a recogniser heard speech, a transcript.  Reading
that meant opening files one at a time and guessing which were worth
playing.

saunterbrowse is a second executable in the same package.  Arrow keys move
through the recordings; the transcript of whichever is highlighted fills
the top of the screen, because that is the part anyone actually wants to
read.  Enter plays it, handing the file to whichever player is installed
-- the recordings are ordinary WAVs, every desktop already has something
that plays them, and a browser that cannot start would be worse than one
that cannot play.  t opens the whole transcript full screen when it is
longer than the panel, and says so rather than cutting the end off
silently.  / filters on the frequency, the name, the identification, or
anything that was said, which is the point of it: "was the repeater
mentioned" is a question about content.

Sidecars are read only for the rows on screen, so a directory of ten
thousand recordings opens instantly.  Where there is no transcript the
panel says which of the reasons applies -- Morse (decoded, and shown),
data, a bare carrier, or speech never offered to a recogniser -- because
those want different things done about them.  It only ever reads.

Two things were only found by driving it through a real terminal.
sys.stdin.read(1) goes through a buffered text wrapper, which in cbreak
mode waits for more bytes than one keypress provides: the program drew its
first frame and then hung, while tests against a stand-in stream object
passed.  It reads the file descriptor now, and the tests drive a pty.  And
stopping playback signalled only the direct child, so a player that is a
wrapper script kept the sound going with nothing on screen to stop it; the
whole process group is signalled instead, which is what start_new_session
was there for.

man saunterbrowse ships beside man bandsaunter, and the two point at each
other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-08-22 15:06:33 -07:00
The Dust Council
4a272eb1d5 Recognise trunking control channels, and refuse to sit on them
A trunked system keeps one frequency transmitting a data stream around
the clock so its radios know where each conversation has been put.  There
is no speech on it and it never stops, which makes it the strongest and
most useless signal in the band: the scanner parked on 856.561 MHz for
the full record limit, saved four minutes of buzzing, and found it again
on the next sweep.

Five signatures, matched against a constant-envelope stream that never
pauses: 3600 baud two-level (Motorola SMARTNET/SmartZone), 9600 (EDACS),
1200 (MPT-1327), 4800 four-level (P25 or DMR Tier III), 2400 (NXDN).
The first two are believed at once -- nothing else sends at those rates
without pausing.  The rest share their shape with a digital voice call on
the same system, so they wait for the carrier to run unbroken past
--control-seconds, longer than a conversation goes without a breath.
Being in a trunked allocation raises confidence but is never required;
trunking is licensed on business pairs all over the spectrum.

One is named on screen, abandoned within a second or so, and its capture
deleted.  --keep-control records them for a decoder; --lockout-control
writes them into the lock-out list.

Three things had to be fixed to get there.

The simulator's "pseudo-random" symbols were a counter: multiplying the
symbol index by an odd constant and taking it modulo the level count
returns the low bits, so two-level FSK came out 0,1,0,1.  Every FSK test
in the suite was measuring a tone.  Its FSK is now shaped the way GFSK
and C4FM shape a stream, too, square-edged keying being a signal no
licensed transmitter would radiate.

The symbol-rate estimator locked onto harmonics -- 3600 baud read as
18000 -- because a transition impulse train is a comb of equal lines; it
now walks down to the fundamental.  The squared envelope is no longer a
candidate: it is not a transition signal, and its DC lobe made every
random OOK signal measure ninety baud.  The search starts at 200 Hz
rather than 40, below which it was reading drift, which is how a bare
carrier was awarded a symbol rate.  And a clean two-level signal counted
zero discriminator levels, because its modes land in the first and last
histogram bin, where find_peaks cannot see them.

Separately: locking out a frequency wrote to the settings file even under
--no-config, which has no settings file by definition.  It now writes
only where it read from, and --simulate never writes at all -- an
invented frequency would sit in a real config for ever, skipping whatever
genuine signal happened to land near it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-08-22 14:22:49 -07:00
The Dust Council
ba6c925351 Add a manual page, and explain every setting in plain words
Every setting now carries a paragraph saying what it is in everyday terms
and why someone who does not already speak radio would turn it up, down,
on or off: what the squelch knob actually is, why automatic gain is a bad
idea for scanning, why a bias tee can damage equipment, why setting the
transcription language matters on noisy audio. The menus and
`config --describe` show it alongside the existing technical detail.

packaging/make-man.py generates bandsaunter(1) from that same table, so the
manual cannot document a setting the program lacks or miss one it has --
tests check both, that the page renders through groff without a single
warning, and that the guidance survives into the rendered output. Around
it are hand-written sections on the commands, entering frequencies, the
band plan, lock-outs, the keys during a scan, HF, single sideband, files,
environment variables and worked examples.

The .deb regenerates and installs it rather than shipping a copy, so an
installed manual always matches the installed program.

The README picks up what the last few commits added: the plain display as
a saved setting, what the settings tests now guarantee, and where to read
the manual before installing.

Version is the day's build: 2026-08-22_01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 00:01:00 -07:00