Compare commits

...

15 commits

Author SHA1 Message Date
The Dust Council
2665a17a22 Cache what has been looked up, make it browsable, and say where this lives
Four things asked for in turn, landing together because they run through the
same files.

THE MAPS.  The tiles were already cached and always had been -- a second
evening on the same view was measured at nought network requests -- but the
work done on them was not.  Every window open decoded forty PNGs and resampled
a megapixel and a half into this program's own projection, for an answer that
cannot have changed, because a coastline does not move.  The finished map is
kept beside the tiles now: 0.61 seconds become 0.01, byte for byte identical,
two hundred kilobytes a view.  Both windows and both kinds of still picture get
it, all four reaching the ground through one function.  A map with squares
missing is deliberately not kept, since caching a hole would keep it for a
month and the point of calling a partial map provisional is that it is asked
for again.  And because this adds a disk consumer, the whole cache is now
pruned to four hundred megabytes, least recently *used* first: a tile fetched a
year ago and looked at last night is the receiver's own neighbourhood, and
discarding that to keep last week's holiday is the wrong way round.

THE LOOKUPS.  APRS and FT8 now ask who each station is licensed to.  Every
other mode that hears a callsign already did -- speech transcripts, Morse
idents, the recording browser -- and all five resolve through one file, so a
callsign heard on two bands is asked about once.  The rules for finding the
licensed callsign inside a heard one are now in one place rather than per band,
because getting them wrong is silent: a register asked about W1AW-9 returns
nothing, which looks exactly like a station that is not licensed.  An SSID, a
rover suffix, a guest prefix, a digipeater alias and an unspelled hash all come
off or are refused.

The bug worth recording is that the first cut of this did the lookups and threw
them away.  The book has to be told to save and was not, so every evening would
have asked the register about the same net again -- which is the one thing
caching them was for, and is invisible from inside a single run because the
answers are all in memory while it lasts.  Caught by looking at the file on
disk rather than at the display.  There is a test for each side of it now, and a
register of which modules resolve callsigns at all, which fails when a new one
starts so that somebody has to decide whether it should.

THE REGISTER.  c in saunterbrowse opens everything ever looked up: sixty-odd
callsigns and sixteen hundred aircraft here, every field of each in two columns
because a licence has a dozen and a screen is wider than it is tall.  tab
switches, / searches every field rather than the name -- the question is
usually "who was in Arizona" rather than "which callsign" -- and g opens the
place in a browser.  Only the coordinates go into that link: a map does not
need to be told whose licence it is looking at, and the link is the one part of
this that leaves the machine.  A headless box, which is the normal case for a
receiver, gets the coordinates printed instead.  Callsigns no register could
place are kept rather than dropped, because "asked about, and in no register
reachable from here" is a fact about a station.

THE FRONT OF IT.  A title screen for each program: five rows of blocks cut by
hand, a figlet dependency to draw eleven letters being the largest thing that
would then be in the requirements, coloured blue to red across the width, which
is the ramp every waterfall here already uses because it is what a spectrum
looks like.  The interesting part is where it does not appear -- everything
here can be piped into something else and a banner in the middle of that is
corruption rather than decoration, so anything that is not a terminal gets
nothing, --help is untouched because it is drawn after parsing, --no-splash
turns it off for a run and BANDSAUNTER_NO_SPLASH=1 for good.

And the address.  INSTALL.md said "git clone <the repository>" for a long time:
a placeholder in the first command anybody types, unnoticed because nothing
reads install instructions except somebody installing, who then cannot.  It is
filled in, along with the readme, the metadata, both manuals and the Homepage
field of all three packages.  The tile server's User-Agent pointed at a topic
listing on somebody else's site for want of an address of its own; the usage
policy of that service asks for one naming the application and giving somewhere
to look it up, so an operator with a question about the traffic has somebody to
ask, and now it gives the real one.  Seven tests so the placeholder cannot come
back, verified by putting it back and watching two of them fail.

One thing forced by all this: the keys page in saunterbrowse was exactly as
tall as an eighty-by-twenty-four terminal, so the register entry pushed "q
quit" off the bottom.  A test caught it.  Home and End have merged into the
Page Up line, which were always the same thought.

THE WINDOWS.  They open maximised now, this being a map and the thing anybody
wants more of being map; f goes to true full screen and back, and is written
along the top of the screen because a window with no frame is one somebody has
to know a key to get out of.  Maximised rather than frameless by default,
because the title bar is where the band and the frequency are written.

And a map made bigger now gets a sharper map, which it did not.  The window
only ever re-examined the ground when the view left the box that had been
fetched, so a window opened at its default size and taken to the whole screen
kept the map it started with until an aircraft wandered far enough to move it
-- on a quiet band, a long time to look at a blurred coastline.  Measured
before it was believed: 1100 to 1920 asked for nothing and stretched a
1364-pixel map across 1920, then across 3840.  It now compares map pixels per
degree in hand against what the view wants, and asks when it is being blown up
by more than fifteen per cent.

That comparison has to be per degree rather than pixel against pixel, which a
surviving mutation was what established: the fetched box is a quarter wider
than the view, so a map with exactly as many pixels as the window is wide has
only four fifths of them on the screen.  The two ways of measuring agree
everywhere except a narrow band, and the realistic case sits inside it.  There
is a test pinning that case now.  Asking is safe at any size, because the
request is keyed on the window's dimensions: once answered, nothing more is
asked, which is what stops a window larger than the tile budget can cover from
asking all evening.

The braille, while this was open.  The banners were blocks in capitals; they
are braille in mixed case, two dots wide and four tall to a character, which is
eight times the detail and is what makes room for two heights of letter at
once -- a five-row block font has no second height to spend, so the name came
out shouted.  Two attempts failed first: thin one-dot strokes came out as
confetti, because braille dots render as dots and a one-dot stroke reads as a
dotted line rather than a line.  What worked was cutting the font small, at cap
height seven where there is only one way to draw each letter, and doubling it.
Coloured deep blue through cyan to a cool white, which is deliberately not the
waterfall ramp the rest of the program draws in: that one has to run to red
because it stands in for a spectrum, and a title screen stands in for nothing.

One hundred and four new tests.  Full suite 2892 passed.  Built as
2026-09-24_01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-24 00:36:18 -07:00
The Dust Council
93120b80a6 Listen to FT8: fifteen seconds of everybody at once
A new section, alongside the aircraft, the weather sensors and APRS.

FT8 is the odd one out among the things this program listens to, and the
reason is worth stating because it shapes everything below.  Every station on
the band transmits in the same quarter-minute slots, on the same dial
frequency, fifty hertz wide each, stacked across three kilohertz of audio.
One receiver parked on one frequency therefore hears the whole band's worth of
stations at once -- and hears most of them well below the noise, because half
of what is sent is error-correcting code.  That is the entire trick: a rate of
about one half buys a mode that decodes twenty-odd decibels under what an
operator can hear.  A receiver that took the loudest tone of each symbol and
hoped would decode almost nothing, which is why the tone detector reports how
confident it is bit by bit rather than what it thinks it heard.

Written from first principles except for two tables.  The checksum, the
belief propagation over the sparse graph, the Costas sync search, the
waterfall, the soft-bit metric, and the seventy-seven bits that hold two
callsigns and a grid square are all here.  The generator and the parity-check
matrix are not: they cannot be derived, being the code itself rather than
consequences of anything, so they are taken from ft8_lib under its MIT licence
with the attribution it asks for, and said so in the readme, the manual and
the file.  No decoding logic came with them.  That the two agree -- and they
are not derivable from one another, the generator's parity half running to
fifty-odd bits a row against the sparse matrix's six or seven -- is a test
rather than an assumption.

Tested against the air, not against itself.  Eleven off-air recordings with
published decodes: ninety-seven of a hundred and fifty messages, no false
decodes, timing within a hundredth of a second, frequency within a hertz,
signal reports within half a decibel on average.  The third not decoded are
the weakest in each slot; a mature decoder subtracts what it has decoded and
looks again in the remainder, and does ordered-statistics decoding where
belief propagation fails, and neither is built here.  What is here decodes
nothing that other receivers did not also hear, which is the property that
matters in a log.  Ten whole codewords lifted off the air are in the tests as
a permanent fixture, so the recordings can go missing and the regression
cannot.

Three things that looked like bugs and were not, and three that were.  The
half-second timing discrepancy was the convention: a transmission is 12.64
seconds in a slot of fifteen and everybody starts half a second in, so
lateness is reported against that.  Synthetic signals at known offsets proved
the clock self-consistent before anything was changed.  The signal reports
were twenty-one decibels optimistic because those recordings have a receiver
passband above three kilohertz, putting a whole-band median twelve to sixteen
decibels below the real noise floor -- so noise is now measured beside the
signal, and in the tone that was actually sent rather than the loudest of
eight, the largest of eight noisy numbers being well above their mean even
with no signal at all.  And the test transmitter was thirteen decibels
pessimistic, scaling its noise into a fifty-hertz reference instead of the
sampled bandwidth, which made the decoder look deaf when it was the test
signal that had been quietly attenuated.

The real bug the simulator caught was a one-block timestamp error: samples
were dated a block earlier than they were taken, which slid every slot slice a
second late and cut the first half-second -- three symbols, part of the
opening Costas array -- off every transmission on the band.  One decode a slot
became six.

Reachable both ways, as everything here is.  Twenty-one options, every one of
them a command-line flag and a line in the menu, both built from one table so
they cannot disagree -- and a test that says so, since an option in no group
would be settable from the command line and invisible in the menu.  The band
list says which channels a plain dongle can reach and which need an
upconverter, because almost all the activity is on shortwave and finding that
out by listening to silence for ten minutes is the wrong way to learn it.  The
default is two metres, which a plain dongle can hear.

--grid turns decodes into geography: every CQ says where it is, so each gets a
distance and a bearing and the furthest is named.  --adif writes the log in
the form every amateur logging program imports, marked as heard rather than
worked, because nothing here transmits and an ADIF that let a logging program
treat these as contacts would put claims into somebody's log that they cannot
make.

One bug shipped and found by being used rather than by being tested: the
line that opens the receiver called a function this program has never had.
Every test reached it through the simulator, which takes the other branch, so
the one line that matters to somebody with an aerial was the one line never
run.  There is now a check that every name these modules import actually
exists -- it names the missing one rather than failing somewhere downstream --
and two that say a receiver which cannot be opened is reported rather than
raised, and that nothing claims to be listening before there is one.  It had
been announcing the frequency first, so a dongle that would not open read as
listening that had gone wrong.

Ninety-six new tests.  Full suite 2781 passed.  Built as 2026-09-21_04.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-21 16:09:57 -07:00
The Dust Council
e203b3e581 Draw the map's missing squares as gaps, and its rings in your own units
Two faults reported from in front of the window, both of them the map saying
something confidently and wrongly.

Tiles that did not arrive.  Resizing the window does not refetch the map: the
window deliberately fetches more world than it shows, so a resize still fits
inside what is in hand and gets stretched to the new size.  It refetches when
the view leaves that box, which is what a new station does -- and that fetch
asks a volunteer-funded server for a hundred tiles that have never been on
this disk, all at once, at the sharper zoom the bigger window chose.  Some of
them are refused.

What the window did with a refusal was draw it.  A tile that never arrived
leaves its square of the canvas black, and black is not a neutral colour
here: the brightness is inverted on the way in, because a printed map is ink
on paper and this picture is the other way round.  So the darkest possible
square came out as the brightest thing on the picture, a glowing rectangle
where the map should be.  Measured on a reproduction, one missing tile in
eighteen put thirteen thousand pixels at full brightness -- and dragged the
floor of the map's own contrast down to black with it, so thirteen thousand
four hundred and ninety pixels changed in all: the whole map was redrawn
dimmer to make room for a square that was not there.  Then it was kept, cached
under the view it was fetched for, until the view moved again.

So the missing squares are asked for again at once, and only those, the rest
being on the disk by then; what is still missing is drawn as bare ground and
left out of the reckoning when the darkest and brightest of the map are worked
out, which puts the same reproduction at two pixels changed rather than
thirteen thousand four hundred and ninety, a hairline where a cell is averaged
over part of a tile and part of nothing; and the map is kept as provisional
rather than as the last word, asked for again half a minute later, four
attempts in all, each retrying its own misses once.

Found while measuring that: the politeness pause between requests was being
paid on every tile, including the ones read straight back off the disk.  Two
hundred and twenty tiles at an eighth of a second is twenty-six seconds of
sleeping to redraw a view that was entirely cached, and it would have made
asking again for three missing squares cost the wait for the two hundred that
were not.  The constant's own comment already said it should only be paid on a
tile that was not already there.  Now it is.

The APRS map's units.  Setting imperial changed nothing at all about the
window: the unit it measures in was hardcoded to kilometres, and that one
value drives the ring labels and the scale along the bottom; and --radius was
always read as kilometres, so the rings were not merely mislabelled, they were
at the wrong distance from the flag.  A ring is what a distance gets judged
against by eye, and one labelled in a unit it was not drawn in is a wrong
answer given confidently.  The aircraft side has done this properly all along
-- a radius read in whatever unit the speeds are in, and no unit suffix on the
setting because the suffix belongs to the other setting -- so this now mirrors
it exactly.  At --radius 100 in imperial the outermost ring stands seventy-
five statute miles from the flag and says so, where it used to stand seventy-
five kilometres and say kilometres whatever you had asked for.

Twenty-one new tests against sixteen deliberately broken builds.  One
survived, and removing what it broke was the right answer rather than
strengthening a test: a check that the remembered request still matched the
map in hand could not be made to fail, a request for a different view being
taken up only after the slot it guards is already full.  The tests do not
trust the drawing to mark its own homework -- the one that matters walks north
from the flag by each ring's radius and measures the great-circle distance
with a haversine written in the test, then checks that against the printed
label.  Full suite 2685 passed.  Built as 2026-09-21_03.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-21 02:36:43 -07:00
The Dust Council
e05b66ec3d Tell the APRS map which band it is on, and where you are standing
Three things wrong with the window, all reported from the chair in front of
it, and all three the same kind of wrong: the map had been handed the aircraft
map's furniture and nobody had checked which bits of it were about aircraft.

The empty screen said "listening on 1090 MHz" and went on to explain that an
aeroplane is placed once an even and an odd position frame have both arrived.
That sentence was true of the window it was written for and false of every
word in this one.  What has to arrive before a mark can be placed is a fact
about the signal rather than about the window, so it is now a thing the window
is told: an aeroplane still says what an aeroplane needs, a station says that
most of them mention where they are every few minutes, and left unsaid the
words follow whatever channel the window was given rather than naming 1090
from memory.

There was no red flag.  The flag is drawn where the receiver was actually told
it is, and this section has its own --at that had never been filled in -- but
the aircraft section's had, by the same person, about the same aerial, on the
same roof.  One aerial does not move because the receiver was pointed at a
different band, so a position set on either side now serves both, this
section's own winning where it has one because two receivers in two places is
exactly what a separate setting is for.  It says out loud which it used, an
inherited position being a convenience right up until somebody has moved and
changed only one of them.  The flag, the range rings and every distance and
bearing all come from the same answer, so all four arrive together.

The boxes drew a station's position and never wrote it down.  A mark on a map
shows where something is; the figures are what gets read out over the air,
copied into a log or typed into something else.  They are also the only place
the doubt shows: a station that blanks its minutes is somewhere inside a
two-degree square, the diamond is as definite there as it is anywhere, and
only "49.0000N 72.0000W +/-340 km" admits it.

Twelve new tests against six deliberately broken builds.  One survived, and
it is the one that matters: with the drawing code changed back to print
aeroplanes at 1090 MHz, every test still passed, because they all read the
sentence off the object and none of them read the pixels.  That is the
reported fault exactly -- a window that holds one sentence and paints another
-- and the suite could not see it.  It reads the picture now, cropped below
the header, whose ticking clock has made a test pass for the wrong reason
twice already this session.  Full suite 2664 passed.  Built as
2026-09-21_02.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-21 01:05:11 -07:00
The Dust Council
2c7b1ba25e Put APRS stations on the live map, where they stay
The same window the aircraft use, given marks that are not aeroplanes.  It
already knew how to fetch a map, place an information box where it covers
nothing, glide it when its owner moves, draw range rings, put a flag where
the aerial stands and a scale along the bottom -- and not one of those has
anything to do with aviation.  What it did not know is that a mark might not
fade, might not point anywhere, and might be coloured by what it is rather
than by how high it is.  Those are three hooks rather than a second window,
and the aircraft map is untouched: every one of them defaults to exactly what
an aeroplane does.

Nothing fades, which is the difference asked for and the right one.  An
aeroplane that stops transmitting has flown out of range, and drawing it an
hour later where it was would be drawing something that is certainly not
there.  A fixed amateur station that stops transmitting is still exactly
where it was -- it beacons every half hour, and the gaps are silence rather
than absence.  So the picture accumulates and an evening of listening fills a
map.  Marks are ordered most-recently-heard first, because that is the order
the boxes are laid out in and an accumulating map has more marks on it than
it has room for boxes.

Marks are drawn by what they are: something moving as a body with a stalk
pointing where it is going, and anything fixed as a diamond, which is the one
shape on the picture with no front -- a house that beacons twice an hour is a
place, and a triangle would have it pointing north for no reason.  Colours
come off the altitude ramp, not because a station has an altitude but because
that ramp is the one set of colours all five themes define: warm to cold on
the default map, dim to bright on the phosphor ones, so a digipeater stays
distinguishable from a car everywhere without a colour being named here.

The box says what the station is, how far off and in which bearing, what it
is doing if it is moving, its altitude, its weather, its status, the
digipeaters it came through, how many packets and how many of those arrived
directly, and how strongly.  The heading prints the callsign once: an
aeroplane has two names and the heading was built for that.

Reachable both ways, as everything here is -- --window on the command line, w
in the menu -- with the same eight map settings the aircraft side has.

Forty new tests against nine deliberately broken builds.  Two of them survived
the first attempt, and both for the same reason: they asked whether the
rendered frames differed rather than whether the symbol did, and the strip
along the top carries a running clock, so two frames taken a millisecond apart
differ by a few hundred pixels whatever is on the map.  They now crop to the
mark.  That is the second time this session that a ticking header has made a
test pass for the wrong reason.  Full suite 2652 passed.  Built as
2026-09-21_01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-21 00:12:10 -07:00
The Dust Council
14ba77da9a Put the APRS channel on the front page, and find it for you
The region was in the menu, in the receiver group, between the sample rate and
the invented channel.  That is the wrong place for it.  It is the setting that
decides whether anything is heard at all, and being on the wrong one sounds
exactly like having no aerial, so it does not belong a level down among the
things that make a working receiver work slightly better.

It is now on the front page of the APRS menu, named as well as numbered, and
so is the Listen line: "north-america 144.39 MHz" rather than "144.39 MHz",
because the number alone does not say whether it is the right one and the name
alone does not say what will be tuned.  A channel that is no region's says so
rather than claiming one.

The region and the frequency are separate settings -- somebody may want a
local packet network on neither -- which means they can be made to disagree.
Every place that chooses a region now goes through one function, so they
cannot.

And there is a search.  `bandsaunter aprs --find-channel`, or f in the menu,
listens on each region's channel in turn and prints what was on each, then
offers to use the busiest.  This answers the one question about APRS that
cannot be answered on any single frequency, because the answer *is* a
frequency: somebody who has just plugged a dongle in cannot tell a wrong
channel from a dead aerial, and that is worth a minute of listening rather
than an evening of doubt.

What it does not do is claim more than it found.  A quiet channel is not proof
of an empty one -- a fixed station beacons every half hour -- so what it finds
is traffic, and when every channel comes back silent it says that this is not
the same as an empty band and points at the aerial instead.

The invented channel now honours tuning, and only carries its stations on its
own frequency.  Without that the search would pass on a simulated band having
never searched anything, which is the kind of test that is worse than none.

Sixteen new tests against six deliberately broken builds.  The three that
searched a whole simulated band were doing the same expensive thing three
times over and now build their results directly, leaving one real end-to-end
search; that file went from nine and a half minutes to two.  Full suite 2612
passed.  Built as 2026-09-20_03.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-20 20:39:45 -07:00
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
50 changed files with 25857 additions and 227 deletions

View file

@ -9,8 +9,11 @@ If you are on Debian, Ubuntu or Mint, [build the package](#a-debian-ubuntu-mint-
[a virtual environment](#b-anywhere-else-a-virtual-environment).
**You can try the whole program before buying or plugging in a receiver.**
`--simulate` runs the scanner against a synthetic band, and `bandsaunter adsb
--simulate` flies imaginary aircraft past an imaginary receiver. Neither needs
`--simulate` runs the scanner against a synthetic band, `bandsaunter adsb
--simulate` flies imaginary aircraft past an imaginary receiver, `bandsaunter
weather --simulate` puts six weather sensors on a fence that does not exist,
and `bandsaunter aprs --simulate` fills a channel with amateur stations that
are not there. None of the four needs
hardware or a network.
---
@ -35,7 +38,7 @@ a Raspberry Pi is the easier answer.
## A. Debian, Ubuntu, Mint: the package
```bash
git clone <the repository> bandsaunter
git clone https://frostwarning.com/git/dustcouncil/bandsaunter
cd bandsaunter
./packaging/build-deb.sh # writes dist/bandsaunter_<version>_all.deb
sudo apt install ./dist/bandsaunter_*.deb
@ -80,7 +83,7 @@ sudo apt install librtlsdr0 # Debian/Ubuntu/Mint
# sudo pacman -S rtl-sdr # Arch
# brew install librtlsdr # macOS
git clone <the repository> bandsaunter
git clone https://frostwarning.com/git/dustcouncil/bandsaunter
cd bandsaunter
python3 -m venv .venv
. .venv/bin/activate
@ -116,6 +119,10 @@ bandsaunter bands # the built-in US band plan; no hardware need
bandsaunter scan -b 2m --simulate # a whole scan against a synthetic band
bandsaunter adsb --simulate --seconds 30
bandsaunter flights # draws what the last command heard
bandsaunter weather --simulate --seconds 60
bandsaunter readings --csv # turns what it heard into a spreadsheet
bandsaunter aprs --simulate --seconds 120
bandsaunter packets --kml # turns what it heard into a map
```
With a receiver plugged in:
@ -205,6 +212,33 @@ package. `python3-pyqt6` on Debian and Fedora, `python-pyqt6` on Arch, or
`pip install PyQt6` in the environment bandsaunter runs from. Without it the
window is the only thing missing, and the program says so rather than failing.
**The weather sensors need nothing at all** beyond what is already in the
table above — no network, no extra package, no key. They are a few milliwatts
on 433.92 MHz and the only thing that decides whether they are heard is the
aerial: a quarter-wave whip is 17 cm, which the stock telescopic aerial does
if it is collapsed to about that. `bandsaunter weather --simulate` runs the
whole thing without one.
It listens at 250 kS/s, tuned straight at 433.92 MHz, with the RTL2832's
digital gain control left off — the same configuration the established tools
for this band use, because differing from it turned out to buy nothing.
If sensors you know are in range are not appearing, `bandsaunter weather
--diagnose` prints each second of band taken apart stage by stage and says
which of five possible faults it is — nothing arriving, nothing above the
noise, something never keyed, a burst that framed as nothing, or a message
that framed and arrived only once. The README section on it explains how to
read the output. `--save-iq FILE` keeps the raw samples (2 MB a second, so
bound it with `--seconds 60`) and `--from-iq FILE` reads one back, so a
recording made where the aerial is can be worked on anywhere.
**APRS needs nothing extra either** — no network, no key, no package beyond
the table above. The only thing that decides whether you hear it is the aerial
and whether a digipeater is in range: a quarter-wave whip for 144 MHz is 49 cm,
which is longer than the one most dongles ship with. Check `--region` before
anything else, because on the wrong channel there is silence rather than a bad
signal. `bandsaunter aprs --simulate` runs the whole thing without an aerial.
**Aircraft and callsign lookups need no installation**, only a network. They
ask public registers about a callsign or a 24-bit address and cache the
answers for a month; `--no-lookup` turns them off, and what the address and

1282
README.md

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,8 @@ and transcribing speech.
# Versions are the release date and a revision within that day, so
# 2026-08-21_02 is the second build made on the 21st. The revision is padded
# to two digits so versions sort as text.
VERSION_DATE = "2026-09-06"
VERSION_REVISION = 3
VERSION_DATE = "2026-09-24"
VERSION_REVISION = 1
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

1937
bandsaunter/acurite.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -28,7 +28,7 @@ from .adsb import ADSB_HZ, SAMPLE_RATE
from .flightlog import read_position
from .settings import Setting, format_value
__all__ = ["AircraftOptions", "OPTIONS", "listen", "watch", "draw",
__all__ = ["AircraftOptions", "OPTIONS", "defaults", "listen", "watch", "draw",
"logs_in", "open_device", "open_log", "pump", "finish",
"windowed",
"format_option",
@ -122,6 +122,11 @@ class AircraftOptions:
window_rings: bool = True
theme: str = "night"
box_opacity: int = 85
pulse: bool = True
pulse_rate: float = 2.2
echo: bool = True
echo_every: float = 3.0
echo_size: int = 46
map_brightness: int = 70
tile_url: str = ""
@ -452,9 +457,75 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
flags=("--speed-unit",), metavar="UNIT",
guidance="knots is what aviation uses and what the aircraft actually "
"said. mph or kph if that is what means something to you."),
O("pulse", "Pulsing aircraft", "Effects", "bool",
"swell each aircraft from bright to dim and back, over and over",
"At the top of the swell an aeroplane burns: it is drawn in the peak "
"colours with a halo grown out of its own shape, which is what a "
"phosphor does when the beam sits in one place a little too long. At "
"the bottom it is merely there. Each aircraft is offset by its own "
"address so that a sky full of them swells separately rather than "
"beating as one, which would read as a display flashing rather than "
"as a lot of separate things transmitting. Only aircraft still being "
"heard pulse: one that has gone quiet is fading, and a thing that is "
"fading and beating at once says two contradictory things about "
"itself.",
flags=("--pulse",), off_flags=("--no-pulse",),
guidance="Turn it off for a still picture, or if a moving screen in "
"the corner of the room is a distraction."),
O("pulse_rate", "Pulse takes", "Effects", "float",
"how long one swell from dim to bright and back again takes",
"Seconds of watching rather than of flying, so a pulse looks the "
"same whatever speed an evening is being run through. Long enough "
"and it is a slow breath; short enough and it is a blink, which is "
"the one thing it should not be.",
unit="s", minimum=0.2, maximum=30.0, flags=("--pulse-rate",),
metavar="SECONDS", example="2.2",
guidance="Two or three seconds reads as breathing. Under one is a "
"strobe."),
O("echo", "Echo circles", "Effects", "bool",
"rings travelling outward from each aircraft, growing and dimming",
"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.",
flags=("--echo",), off_flags=("--no-echo",),
guidance="Handsome on a quiet sky. Turn it off when a hundred "
"aircraft are overhead."),
O("echo_every", "Echo every", "Effects", "float",
"how long between one ring leaving an aircraft and the next",
"Also how long a ring takes to cross its whole reach, since there is "
"only ever one in flight at a time. Seconds of watching rather than "
"of flying.",
unit="s", minimum=0.3, maximum=60.0, flags=("--echo-every",),
metavar="SECONDS", example="3",
guidance="Longer for a calmer picture, shorter for a busier one."),
O("echo_size", "Echo reaches", "Effects", "int",
"how far a ring gets from its aircraft before it has faded away",
"In pixels rather than miles, because it is a mark on a picture "
"rather than a distance in the sky: a ring measured in miles would "
"be a claim about range, and this is not one. Bigger than the space "
"between two aircraft and the rings cross each other; smaller than "
"the aeroplane and there is nothing to see.",
unit="px", minimum=6, maximum=400, flags=("--echo-size",),
metavar="PIXELS", example="46",
guidance="About forty on a nine-hundred-pixel picture. Scale it up "
"with the width."),
)
OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map", "Labels")
OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map",
"Labels", "Effects")
def defaults() -> AircraftOptions:
"""A fresh set, for showing what has been changed from it.
Named the same as the weather section's, so that the menus can show
either without knowing which they are showing.
"""
return AircraftOptions()
def in_group(group: str) -> list[Setting]:
@ -768,7 +839,10 @@ def watch(console, options: AircraftOptions, output_dir: str,
fade=max(0.0, options.fade),
airports=options.airports,
rings=options.window_rings,
box_opacity=max(0, options.box_opacity) / 100.0)
box_opacity=max(0, options.box_opacity) / 100.0,
pulse=options.pulse_rate if options.pulse else 0.0,
echo=options.echo_every if options.echo else 0.0,
echo_reach=max(1, options.echo_size))
sky.started = started
sky.log_name = log.path.name if log is not None else ""
if options.simulate:
@ -980,6 +1054,9 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
airports=options.airports,
rings=options.rings,
box_opacity=max(0, options.box_opacity) / 100.0,
pulse=options.pulse_rate if options.pulse else 0.0,
echo=options.echo_every if options.echo else 0.0,
echo_reach=max(1, options.echo_size),
radius_nm=radius_in_nm(options),
centre=read_position(options.location))
except (OSError, RuntimeError, ValueError) as exc:

1724
bandsaunter/aprs.py Normal file

File diff suppressed because it is too large Load diff

345
bandsaunter/aprslog.py Normal file
View file

@ -0,0 +1,345 @@
"""Writing down what came over the channel, and reading it back.
One line of JSON per packet, written the moment it arrives. Flushed after
every one, for the reason every log in this program is: a listening session
ends when the operator gets bored and presses control-C, and a log that only
reached the disk on a clean shutdown would be empty exactly when it was most
wanted.
Each line keeps the whole AX.25 frame in hexadecimal alongside whatever was
made of it. APRS has a long tail of formats -- about twenty in the
specification and more that one manufacturer invented once -- so a packet
this version cannot read is still on the disk in full, and a later version
that can read it can go back through old logs and do so. That is not a
hypothetical: the reason the frame is kept is that the list of formats is
still growing.
Out of it come two other things. A spreadsheet, because a track and a
temperature are both columns of numbers people want to plot; and a KML file,
because the natural thing to do with a hundred positions is to look at them
on a globe.
"""
from __future__ import annotations
import csv
import json
import time
from datetime import datetime
from pathlib import Path
from .acurite import Measure
from .packets import Message, Packet, Position, Telemetry
__all__ = ["AprsLog", "read_logs", "write_csv", "write_kml", "logs_in",
"LOG_VERSION"]
LOG_VERSION = 1
class AprsLog:
"""A JSON Lines record of every packet heard, written as it arrives."""
def __init__(self, path, receiver: str = "", frequency: float = 0.0,
sample_rate: float = 0.0, started: float = 0.0):
self.path = Path(path)
self.packets = 0
self.started = started or time.time()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._file = self.path.open("a", encoding="utf8")
self._write({"log": "bandsaunter-aprs", "version": LOG_VERSION,
"started": round(self.started, 3),
"started_local": datetime.fromtimestamp(
self.started).strftime("%Y-%m-%d %H:%M:%S"),
"frequency": frequency, "sample_rate": sample_rate,
"receiver": receiver})
def _write(self, body: dict) -> None:
self._file.write(json.dumps(body, separators=(",", ":"),
ensure_ascii=False) + "\n")
self._file.flush()
def append(self, packet: Packet, frame=None) -> None:
"""Record one packet: what arrived, and what was made of it."""
body: dict = {"t": round(packet.at or time.time(), 3),
"src": packet.source, "dst": packet.destination,
"kind": packet.kind, "info": packet.info}
if packet.path:
body["path"] = list(packet.path)
if frame is not None and frame.raw:
# The frame is the evidence; everything else in the line is an
# opinion about it, and the opinions may improve later.
body["hex"] = frame.raw.hex().upper()
if packet.snr:
body["snr"] = round(packet.snr, 1)
if packet.reported:
body["reported"] = round(packet.reported, 3)
if packet.position is not None:
body["lat"] = round(packet.position.latitude, 6)
body["lon"] = round(packet.position.longitude, 6)
body["sym"] = packet.position.table + packet.position.code
if packet.position.ambiguity:
body["vague"] = packet.position.ambiguity
for name in ("course", "speed", "altitude", "range", "power",
"height", "gain"):
value = getattr(packet, name)
if value is not None:
body[name] = round(float(value), 3)
for name in ("name", "status", "comment", "beam"):
value = getattr(packet, name)
if value:
body[name] = value
if not packet.live:
body["killed"] = True
if packet.weather:
body["wx"] = {n: [m.value, m.unit]
for n, m in packet.weather.items()}
if packet.message is not None:
body["msg"] = {k: v for k, v in vars(packet.message).items() if v}
if packet.telemetry is not None:
body["tlm"] = {"seq": packet.telemetry.sequence,
"a": list(packet.telemetry.analogue),
"d": packet.telemetry.digital}
self.packets += 1
self._write(body)
def close(self) -> None:
try:
self._file.close()
except OSError:
pass
def __enter__(self) -> "AprsLog":
return self
def __exit__(self, *exc) -> None:
self.close()
# ---------------------------------------------------------------------------
# Reading it back
# ---------------------------------------------------------------------------
def logs_in(directory) -> list[Path]:
try:
found = list(Path(directory).expanduser().glob("aprs_*.jsonl"))
except OSError:
return []
return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)
def read_logs(paths) -> list[Packet]:
"""Every packet in one or more logs, in the order they were heard.
A line that will not parse is skipped rather than fatal. A log is
appended to while the disk fills and the power goes off, so the last line
of one is quite often half a line.
"""
out: list[Packet] = []
for path in ([paths] if isinstance(paths, (str, Path)) else paths):
try:
text = Path(path).expanduser().read_text(encoding="utf8")
except OSError:
continue
for line in text.splitlines():
packet = _packet_from(line)
if packet is not None:
out.append(packet)
out.sort(key=lambda p: p.at)
return out
def _packet_from(line: str) -> Packet | None:
line = line.strip()
if not line:
return None
try:
body = json.loads(line)
except ValueError:
return None
if not isinstance(body, dict) or "src" not in body:
return None # the header line, or something else entirely
packet = Packet(kind=str(body.get("kind", "unparsed")),
source=str(body.get("src", "")),
destination=str(body.get("dst", "")),
path=tuple(body.get("path") or ()),
at=float(body.get("t", 0.0) or 0.0),
reported=float(body.get("reported", 0.0) or 0.0),
snr=float(body.get("snr", 0.0) or 0.0),
info=str(body.get("info", "")))
if "lat" in body and "lon" in body:
symbol = str(body.get("sym", "/-"))
packet.position = Position(latitude=float(body["lat"]),
longitude=float(body["lon"]),
ambiguity=int(body.get("vague", 0) or 0),
table=symbol[:1] or "/",
code=symbol[1:2] or "-")
for name in ("course", "speed", "altitude", "range", "power", "height",
"gain"):
if name in body:
setattr(packet, name, float(body[name]))
for name in ("name", "status", "comment", "beam"):
if name in body:
setattr(packet, name, str(body[name]))
packet.live = not body.get("killed", False)
for name, value in (body.get("wx") or {}).items():
if isinstance(value, list) and value:
packet.weather[name] = Measure(
name, float(value[0]), str(value[1]) if len(value) > 1 else "")
if body.get("msg"):
packet.message = Message(**{k: str(v)
for k, v in body["msg"].items()
if k in vars(Message())})
if body.get("tlm"):
told = body["tlm"]
packet.telemetry = Telemetry(sequence=str(told.get("seq", "")),
analogue=tuple(told.get("a") or ()),
digital=str(told.get("d", "")))
return packet
# ---------------------------------------------------------------------------
# Out to a spreadsheet
# ---------------------------------------------------------------------------
_IMPERIAL = {"C": "F", "km/h": "mph", "mm": "in", "km": "mi", "m": "ft"}
def write_csv(path, heard, imperial: bool = False) -> Path:
"""A row per packet, with what it said in columns.
The union of every weather quantity anything reported, so a channel with
one weather station on it has temperature and pressure columns and every
car leaves them empty. That is the shape a spreadsheet wants.
"""
path = Path(path).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
quantities: list[str] = []
for packet in heard:
for name in packet.weather:
if name not in quantities:
quantities.append(name)
speed_unit = "mph" if imperial else "km/h"
height_unit = "ft" if imperial else "m"
heads = ["time", "unix", "station", "source", "destination", "path",
"kind", "latitude", "longitude", "symbol", "course",
f"speed ({speed_unit})", f"altitude ({height_unit})",
"signal (dB)", "status", "comment"] \
+ [_column(name, heard, imperial) for name in quantities]
with open(path, "w", encoding="utf8", newline="") as fh:
out = csv.writer(fh)
out.writerow(heads)
for packet in heard:
place = packet.position
out.writerow([
datetime.fromtimestamp(packet.at).isoformat(timespec="seconds")
if packet.at else "",
f"{packet.at:.3f}" if packet.at else "",
packet.station, packet.source, packet.destination,
",".join(packet.path), packet.kind,
f"{place.latitude:.6f}" if place else "",
f"{place.longitude:.6f}" if place else "",
(place.table + place.code) if place else "",
f"{packet.course:.0f}" if packet.course is not None else "",
_shown(packet.speed, "km/h", imperial),
_shown(packet.altitude, "m", imperial),
f"{packet.snr:.1f}" if packet.snr else "",
packet.status, packet.comment]
+ [_shown(packet.weather[name].value,
packet.weather[name].unit, imperial)
if name in packet.weather else ""
for name in quantities])
return path
def _column(name: str, heard, imperial: bool) -> str:
unit = ""
for packet in heard:
if name in packet.weather and packet.weather[name].unit:
unit = packet.weather[name].unit
break
if imperial:
unit = _IMPERIAL.get(unit, unit)
return f"{name} ({unit})" if unit else name
def _shown(value, unit: str, imperial: bool) -> str:
"""One number, in whichever system was asked for, as a plain figure.
Plain because a column of "21.5 C" is text and a column of 21.5 is a
temperature, and only one of those can be plotted.
"""
if value is None:
return ""
if imperial:
if unit == "C":
value = value * 9 / 5 + 32
elif unit in ("km/h", "km"):
value = value / 1.609344
elif unit == "mm":
value = value / 25.4
elif unit == "m":
value = value / 0.3048
return str(round(float(value), 3))
# ---------------------------------------------------------------------------
# Out to a globe
# ---------------------------------------------------------------------------
def write_kml(path, heard, imperial: bool = False,
title: str = "bandsaunter — APRS stations") -> Path | None:
"""A pin where each station was last heard, and a line where it moved.
Only the stations that said where they were, because a pin at nowhere is
worse than no pin: it puts a station off the west coast of Africa, which
is where nought degrees by nought degrees is and is the reason that bug
has a name.
"""
from xml.sax.saxutils import escape as xml_escape
tracks: dict[str, list] = {}
latest: dict[str, object] = {}
for packet in heard:
if packet.position is None:
continue
where = (packet.position.longitude, packet.position.latitude,
packet.altitude or 0.0)
line = tracks.setdefault(packet.station, [])
if not line or line[-1][:2] != where[:2]:
line.append(where)
latest[packet.station] = packet
if not latest:
return None
path = Path(path).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<kml xmlns="http://www.opengis.net/kml/2.2">', " <Document>",
f" <name>{xml_escape(title)}</name>",
' <Style id="track"><LineStyle><color>ff20a0ff</color>'
"<width>2</width></LineStyle></Style>"]
for call, line in sorted(tracks.items()):
packet = latest[call]
told = xml_escape(packet.describe(imperial))
name = xml_escape(call)
if len(line) > 1:
where = " ".join(f"{lon:.6f},{lat:.6f},{alt:.0f}"
for lon, lat, alt in line)
out += [" <Placemark>", f" <name>{name}</name>",
f" <description>{told}</description>",
" <styleUrl>#track</styleUrl>",
" <LineString><tessellate>1</tessellate>",
f" <coordinates>{where}</coordinates>",
" </LineString>", " </Placemark>"]
last = line[-1]
out += [" <Placemark>", f" <name>{name}</name>",
f" <description>{told}</description>",
" <Point><coordinates>"
f"{last[0]:.6f},{last[1]:.6f},{last[2]:.0f}"
"</coordinates></Point>", " </Placemark>"]
out += [" </Document>", "</kml>", ""]
path.write_text("\n".join(out), encoding="utf8")
return path

524
bandsaunter/ax25.py Normal file
View file

@ -0,0 +1,524 @@
"""AX.25 over the air: the frames APRS is carried in, and how to recover them.
Amateur packet radio sends data as HDLC frames over a carrier that is simply
switched between two audio tones inside an ordinary FM transmission -- 1200 Hz
for a mark and 2200 Hz for a space, twelve hundred of them a second, which is
Bell 202 and is what a telephone modem sounded like in 1976. It has stayed
because it works through any FM radio ever made, and because every handheld in
a rucksack is already an AFSK transmitter with a microphone socket.
Four things happen between the aerial and a frame, and each is a place to get
it wrong.
**The tones become a soft symbol.** Two correlators, one at each tone, and
the difference between them. A correlator rather than a frequency
discriminator because the tones are less than an octave apart and radio audio
is distorted enough that instantaneous frequency wanders badly; asking which
of the two tones a bit-length window contains more of is a question that
survives a weak signal.
**The soft symbol becomes bits.** Sampled once a bit, at an instant kept in
the middle of the bit by a phase-locked loop that is nudged at every zero
crossing. The loop is what makes this a receiver rather than a decoder of
recordings: it carries its phase from one block of audio to the next, so a
frame that straddles the boundary is read straight through.
**The bits become a frame.** NRZI first -- the data is in whether the tone
changed, not which tone it is, which makes the whole thing immune to being
wired up backwards. Then HDLC: frames are delimited by the flag 01111110 and
a zero is stuffed after every five ones so the flag cannot occur inside one.
**The frame is believed or it is not.** Sixteen bits of CRC, and nothing
without a correct one is reported. That is what makes it safe to run this
over hours of an open squelch: a frame either checks out or it never existed.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
import numpy as np
__all__ = ["Address", "Frame", "Receiver", "fcs", "frame_from", "frame_bytes",
"hdlc_frames", "stuff", "unstuff", "modulate", "nrzi", "un_nrzi",
"MARK_HZ", "SPACE_HZ", "BAUD", "FLAG", "APRS_HZ", "APRS_CHANNELS",
"UI_CONTROL", "NO_LAYER_3", "MAX_FRAME"]
# Bell 202, as every VHF packet station on earth sends it.
MARK_HZ = 1200.0
SPACE_HZ = 2200.0
BAUD = 1200.0
FLAG = "01111110"
# Where APRS lives. One channel per region by agreement rather than by
# regulation, which is why there is a list of them rather than a number.
APRS_HZ = 144_390_000.0
APRS_CHANNELS = (
("north-america", 144_390_000.0, "United States, Canada, Mexico"),
("europe", 144_800_000.0, "IARU Region 1, including the UK"),
("australia", 145_175_000.0, "Australia and New Zealand"),
("japan", 144_640_000.0, "Japan"),
("brazil", 145_570_000.0, "Brazil"),
("thailand", 145_525_000.0, "Thailand"),
)
# An unnumbered information frame with no layer-3 protocol, which is what
# every APRS packet is and very nearly all this will ever see.
UI_CONTROL = 0x03
NO_LAYER_3 = 0xF0
# The longest thing worth believing: eight two-byte-addressed hops, a control
# and protocol byte, and 256 bytes of information.
MAX_FRAME = 8 * 7 + 2 + 2 + 256 + 2
# ---------------------------------------------------------------------------
# What a frame is made of
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Address:
"""One callsign in a frame's path, with everything packed around it.
An AX.25 address is six characters and four bits, and the other four bits
of the last byte carry the parts that matter here: which station this is
when a callsign is not unique -- the SSID, the number after the dash --
and whether a digipeater has already repeated the frame.
"""
call: str = ""
ssid: int = 0
repeated: bool = False # the H bit: a digipeater has used this hop
reserved: int = 0b11 # the two bits nobody uses
command: bool = False # the C bit, meaningful only on the first two
def __str__(self) -> str:
out = f"{self.call}-{self.ssid}" if self.ssid else self.call
return out + "*" if self.repeated else out
@property
def plain(self) -> str:
"""Without the asterisk, for looking a station up."""
return f"{self.call}-{self.ssid}" if self.ssid else self.call
def address_from(raw: bytes) -> Address:
"""Seven bytes into a callsign. Everything is shifted up by one bit."""
call = "".join(chr(byte >> 1) for byte in raw[:6]).rstrip()
last = raw[6]
return Address(call=call, ssid=(last >> 1) & 0x0F,
repeated=bool(last & 0x80),
reserved=(last >> 5) & 0x03,
command=bool(last & 0x80))
def address_bytes(address: Address, last: bool = False) -> bytes:
"""One callsign back into seven bytes, ready to send."""
call = (address.call.upper() + " ")[:6]
flags = ((address.ssid & 0x0F) << 1) | (0x60 if address.reserved else 0)
if address.repeated:
flags |= 0x80
return bytes((ord(c) << 1) & 0xFE for c in call) + bytes([flags | int(last)])
@dataclass
class Frame:
"""One AX.25 frame whose frame-check sequence was correct."""
destination: Address = field(default_factory=Address)
source: Address = field(default_factory=Address)
path: tuple = ()
control: int = UI_CONTROL
pid: int = NO_LAYER_3
info: bytes = b""
raw: bytes = b""
at: float = 0.0 # when it arrived, as a clock time
snr: float = 0.0 # how far above the noise, in dB
@property
def unnumbered_information(self) -> bool:
"""Whether this is the frame type APRS uses, and nothing else does.
The control byte has its two low bits set on an unnumbered frame, and
the rest of it says which sort. APRS is always UI with no layer 3;
anything else on this channel is somebody running a real AX.25
connection, which is a fair thing to see and not an APRS packet.
"""
return (self.control & 0xEF) == UI_CONTROL and self.pid == NO_LAYER_3
@property
def kind(self) -> str:
"""What sort of AX.25 frame this is, in words."""
if not self.control & 0x01:
return "information"
if (self.control & 0x03) == 0x01:
return {0x00: "receive ready", 0x04: "receive not ready",
0x08: "reject", 0x0C: "selective reject"}.get(
self.control & 0x0C, "supervisory")
return {0x03: "unnumbered information", 0x2F: "set async balanced",
0x43: "disconnect", 0x0F: "disconnect mode",
0x63: "unnumbered ack", 0x87: "frame reject"}.get(
self.control & 0xEF, "unnumbered")
@property
def heard_through(self) -> tuple:
"""The digipeaters that actually repeated this, in order.
The ones with the H bit set, which is how a station says "I passed
this on". The rest of the path is where it has yet to go.
"""
return tuple(hop for hop in self.path if hop.repeated)
def route(self) -> str:
"""The frame's path the way every APRS tool in the world writes it."""
parts = [self.source.plain, self.destination.plain]
parts += [str(hop) for hop in self.path]
return ">".join(parts[:2]) + ("," + ",".join(parts[2:])
if len(parts) > 2 else "")
def text(self) -> str:
"""The information field as characters, for reading and for parsing."""
return self.info.decode("latin-1")
def describe(self) -> str:
body = self.text()
return f"{self.route()}:{body}" if body else self.route()
# ---------------------------------------------------------------------------
# The check that makes any of this safe to run
# ---------------------------------------------------------------------------
def fcs(data: bytes) -> int:
"""The AX.25 frame check: CRC-16/X.25, reflected, inverted at the end."""
crc = 0xFFFF
for byte in data:
crc ^= byte
for _ in range(8):
crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1
return crc ^ 0xFFFF
def frame_from(raw: bytes) -> Frame | None:
"""One frame out of its bytes, or None if it is not one.
The check is run first and nothing else is looked at until it passes.
Every field below is read on the strength of sixteen bits of CRC saying
the bytes are what was sent.
"""
if len(raw) < 7 * 2 + 2 + 2:
return None
body, check = raw[:-2], raw[-1] << 8 | raw[-2]
if fcs(body) != check:
return None
addresses, at = [], 0
while at + 7 <= len(body) and len(addresses) < 10:
field_ = body[at:at + 7]
addresses.append(address_from(field_))
at += 7
if field_[6] & 0x01: # the end-of-address bit
break
else:
return None
if len(addresses) < 2 or at + 2 > len(body):
return None
if not all(_sane_call(a.call) for a in addresses):
return None
return Frame(destination=addresses[0], source=addresses[1],
path=tuple(addresses[2:]), control=body[at],
pid=body[at + 1], info=body[at + 2:], raw=raw)
def _sane_call(call: str) -> bool:
"""Whether a callsign is one, rather than seven bits of luck.
Sixteen bits of CRC is a strong check and this is a crowded band; a frame
whose addresses are unprintable is one that passed the check by accident,
and there is no reason to put it on a display.
"""
return bool(call) and all(c.isalnum() or c == "-" for c in call) \
and call.isascii() and call.upper() == call
def frame_bytes(source, destination, info: str | bytes = b"",
path=(), control: int = UI_CONTROL,
pid: int = NO_LAYER_3) -> bytes:
"""A complete frame, check included, ready to be keyed out.
Kept beside the decoder so the two cannot drift apart, and so a test can
put a packet in and take the same one out. Callsigns may be given as
strings -- "W1AW-5", "WIDE2-1*" -- or as addresses.
"""
hops = tuple(_as_address(hop) for hop in path)
out = address_bytes(_as_address(destination))
out += address_bytes(_as_address(source), last=not hops)
for i, hop in enumerate(hops):
out += address_bytes(hop, last=(i == len(hops) - 1))
out += bytes([control & 0xFF, pid & 0xFF])
out += info.encode("latin-1") if isinstance(info, str) else bytes(info)
return out + bytes([fcs(out) & 0xFF, (fcs(out) >> 8) & 0xFF])
def _as_address(value) -> Address:
if isinstance(value, Address):
return value
text = str(value).strip().upper()
repeated = text.endswith("*")
text = text.rstrip("*")
call, _, ssid = text.partition("-")
return Address(call=call, ssid=int(ssid) if ssid.isdigit() else 0,
repeated=repeated)
# ---------------------------------------------------------------------------
# HDLC: where one frame ends and the next begins
# ---------------------------------------------------------------------------
def stuff(bits: str) -> str:
"""Insert a zero after every five ones, so no flag can occur inside."""
out, ones = [], 0
for bit in bits:
out.append(bit)
if bit == "1":
ones += 1
if ones == 5:
out.append("0")
ones = 0
else:
ones = 0
return "".join(out)
def unstuff(bits: str) -> str:
"""Take those zeroes back out again."""
out, ones = [], 0
for bit in bits:
if ones == 5:
ones = 0
if bit == "0":
continue # the stuffed bit
out.append(bit)
ones = ones + 1 if bit == "1" else 0
return "".join(out)
def nrzi(bits: str, level: str = "1") -> str:
"""Encode: a zero is sent as a change of tone, a one as no change."""
out = []
for bit in bits:
if bit == "0":
level = "0" if level == "1" else "1"
out.append(level)
return "".join(out)
def un_nrzi(bits: str) -> str:
"""Decode the same, which needs no knowledge of which tone is which.
A one is no change and a zero is a change, so inverting the whole stream
-- swapping mark for space, or wiring a discriminator up backwards --
decodes to exactly the same data. That is the point of the coding and is
why nothing here ever has to guess at polarity.
"""
return "".join("1" if a == b else "0" for a, b in zip(bits, bits[1:]))
def hdlc_frames(bits: str, most: int = 64) -> tuple[list[bytes], int]:
"""Split a bit stream at flags and undo the stuffing.
Returns the frames and how far along the stream was consumed, so a caller
reading a continuous signal knows what it may forget.
"""
frames: list[bytes] = []
at = bits.find(FLAG)
if at < 0:
return frames, max(0, len(bits) - len(FLAG))
used = at
while len(frames) < most:
while bits.startswith(FLAG, at): # flags repeat between frames
at += 8
used = at - 8 # the last flag fully passed
end = bits.find(FLAG, at)
if end < 0:
break # a body still arriving; keep it
body, at = bits[at:end], end
# Everything before this flag has been dealt with. Said here rather
# than at the top of the loop because a caller reading a continuous
# signal trims its buffer by this, and trimming to before a frame
# that has already been reported hands it back again on the next
# block -- every packet counted twice, for ever.
used = end
if len(body) < 8 * 17: # shorter than an empty frame
continue
clean = unstuff(body)
whole = len(clean) - len(clean) % 8
if not 17 <= whole // 8 <= MAX_FRAME:
continue
# Least significant bit first on the air, which is the one thing
# about AX.25 that catches everybody out.
frames.append(bytes(int(clean[i:i + 8][::-1], 2)
for i in range(0, whole, 8)))
return frames, max(used, 0)
# ---------------------------------------------------------------------------
# From audio to frames, without ever stopping
# ---------------------------------------------------------------------------
# How hard the sampling instant is pulled towards the middle of a bit at each
# zero crossing. Low enough that noise cannot drag it about, high enough to
# pull in within a flag or two of the start of a transmission -- which is
# what the flags at the front of every frame are there to allow.
LOOP_GAIN = 0.15
# How much of a bit stream to carry over when no flag has been seen. A frame
# is at most three hundred bytes, so anything older than that has no frame
# in it that has not already been found.
KEEP_BITS = MAX_FRAME * 8 * 2
class Receiver:
"""Audio in, frames out, across as many blocks as you care to feed it.
The state that has to survive a block boundary is the whole point of this
being a class: the tail of the audio, so the correlators see no edge; the
phase of the sampling loop, so a bit is not lost or gained where one
block meets the next; the level the tone was last at, for the NRZI; and
the bits themselves, so a frame that began in one block and ended in
another is read straight through rather than halved.
A packet takes most of a second at twelve hundred baud and blocks are
about that long, so frames straddling a boundary are not an edge case --
they are most of them.
"""
def __init__(self, rate: float, baud: float = BAUD,
mark: float = MARK_HZ, space: float = SPACE_HZ):
self.rate = float(rate)
self.baud = float(baud)
self.window = max(4, int(round(self.rate / self.baud)))
self.frames = 0
self.bytes_seen = 0
turn = 2.0 * math.pi * np.arange(self.window) / self.rate
self._mark = (np.cos(mark * turn), np.sin(mark * turn))
self._space = (np.cos(space * turn), np.sin(space * turn))
self._tail = np.zeros(self.window - 1, dtype=np.float64)
self._phase = 0.0
self._was = 0.0 # the last soft sample, for crossings
self._level = "1" # the tone the line was last at
self._bits = ""
# -- the three stages ------------------------------------------------
def soft(self, audio: np.ndarray) -> np.ndarray:
"""How much more mark than space each moment of audio holds.
Two correlators a bit long, and the difference of their magnitudes.
Positive is a mark. The tail of the previous block is prepended so
that the first bit of this one is measured against real audio rather
than against the zeroes a convolution would otherwise invent.
"""
x = np.concatenate((self._tail, np.asarray(audio, dtype=np.float64)))
if x.size < self.window:
self._tail = x
return np.zeros(0)
self._tail = x[-(self.window - 1):] if self.window > 1 else x[:0]
x = x - x.mean()
out = []
for cosine, sine in (self._mark, self._space):
i = np.convolve(x, cosine[::-1], mode="valid")
q = np.convolve(x, sine[::-1], mode="valid")
out.append(np.hypot(i, q))
return out[0] - out[1]
def slice(self, soft: np.ndarray) -> str:
"""Sample the soft signal once a bit, in the middle of the bit.
The instant is held there by a loop nudged at every zero crossing:
a crossing is a bit boundary, so it should fall half a bit away from
where the last sample was taken, and any difference is an error to
be taken out gently.
"""
step = self.baud / self.rate
phase, was = self._phase, self._was
out = []
for value in soft:
phase += step
if (value > 0.0) != (was > 0.0):
error = phase - 0.5 if phase < 1.0 else phase - 1.5
phase -= error * LOOP_GAIN
if phase >= 1.0:
phase -= 1.0
out.append("1" if value > 0.0 else "0")
was = value
self._phase, self._was = phase, was
return "".join(out)
def feed(self, audio, when: float = 0.0, snr: float = 0.0) -> list[Frame]:
"""One block of audio. Returns whatever frames finished in it."""
soft = self.soft(audio)
if soft.size == 0:
return []
tones = self._level + self.slice(soft)
self._level = tones[-1]
self._bits += un_nrzi(tones)
raw, used = hdlc_frames(self._bits)
self._bits = self._bits[used:][-KEEP_BITS:]
out = []
for data in raw:
self.bytes_seen += len(data)
frame = frame_from(data)
if frame is not None:
frame.at, frame.snr = when, snr
self.frames += 1
out.append(frame)
return out
def reset(self) -> None:
self._tail = np.zeros(self.window - 1, dtype=np.float64)
self._phase, self._was, self._level, self._bits = 0.0, 0.0, "1", ""
# ---------------------------------------------------------------------------
# Keying the same thing out, so the receiver can be held to it
# ---------------------------------------------------------------------------
def bits_of(frame: bytes, flags: int = 8) -> str:
"""One frame as the bits that go on the air: flags, stuffing and all."""
body = "".join(f"{byte:08b}"[::-1] for byte in frame)
return FLAG * flags + stuff(body) + FLAG * flags
def modulate(frames, rate: float = 22_050.0, baud: float = BAUD,
mark: float = MARK_HZ, space: float = SPACE_HZ,
flags: int = 8, amplitude: float = 0.5, noise: float = 0.0,
seed: int = 0, quiet_ms: float = 40.0) -> np.ndarray:
"""What a receiver would hear: the audio, not the radio signal.
The tone is continuous in phase across a bit boundary, because a real
modem's is -- it is one oscillator being retuned, not two being switched
-- and a decoder that only ever saw phase jumps at every bit would be
tested against something nothing transmits.
"""
if isinstance(frames, (bytes, bytearray)):
frames = [frames]
stream = "".join(nrzi(bits_of(bytes(f), flags)) for f in frames)
quiet = int(round(rate * quiet_ms / 1000.0))
per_bit = rate / baud
total = quiet * 2 + int(round(len(stream) * per_bit))
out = np.zeros(total, dtype=np.float64)
phase, at = 0.0, float(quiet)
for bit in stream:
tone = mark if bit == "1" else space
n = int(round(at + per_bit)) - int(round(at))
step = 2.0 * math.pi * tone / rate
out[int(round(at)):int(round(at)) + n] = amplitude * np.sin(
phase + step * np.arange(n))
phase = (phase + step * n) % (2.0 * math.pi)
at += per_bit
if noise:
out = out + np.random.default_rng(seed).normal(0.0, noise, out.size)
return out.astype(np.float32)

View file

@ -39,7 +39,9 @@ from . import __version__
__all__ = ["decode_png", "tile_of", "choose_zoom", "fetch_tile", "mosaic",
"ground_under", "TILE_URL", "ATTRIBUTION", "MAX_TILES", "MAX_ZOOM",
"cache_dir", "PNGError", "airports_in", "AIRPORTS_URL",
"tile_span"]
"tile_span", "on_disk", "ground_cache_dir", "cache_size",
"prune_cache", "forget_ground", "GROUND_CACHE_VERSION",
"GROUND_CACHE_DAYS", "CACHE_LIMIT_MB"]
# The standard OpenStreetMap tiles. Any {z}/{x}/{y} server can be put here
# instead; nothing below knows anything about this one in particular.
@ -64,13 +66,27 @@ OVERSAMPLE = 1.4
MIN_ZOOM = 2
TILE_PIXELS = 256
# What the tile server is told it is talking to. This is not decoration: the
# usage policy of the service the default tiles come from asks for a User-Agent
# that identifies the application and gives somewhere to look it up, so that an
# operator with a question about the traffic has somebody to ask. It pointed
# at a topic listing until the project had an address of its own.
USER_AGENT = (f"bandsaunter/{__version__} "
"(+https://github.com/topics/rtl-sdr; aircraft map drawing)")
f"(+https://frostwarning.com/git/dustcouncil/bandsaunter; offline-cached map tiles for a signal scanner)")
# Politeness between requests to a volunteer-funded service. Only paid on a
# tile that was not already on the disk.
FETCH_PAUSE = 0.12
# What to do about tiles that did not arrive. A server that has just been
# asked for a hundred tiles in a row refuses some of them, and the answer to
# being told to slow down is to slow down and ask again rather than to draw
# the gap. Longer than FETCH_PAUSE because the first pass is what provoked
# it; once, because a tile that fails twice is usually a tile that is not
# there, and the caller asks again later anyway.
RETRY_PAUSE = 0.8
RETRIES = 1
# Where to ask what aerodromes are in a piece of the world. The same OSM
# data the tiles are drawn from, asked as a question rather than a picture.
@ -89,6 +105,20 @@ AIRPORT_CACHE_VERSION = 2
# list of airstrips.
MOST_AIRPORTS = 40
# The finished map, kept as well as the tiles it was built from. The tiles
# are cached already and always have been, so a second evening on the same
# view costs nothing on the network -- but it still costs decoding forty
# PNGs and resampling a megapixel and a half into the picture's own
# projection, every time the window opens, for an answer that cannot have
# changed. A coastline does not move.
GROUND_CACHE_VERSION = 1
GROUND_CACHE_DAYS = 30
# What the whole cache is allowed to grow to. Tiles are small and a map is
# not, so a cache that only ever grew would quietly become the largest thing
# this program had ever written.
CACHE_LIMIT_MB = 400
class PNGError(ValueError):
"""A PNG this decoder cannot read."""
@ -303,12 +333,55 @@ def fetch_tile(zoom: int, x: int, y: int, url: str = TILE_URL,
return body
def on_disk(zoom: int, x: int, y: int, cache: Path | None = None,
**kw) -> bool:
"""Whether this tile has been fetched before.
A read off the disk owes a volunteer-funded server no politeness, and
paying it anyway is how a view whose tiles are all in hand takes half a
minute to redraw: two hundred and twenty tiles at an eighth of a second
each, spent sleeping between files that were already there.
"""
where = (cache if cache is not None else cache_dir()) / str(zoom) / str(x)
try:
return (where / f"{y}.png").exists()
except OSError:
return False
def _one_tile(fetch, zoom: int, tx: int, ty: int, **kw):
"""One decoded tile, or None if it could not be had or made sense of."""
body = fetch(zoom, tx, ty, **kw)
if body is None:
return None
try:
tile = decode_png(body)
except (PNGError, zlib.error, ValueError):
return None
if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS:
return None
return tile
def mosaic(south: float, west: float, north: float, east: float, zoom: int,
fetch=fetch_tile, pause: float = FETCH_PAUSE, **kw):
fetch=fetch_tile, pause: float = FETCH_PAUSE,
retries: int = RETRIES, retry_pause: float = RETRY_PAUSE,
cached=on_disk, **kw):
"""Every tile the box touches, stitched into one image.
Returns the pixels and where their top-left corner sits in the world, in
tile-grid pixels at this zoom, so the resampling below can place them.
Returns the pixels, where their top-left corner sits in the world in
tile-grid pixels at this zoom so the resampling below can place them,
and which tiles actually arrived.
That last one matters because the canvas starts black, and black is not
a neutral colour here: the brightness is inverted further down, so a
tile that never arrived is drawn as the brightest thing on the map
rather than as a gap. Saying which squares are real lets the map be
drawn without them, and lets the caller know the answer is not final.
Tiles that did not arrive are asked for again before the map is given up
on, because the usual reason for a hole is a hundred tiles having been
asked for in the preceding second.
"""
x0, y0 = tile_of(north, west, zoom)
x1, y1 = tile_of(south, east, zoom)
@ -317,32 +390,40 @@ def mosaic(south: float, west: float, north: float, east: float, zoom: int,
span = 2 ** zoom
wide, tall = right - left + 1, bottom - top + 1
if wide <= 0 or tall <= 0 or wide * tall > MAX_TILES * 4:
return None, 0, 0
return None, 0, 0, np.zeros((0, 0), dtype=bool)
canvas = np.zeros((tall * TILE_PIXELS, wide * TILE_PIXELS, 3),
dtype=np.uint8)
got = 0
for row in range(tall):
for column in range(wide):
tx, ty = (left + column) % span, top + row
if not 0 <= ty < span:
continue
body = fetch(zoom, tx, ty, **kw)
if body is None:
continue
try:
tile = decode_png(body)
except (PNGError, zlib.error, ValueError):
continue
if tile.shape[0] != TILE_PIXELS or tile.shape[1] != TILE_PIXELS:
covered = np.zeros((tall, wide), dtype=bool)
# Rows off the top or bottom of the world are left out rather than
# asked for: they are not missing tiles, they are places there is no
# map of, and they stay uncovered so they are drawn as nothing.
todo = [(row, column, (left + column) % span, top + row)
for row in range(tall) for column in range(wide)
if 0 <= top + row < span]
for attempt in range(max(0, retries) + 1):
if not todo:
break
if attempt and retry_pause:
time.sleep(retry_pause)
missed = []
was_here = {(row, column): bool(pause) and cached(zoom, tx, ty, **kw)
for row, column, tx, ty in todo}
for row, column, tx, ty in todo:
tile = _one_tile(fetch, zoom, tx, ty, **kw)
if tile is None:
missed.append((row, column, tx, ty))
continue
canvas[row * TILE_PIXELS:(row + 1) * TILE_PIXELS,
column * TILE_PIXELS:(column + 1) * TILE_PIXELS] = tile
got += 1
if pause:
covered[row, column] = True
# Asked before the fetch rather than after it, because the
# fetch is what puts the tile on the disk.
if pause and not was_here[(row, column)]:
time.sleep(pause)
if not got:
return None, 0, 0
return canvas, left * TILE_PIXELS, top * TILE_PIXELS
todo = missed
if not covered.any():
return None, 0, 0, covered
return canvas, left * TILE_PIXELS, top * TILE_PIXELS, covered
# ---------------------------------------------------------------------------
@ -372,6 +453,150 @@ def _resample(values: np.ndarray, edges: np.ndarray, axis: int) -> np.ndarray:
return (total / counts.reshape(shape)).astype(np.float32)
def ground_cache_dir() -> Path:
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
return Path(root).expanduser() / "bandsaunter" / "ground"
def _ground_key(south, west, north, east, width, height, shades,
zoom) -> str:
"""What makes two requests for the ground the same request.
The box is rounded to three places -- about a hundred metres, which is
finer than a tile and far finer than anything visible -- so that a view
which has drifted by a pixel is answered from the cache rather than
rebuilt. The size is in, because the same box rendered into a bigger
window is a different picture and reusing it would be the blur this
program went to some trouble to avoid.
"""
parts = (f"{round(south, 3):+09.3f}", f"{round(west, 3):+09.3f}",
f"{round(north, 3):+09.3f}", f"{round(east, 3):+09.3f}",
f"{int(width)}x{int(height)}", f"s{int(shades)}",
f"z{'auto' if zoom is None else int(zoom)}")
return "_".join(parts)
def _read_ground(path: Path):
"""A finished map off the disk, or None if there is not a usable one."""
try:
with np.load(path) as body:
if int(body["version"]) != GROUND_CACHE_VERSION:
return None
if time.time() - float(body["at"]) > GROUND_CACHE_DAYS * 86_400:
return None
return body["levels"]
except Exception:
# Deliberately everything. A truncated or half-written file is what
# a cache looks like after a power cut, and numpy reports that
# through whichever of half a dozen exceptions the zip reader
# happened to raise. None of them is an error here: they are all a
# cache miss, and the answer to a cache miss is to build the map.
return None
def _write_ground(path: Path, levels: np.ndarray) -> None:
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(".tmp.npz")
np.savez_compressed(tmp, levels=levels, at=time.time(),
version=GROUND_CACHE_VERSION)
tmp.replace(path)
except (OSError, ValueError):
return # an unwritable cache is not a reason to stop
# And keep the whole thing inside its limit. Done here because this is
# the only place that makes the cache bigger by a megabyte at a time --
# a tile is small and a map is not -- and because a cache nothing ever
# prunes is a disk filling up slowly enough that nobody notices.
try:
prune_cache()
except Exception:
pass
def forget_ground() -> int:
"""Throw away every finished map. The tiles under them are kept."""
gone = 0
try:
for path in ground_cache_dir().glob("*.npz"):
try:
path.unlink()
gone += 1
except OSError:
pass
except OSError:
pass
return gone
def cache_size() -> dict:
"""How much of the disk each cache is using, and how many files.
Worth being able to answer, because everything here grows and nothing
here ever shrank until now.
"""
root = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache")
root = root.expanduser() / "bandsaunter"
out = {}
for name, pattern in (("tiles", "tiles/**/*.png"),
("ground", "ground/*.npz"),
("airports", "airports/*.json")):
total = count = 0
try:
for path in root.glob(pattern):
try:
total += path.stat().st_size
count += 1
except OSError:
pass
except OSError:
pass
out[name] = {"bytes": total, "files": count}
out["total"] = {"bytes": sum(v["bytes"] for v in out.values()),
"files": sum(v["files"] for v in out.values())}
return out
def prune_cache(limit_mb: float = CACHE_LIMIT_MB) -> int:
"""Delete the least recently used files until the cache fits.
Least recently *used* rather than oldest: a tile fetched a year ago and
looked at last night is the receiver's own neighbourhood, and throwing
that away to keep last week's holiday is the wrong way round. Reading
a file updates its access time on any filesystem not mounted noatime,
and where it does not this falls back to when it was written, which is
the same thing for a cache that is only ever written once.
"""
root = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache")
root = root.expanduser() / "bandsaunter"
files = []
for pattern in ("tiles/**/*.png", "ground/*.npz", "airports/*.json"):
try:
for path in root.glob(pattern):
try:
stat = path.stat()
files.append((max(stat.st_atime, stat.st_mtime),
stat.st_size, path))
except OSError:
pass
except OSError:
pass
total = sum(size for _when, size, _p in files)
limit = limit_mb * 1024 * 1024
if total <= limit:
return 0
gone = 0
for _when, size, path in sorted(files):
if total <= limit:
break
try:
path.unlink()
total -= size
gone += 1
except OSError:
pass
return gone
def _airport_cache(box) -> Path:
root = os.environ.get("XDG_CACHE_HOME") or "~/.cache"
name = "_".join(f"{round(v, 1):+06.1f}" for v in box)
@ -483,7 +708,7 @@ def _ask_overpass(box, url: str, timeout: float) -> dict:
def ground_under(south: float, west: float, north: float, east: float,
width: int, height: int, shades: int = 32,
fetch=fetch_tile, zoom: int | None = None,
**kw) -> np.ndarray | None:
remember: bool = True, **kw):
"""The map for one picture, as ``shades`` levels of brightness.
The tiles are Web Mercator and the picture is not, so every output pixel
@ -494,17 +719,39 @@ def ground_under(south: float, west: float, north: float, east: float,
point of putting a coastline under it is that the coastline is where the
aircraft was.
Returns None when nothing could be fetched, which the caller draws as the
plain grid it drew before.
A whole map is kept on the disk beside the tiles it was built from, so
that opening the same window tomorrow costs a file read rather than
forty PNG decodes and a resample into this program's own projection.
``remember=False`` turns that off, which is what the tests want when
they are measuring the building rather than the remembering.
Returns the levels and whether that is the whole answer. Nothing at all
is None, which the caller draws as the plain grid it drew before; a map
with squares missing from it is returned all the same, because most of a
map is better than none, but it is flagged as not settled so the caller
knows to ask again rather than keeping it for the life of the view.
"""
if width < 1 or height < 1 or north <= south or east <= west:
return None
return None, True
# The finished map, if this exact picture has been built before. Only
# a whole one is ever written, so anything found here is settled.
key = _ground_key(south, west, north, east, width, height, shades, zoom)
kept = ground_cache_dir() / f"{key}.npz" if remember else None
if kept is not None:
have = _read_ground(kept)
if have is not None and have.shape == (height, width):
return have, True
if zoom is None:
zoom = choose_zoom(south, west, north, east, width=width)
tiles, origin_x, origin_y = mosaic(south, west, north, east, zoom,
fetch=fetch, **kw)
tiles, origin_x, origin_y, covered = mosaic(south, west, north, east,
zoom, fetch=fetch, **kw)
if tiles is None:
return None
# Nothing arrived. Settled on purpose: a machine with no network
# must not be made to ask for the same tiles five times a second
# for the rest of the night.
return None, True
# The edges of each output pixel rather than its middle, so that what
# lands in it can be averaged. Taking the nearest source pixel instead
@ -528,11 +775,39 @@ def ground_under(south: float, west: float, north: float, east: float,
# brightest thing on the picture.
whole = (0.299 * tiles[:, :, 0] + 0.587 * tiles[:, :, 1]
+ 0.114 * tiles[:, :, 2]).astype(np.float32)
luma = _resample(_resample(whole, ys, axis=0), xs, axis=1)
low, high = float(luma.min()), float(luma.max())
# Averaged over the tiles that are really there rather than over the
# canvas. Both sums come off the same box filter, so dividing one by
# the other is the weighted mean over the real pixels -- which gets the
# cells along the edge of a hole right as well, those being part tile
# and part nothing.
real = np.repeat(np.repeat(covered.astype(np.float32), TILE_PIXELS,
axis=0), TILE_PIXELS, axis=1)
share = _resample(_resample(real, ys, axis=0), xs, axis=1)
luma = _resample(_resample(whole * real, ys, axis=0), xs, axis=1)
here = share > 1e-6
luma = np.where(here, luma / np.where(here, share, 1.0), 0.0)
if not here.any():
return None, True
# The darkest and brightest of what was actually fetched. A hole left
# in the reckoning would put the floor at black, which is darker than
# any real tile: the whole map would be drawn dimmer to make room for
# a square that is not there.
low, high = float(luma[here].min()), float(luma[here].max())
if high - low < 1.0:
levels = np.zeros_like(luma)
else:
levels = 1.0 - (luma - low) / (high - low)
return np.clip((levels * (shades - 1)).round(), 0,
shades - 1).astype(np.uint8)
# And the hole itself is drawn as bare ground rather than as the
# brightest thing on the picture, which is what inverting black gives.
levels = np.where(here, levels, 0.0)
out = np.clip((levels * (shades - 1)).round(), 0,
shades - 1).astype(np.uint8)
settled = bool(covered.all())
# Only a whole map is kept. Writing one with squares missing would
# cache the hole for a month, and the whole point of treating a partial
# map as provisional is that it gets asked for again.
if kept is not None and settled:
_write_ground(kept, out)
return out, settled

View file

@ -38,6 +38,7 @@ from rich.table import Table
from rich.text import Text
from . import version_notice
from . import __version__
from .bandplan import band_names, fmt_hz, shorten_band
from .callsign import CallsignBook, HEADING, find_callsigns
from .config import DEFAULT_CONFIG_DIR, load_default, remember_lockouts
@ -666,6 +667,13 @@ STAMP = "%y-%m-%d %I:%M:%S %p"
STAMP_WIDTH = 20
def _reg_when(value: float) -> str:
"""When something was looked up, or nothing if it never was."""
if not value:
return ""
return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M")
def _stamp(when: datetime | None) -> str:
"""One capture's date and time, or an empty string when it has neither."""
if when is None:
@ -738,6 +746,17 @@ class Browser:
self.show_help = False
self.reading = False # full-screen transcript
self.read_top = 0
# The register: everything ever looked up, on its own screen. Read
# the first time it is opened rather than at startup, because a
# browser pointed at a directory of recordings should not wait on
# two caches somebody may never ask to see.
self.showing_register = False
self.register: dict | None = None
self.reg_kind = "callsigns"
self.reg_index = 0
self.reg_top = 0
self.reg_query = ""
self.reg_searching = False
self._undo: list[tuple[Path, Path]] = [] # the last move, to reverse
self._undo_path: Path | None = None # where its .wav came from
self._undo_label = ""
@ -1393,10 +1412,10 @@ class Browser:
("space", "stop"), ("/", "search"), ("t", "read"),
("S I N", "file"), ("d", "delete"), ("m", "mask"),
("s", f"sort by {self.sort}"), ("r", "reload"),
("?", "keys"), ("q", "quit")]
("c", "register"), ("?", "keys"), ("q", "quit")]
# Everything given up here is still on the page ? opens, which is why
# ? is among the last to go.
expendable = ["r", "s", "space", "m", "d", "S I N", "t", "/",
expendable = ["r", "s", "space", "m", "d", "S I N", "c", "t", "/",
"⏎", "↑↓", "?"]
width = self.console.size.width or 80
shown = items
@ -1458,8 +1477,10 @@ class Browser:
names += f" or {FILING[-1][1]}/"
for key, what in (
("↑ ↓ / k j", "move through the recordings"),
("PgUp PgDn", "a screenful at a time"),
("Home End", "first and last"),
# Merged, because the page is exactly as tall as an
# eighty-by-twenty-four terminal and the register needed a
# row. These two were always the same thought.
("PgUp PgDn", "a screenful at a time; Home End for the ends"),
("Enter", "play the highlighted recording"),
("space", "stop playing"),
("t", "read the whole transcript, full screen"),
@ -1475,6 +1496,12 @@ class Browser:
("m", "lock this frequency out of every later scan"),
("", ""),
("callsigns", "found in transcripts and looked up for you"),
# One line, not five. Eighty by twenty-four is still what a
# new terminal is, and a list of keys that has to be
# scrolled to reach "q" has failed at its one job -- so the
# register's own keys are named on the register's own screen
# rather than here.
("c", "every callsign and aircraft ever looked up"),
("? h", "this list"),
("q", "quit")):
t.add_row(key, what)
@ -1544,6 +1571,8 @@ class Browser:
return Layout(Align.center(self._help(), vertical="middle"))
if self.reading:
return Layout(self._reader())
if self.showing_register:
return self._register()
layout = Layout()
# One height, computed once: the panel and the region it sits in have
# to agree, or the difference shows up as a gap in the middle of the
@ -1573,6 +1602,8 @@ class Browser:
return True
if self.reading:
return self._handle_reader(key)
if self.showing_register:
return self._handle_register(key)
rows = self._rows()
if key in ("q", "escape") and not self.query:
@ -1613,6 +1644,8 @@ class Browser:
self.query = ""
elif key in ("?", "h"):
self.show_help = not self.show_help
elif key == "c":
self._open_register()
elif key == "t":
cap = self.current
# Either kind of content: the decoded panel says "press t to read
@ -1663,6 +1696,240 @@ class Browser:
self._delete()
return True
# -- the register -----------------------------------------------------
def _open_register(self) -> None:
from . import register as reg
if self.register is None:
self.register = reg.load_all()
self.showing_register = True
self.reg_index = self.reg_top = 0
counts = ", ".join(f"{len(v)} {k}"
for k, v in self.register.items())
self.message = counts or "nothing looked up yet"
# The detail panel's share of the screen: two borders and eight rows of
# label-and-value in two columns, which holds the sixteen fields a
# licence has. Fixed rather than fitted, so the list above it does not
# jump about as the cursor moves between a full record and a bare one.
_REG_DETAIL = 11
def _reg_rows(self) -> int:
"""How many list rows the screen has room for beside the detail."""
height = self.console.size.height or 24
# 3 for the heading panel, the detail panel, 1 for the footer, and
# 1 for the table's own column headings.
return max(3, height - 3 - self._REG_DETAIL - 1 - 1)
def _reg_all(self) -> list:
from . import register as reg
if self.register is None:
return []
return reg.search(self.register.get(self.reg_kind) or [],
self.reg_query)
@property
def reg_current(self):
found = self._reg_all()
if not found:
return None
return found[min(self.reg_index, len(found) - 1)]
def _register(self) -> Group:
"""The register: a list, and everything known about the current one."""
from . import register as reg
found = self._reg_all()
rows = self._reg_rows()
if found:
self.reg_index = max(0, min(self.reg_index, len(found) - 1))
self.reg_top = max(0, min(self.reg_top, max(0, len(found) - rows)))
if self.reg_index < self.reg_top:
self.reg_top = self.reg_index
elif self.reg_index >= self.reg_top + rows:
self.reg_top = self.reg_index - rows + 1
other = "aircraft" if self.reg_kind == "callsigns" else "callsigns"
held = len(self.register.get(self.reg_kind) or []) if self.register \
else 0
mappable = sum(1 for e in found if e.mappable)
head = Text.from_markup(
f"[bold]{self.reg_kind}[/bold] — "
f"[cyan]{len(found)}[/cyan] of {held} shown, "
f"{mappable} with somewhere to point a map at"
+ (f" [yellow]matching {escape(self.reg_query)!r}[/yellow]"
if self.reg_query else ""))
table = Table(box=None, header_style="bold", pad_edge=False,
expand=True)
table.add_column(" ", width=1, style="cyan")
table.add_column("callsign" if self.reg_kind == "callsigns"
else "aircraft", width=12, no_wrap=True)
table.add_column("who" if self.reg_kind == "callsigns" else "what",
ratio=3, no_wrap=True, style="white")
table.add_column("where", ratio=2, no_wrap=True, style="grey62")
table.add_column("looked up", width=16, no_wrap=True,
style="bright_black")
table.add_column("map", width=3, justify="center")
for i in range(self.reg_top, min(len(found), self.reg_top + rows)):
entry = found[i]
here = i == self.reg_index
table.add_row(
"›" if here else "",
Text(entry.title, style="bold reverse" if here else "bold"),
entry.what or Text("—", style="grey37"),
entry.where or "",
_reg_when(entry.when),
Text("◉", style="green") if entry.mappable
else Text("·", style="grey37"))
if not found:
table.add_row("", "", Text("nothing here", style="yellow"),
"", "", "")
# A split rather than a group, so the list takes whatever is left
# and the footer stays on the bottom line. Grouped, a short list
# left the footer floating in the middle of the screen with the
# tail of the previous frame under it.
layout = Layout()
layout.split_column(
Layout(Panel(head, border_style="cyan", padding=(0, 1),
title="[bold]the register[/bold]",
title_align="left",
subtitle=f"[bright_black]tab → {other}"
f"[/bright_black]",
subtitle_align="right"), size=3),
Layout(table, ratio=1),
Layout(self._reg_detail(self.reg_current),
size=self._REG_DETAIL),
Layout(Text.from_markup(self._reg_footer()), size=1),
)
return layout
def _reg_detail(self, entry) -> Panel:
"""Everything known about one entry, in two columns of label and
value.
Two columns rather than one long list because a licence has a dozen
fields and a screen is wider than it is tall, and because the thing
somebody is looking for is usually further down than the fold.
"""
if entry is None:
return Panel(Text("select something to see what is known "
"about it", style="grey62"),
border_style="grey37", padding=(0, 1))
grid = Table.grid(padding=(0, 2), expand=True)
grid.add_column(justify="right", style="grey62", width=16)
grid.add_column(ratio=1, overflow="fold")
grid.add_column(justify="right", style="grey62", width=16)
grid.add_column(ratio=1, overflow="fold")
pairs = list(entry.rows)
half = (len(pairs) + 1) // 2
left, right = pairs[:half], pairs[half:]
for i in range(half):
a_label, a_value = left[i]
b_label, b_value = right[i] if i < len(right) else ("", "")
grid.add_row(a_label, Text(a_value), b_label, Text(b_value))
where = (f"[green]g[/green] opens this place in a browser"
if entry.mappable
else "[grey37]no position, so nowhere to open[/grey37]")
return Panel(grid, border_style="cyan", padding=(0, 1),
title=f"[bold]{escape(entry.title)}[/bold]",
title_align="left", subtitle=where,
subtitle_align="right")
def _reg_footer(self) -> str:
if self.reg_searching:
return (f"[cyan]search[/cyan] {escape(self.reg_query)}[reverse] "
f"[/reverse] [bright_black]⏎ done esc clear"
f"[/bright_black]")
if self.message:
return f"[green]{escape(self.message)}[/green]"
return ("[bright_black]↑↓ move tab switch / search "
"g map c or esc back q quit[/bright_black]")
def _handle_register(self, key: str) -> bool:
if self.reg_searching:
return self._handle_reg_search(key)
rows = self._reg_rows()
if key in ("c", "escape"):
self.showing_register = False
self.reg_query = ""
elif key == "q":
return False
elif key in ("up", "k"):
self.reg_index -= 1
elif key in ("down", "j"):
self.reg_index += 1
elif key in ("pgup", "left"):
self.reg_index -= rows
elif key in ("pgdn", "right"):
self.reg_index += rows
elif key == "home":
self.reg_index = 0
elif key == "end":
self.reg_index = 1 << 20 # clamped when the panel is drawn
elif key == "tab":
self.reg_kind = ("aircraft" if self.reg_kind == "callsigns"
else "callsigns")
self.reg_index = self.reg_top = 0
elif key == "/":
self.reg_searching = True
self.reg_query = ""
elif key == "g":
self._open_map()
elif key == "r":
from . import register as reg
self.register = reg.load_all()
self.message = "read the caches again"
self.reg_index = max(0, self.reg_index)
return True
def _handle_reg_search(self, key: str) -> bool:
if key == "enter":
self.reg_searching = False
elif key == "escape":
self.reg_searching = False
self.reg_query = ""
elif key == "backspace":
self.reg_query = self.reg_query[:-1]
elif len(key) == 1 and key.isprintable():
self.reg_query += key
self.reg_index = self.reg_top = 0
return True
def _open_map(self) -> None:
"""Show where this is, in whatever browser the desktop uses.
Only the coordinates go into the URL. A map does not need to be
told whose licence it is looking at, and the one part of this that
leaves the machine should carry as little as it can.
"""
entry = self.reg_current
if entry is None:
return
url = entry.maps()
if not url:
self.message = "nothing is known about where this one is"
return
import contextlib
import io
import webbrowser
try:
# Quietly: some browsers write to the terminal on startup, and
# this one is running a full-screen display that would be left
# with somebody else's text through the middle of it.
with contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
opened = webbrowser.open(url)
except Exception as exc:
self.message = f"could not open a browser: {exc}"
return
self.message = (f"opened {entry.title} in a browser" if opened
else f"no browser to open — the place is {url}")
def _handle_reader(self, key: str) -> bool:
rows = max(3, (self.console.size.height or 24) - 6)
if key in ("t", "escape", "q", "space"):
@ -1774,6 +2041,8 @@ def build_parser() -> argparse.ArgumentParser:
default=SORTS[0], metavar="ORDER",
help="initial order: %s (default: %s, newest first)"
% (", ".join(SORTS), SORTS[0]))
p.add_argument("--no-splash", action="store_true",
help="do not draw the title screen")
p.add_argument("--filter", default="", metavar="TEXT",
help="start with only recordings matching this")
p.add_argument("--player", default=None, metavar="CMD",
@ -1870,6 +2139,18 @@ def _write_kml(console: Console, browser: "Browser", book: CallsignBook,
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
console = Console()
# Paused for a moment, because what follows takes the whole screen: the
# browser runs on the alternate screen, so without a beat here the
# banner is replaced in the same tenth of a second it was drawn in. It
# is still on the normal screen afterwards, and comes back on quitting.
from . import splash
splash.show(console, splash.BROWSER_NAME,
("Conceived by: The Dust Council",
"100% AI Coded by Claude Code.",
f"{__version__} · read back what a scan left behind"),
no_splash=getattr(args, "no_splash", False), pause=0.9)
directory = (Path(args.directory).expanduser() if args.directory
else default_directory())
if not directory.is_dir():

View file

@ -35,7 +35,7 @@ from pathlib import Path
__all__ = ["Callsign", "CallsignBook", "find_callsigns", "describe_prefix",
"grid_to_latlon", "PHONETIC", "LOOKUP_URL", "BACKUP_URL",
"is_service_call", "SHAPE", "SERVICE_SHAPE"]
"is_service_call", "licensed_call", "SHAPE", "SERVICE_SHAPE"]
# The FCC's own licence data, served as JSON without an account or a key.
# US callsigns only; everything else resolves to what the prefix alone says.
@ -130,6 +130,55 @@ SERVICE_SHAPE = re.compile(
SERVICE_LICENCE = "GMRS or business licence"
def licensed_call(call: str) -> str:
"""The callsign a register could know, out of one that was heard.
Every mode dresses a callsign up in its own way and no licensing
authority has heard of any of it:
W1AW-9 APRS: the ninth radio this licence runs
WIDE2-1* APRS: the star marks the hop a packet came through
ET3RFG/R FT8: a rover, operating away from the licensed address
W1AW/4 operating in another district
DL/G4ABC a guest in Germany -- G4ABC is the licence
<...> FT8: a callsign this receiver has not heard spelled out
Returns the bare callsign, or "" where there is nothing to look up.
Shared rather than written twice because getting it wrong is silent:
asking a register about "W1AW-9" returns nothing at all, which looks
exactly like a station that is not licensed.
"""
call = (call or "").strip().upper().rstrip("*")
if not call or call.startswith("<"):
return ""
if call in ("CQ", "DE", "QRZ", "BEACON", "ALL", "MAIL", "TEST"):
return ""
if call.startswith("CQ "):
return ""
call = call.split("-", 1)[0] # the SSID is not licensed
if "/" in call:
# A guest call is prefix/home or home/suffix, and the licensed part
# is whichever piece is a callsign in its own right. Longest wins,
# because a suffix is one or two characters and a prefix is a
# country: neither is ever longer than the callsign itself.
pieces = [p for p in call.split("/") if p]
real = [p for p in pieces if _looks_licensed(p)]
if not real:
return ""
call = max(real, key=len)
return call if _looks_licensed(call) else ""
def _looks_licensed(call: str) -> bool:
"""Whether something could be an amateur callsign at all.
A letter or two, a digit, and a letter or three. Deliberately loose --
the register is the authority on whether a callsign exists, and this
only has to keep obvious non-callsigns from being asked about.
"""
return bool(re.fullmatch(r"[A-Z0-9]{1,3}[0-9][A-Z]{1,4}", call or ""))
def is_service_call(call: str) -> bool:
"""True for a GMRS, business or public-safety callsign, not a ham one.

View file

@ -18,6 +18,7 @@ from rich.table import Table
from rich.text import Text
from . import version_notice
from . import __version__
from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
is_first_run, list_profiles, load_config, load_default,
@ -25,6 +26,8 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
from .device import RtlSdrError, list_devices, set_driver_messages
from .librtlsdr import load_error
from . import settings as st
from .ax25 import APRS_CHANNELS as _APRS_CHANNELS
from .ft8 import BANDS as _FT8_BANDS
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
from .scanner import Scanner, ScannerCallbacks
from .tui import TUIAbort, first_run_setup, run_tui, settings_menu
@ -58,6 +61,14 @@ examples:
bandsaunter adsb --window live map of the aircraft
bandsaunter flights --out sky.gif animate what they did
bandsaunter flights --theme phosphor draw it as a vector display
bandsaunter weather read the 433 MHz weather sensors
bandsaunter weather --name 1A2B="back fence" name one while listening
bandsaunter readings --csv turn a weather log into a graph
bandsaunter sensors what is out there, and what it is called
bandsaunter aprs read the APRS channel on 144.39 MHz
bandsaunter aprs --region europe ...or 144.80 MHz, or wherever you are
bandsaunter aprs --window a live map of the stations heard
bandsaunter packets --kml turn an APRS log into a map
bandsaunter scan -b 2m --simulate try it without hardware
""")
# The GNU form: the version, then who holds the copyright and what the
@ -65,6 +76,11 @@ examples:
# are allowed to do with it and the program is the only thing in front
# of them.
p.add_argument("--version", action="version", version=version_notice())
# Before the subcommand, because it is about the program rather than
# about any one thing it does. BANDSAUNTER_NO_SPLASH=1 does the same for
# anybody who wants it off for good.
p.add_argument("--no-splash", action="store_true",
help="do not draw the title screen")
sub = p.add_subparsers(dest="command")
# -- scan ------------------------------------------------------------
@ -260,6 +276,25 @@ examples:
ad.add_argument("--no-labels", dest="labels", action="store_false",
default=None,
help="draw the aircraft without labels beside them")
ad.add_argument("--pulse", dest="pulse", action="store_true",
default=None, help="swell each aircraft from bright to "
"dim and back")
ad.add_argument("--no-pulse", dest="pulse", action="store_false",
default=None, help="draw the aircraft at a steady "
"brightness")
ad.add_argument("--pulse-rate", type=float, default=None,
metavar="SECONDS",
help="how long one swell takes, in seconds of watching")
ad.add_argument("--echo", dest="echo", action="store_true",
default=None, help="rings travelling outward from each "
"aircraft")
ad.add_argument("--no-echo", dest="echo", action="store_false",
default=None, help="no rings")
ad.add_argument("--echo-every", type=float, default=None,
metavar="SECONDS",
help="how long between one ring and the next")
ad.add_argument("--echo-size", type=int, default=None, metavar="PIXELS",
help="how far a ring gets before it has faded away")
ad.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad",
@ -269,6 +304,231 @@ examples:
"phosphor, amber and red")
ad.set_defaults(log_frames=True, lookup=True)
# -- aprs -----------------------------------------------------------------
ap = sub.add_parser("aprs",
help="read the APRS channel: positions, weather, "
"messages, telemetry")
ap.add_argument("--seconds", type=float, default=None,
help="stop after this long (default: until interrupted)")
ap.add_argument("--rate", type=float, default=None,
help="sample rate in Hz; 96 kS/s is the least that holds "
"the channel")
ap.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
ap.add_argument("--device", type=int, default=None, help="which receiver")
ap.add_argument("--region", default=None,
choices=[key for key, _hz, _w in _APRS_CHANNELS],
help="which APRS channel to listen on")
ap.add_argument("--frequency", "--freq", dest="frequency", type=float,
default=None, metavar="HZ",
help="the exact frequency, if the region's channel is "
"not what you want")
ap.add_argument("--lookup", dest="lookup", action="store_true",
default=None,
help="look up who each callsign is licensed to, and "
"cache the answers (the default)")
ap.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not look callsigns up over the network")
ap.add_argument("--window", action="store_true",
help="open a window and show the stations on a map as "
"they are heard, instead of a table in the terminal")
ap.add_argument("--radius", type=float, default=None, metavar="KM",
help="how far around the aerial the window reaches")
ap.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red"),
help="how the window looks")
ap.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT",
help="how bright the ground under the stations is (10-100)")
ap.add_argument("--basemap", dest="basemap", action="store_true",
default=None, help="draw a real map under the stations")
ap.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None, help="no map tiles")
ap.add_argument("--window-rings", dest="window_rings",
action="store_true", default=None,
help="faint range discs around the aerial")
ap.add_argument("--no-window-rings", dest="window_rings",
action="store_false", default=None, help="no range rings")
ap.add_argument("--box-opacity", type=int, default=None,
metavar="PERCENT",
help="how solid the card behind each information box is")
ap.add_argument("--trails", dest="trails", action="store_true",
default=None, help="draw the path behind what moved")
ap.add_argument("--no-trails", dest="trails", action="store_false",
default=None, help="no trails")
ap.add_argument("--tiles", dest="tile_url", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)")
ap.add_argument("--find-channel", nargs="?", type=float, const=20.0,
default=None, metavar="SECONDS",
help="listen on each region's channel in turn and say "
"which has traffic, instead of listening on one "
"(default: 20 seconds each)")
ap.add_argument("--simulate", dest="simulate", action="store_true",
default=None,
help="invent a channel full of stations, for a receiver "
"with no aerial")
ap.add_argument("--no-simulate", dest="simulate", action="store_false",
default=None, help="listen to real stations")
ap.add_argument("--log", default=None, metavar="FILE",
help="where to write the packet log "
"(default: aprs_<time>.jsonl in the output directory)")
ap.add_argument("--no-log", dest="log_packets", action="store_false",
default=None, help="listen without writing anything down")
ap.add_argument("--packets", dest="packets_seen", action="store_true",
default=None,
help="print every packet as it arrives, not a table")
ap.add_argument("--no-packets", dest="packets_seen", action="store_false",
default=None, help="show the table that updates in place")
ap.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a station stays on the display after its "
"last packet")
ap.add_argument("--at", dest="location", default=None, metavar="LAT,LON",
help="where the aerial is, so distances can be worked out")
ap.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in (the log is always metric)")
ap.add_argument("--unparsed", dest="unparsed", action="store_true",
default=None,
help="list packets whose format cannot be read")
ap.add_argument("--no-unparsed", dest="unparsed", action="store_false",
default=None, help="only packets that could be read")
ap.add_argument("--digipeated", dest="digipeated", action="store_true",
default=None, help="include packets that reached here "
"through a digipeater")
ap.add_argument("--direct-only", dest="digipeated", action="store_false",
default=None,
help="only what was heard without a relay in between")
ap.add_argument("--report", dest="report", action="store_true",
default=None, help="print what was heard at the end")
ap.add_argument("--no-report", dest="report", action="store_false",
default=None, help="no report when the listening stops")
ap.add_argument("--csv", dest="csv", action="store_true", default=None,
help="also write the packets as CSV beside the log")
ap.add_argument("--no-csv", dest="csv", action="store_false", default=None,
help="no spreadsheet")
ap.add_argument("--kml", dest="kml", action="store_true", default=None,
help="also write the stations and tracks for Google Earth")
ap.add_argument("--no-kml", dest="kml", action="store_false", default=None,
help="no map")
# -- packets --------------------------------------------------------------
pk = sub.add_parser("packets",
help="read an APRS log: report, spreadsheet, map")
pk.add_argument("path", nargs="*",
help="packet logs (default: the newest in the output "
"directory)")
pk.add_argument("--csv", nargs="?", const="", default=None, metavar="FILE",
help="write the packets as CSV (default: beside the log)")
pk.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
help="write the stations and tracks for Google Earth")
pk.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in")
pk.add_argument("--station", default=None, metavar="CALL",
help="only this station, by callsign")
pk.add_argument("--at", dest="location", default=None, metavar="LAT,LON",
help="where the aerial was, so distances can be shown")
pk.add_argument("--no-report", dest="report", action="store_false",
default=True, help="write the files and say nothing")
# -- weather -------------------------------------------------------------
we = sub.add_parser("weather",
help="read the AcuRite weather sensors on 433 MHz")
we.add_argument("--seconds", type=float, default=None,
help="stop after this long (default: until interrupted)")
we.add_argument("--rate", type=float, default=None,
help="sample rate in Hz; a quarter of a megasample is the "
"minimum")
we.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
we.add_argument("--device", type=int, default=None, help="which receiver")
we.add_argument("--frequency", "--freq", dest="frequency", type=float,
default=None, metavar="HZ",
help="where the sensors are (default: 433.92 MHz)")
we.add_argument("--offset", type=float, default=None, metavar="HZ",
help="how far to one side of them to tune, to keep the "
"receiver's own spike off the signal")
we.add_argument("--simulate", dest="simulate", action="store_true",
default=None,
help="invent a garden of sensors, for a receiver with no "
"aerial")
we.add_argument("--no-simulate", dest="simulate", action="store_false",
default=None, help="listen to real sensors")
we.add_argument("--log", default=None, metavar="FILE",
help="where to write the message log "
"(default: weather_<time>.jsonl in the output "
"directory)")
we.add_argument("--no-log", dest="log_messages", action="store_false",
default=None, help="listen without writing anything down")
we.add_argument("--messages", dest="messages", action="store_true",
default=None,
help="print every message as it arrives, not a table")
we.add_argument("--no-messages", dest="messages", action="store_false",
default=None, help="show the table that updates in place")
we.add_argument("--diagnose", dest="diagnose", action="store_true",
default=None,
help="say what each second of band looked like at every "
"stage, for when sensors you know are there are not "
"appearing")
we.add_argument("--no-diagnose", dest="diagnose", action="store_false",
default=None, help="the ordinary display")
we.add_argument("--from-iq", default=None, metavar="FILE",
help="read a saved capture instead of the receiver, so a "
"recording made where the aerial is can be worked on "
"anywhere")
we.add_argument("--save-iq", default=None, metavar="FILE",
help="also write the raw samples, for working out why "
"something will not decode (2 MB a second at the "
"default rate, so bound it with --seconds)")
we.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a sensor stays on the display after its "
"last message")
we.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in (the log is always metric)")
we.add_argument("--only-named", dest="only_named", action="store_true",
default=None,
help="ignore sensors that have not been given a name")
we.add_argument("--all-sensors", dest="only_named", action="store_false",
default=None, help="show every sensor heard")
we.add_argument("--unknown", dest="unknown", action="store_true",
default=None,
help="list sensors whose model cannot be read")
we.add_argument("--no-unknown", dest="unknown", action="store_false",
default=None, help="only sensors that can be read")
we.add_argument("--report", dest="report", action="store_true",
default=None, help="print what each sensor said at the end")
we.add_argument("--no-report", dest="report", action="store_false",
default=None, help="no report when the listening stops")
we.add_argument("--csv", dest="csv", action="store_true", default=None,
help="also write the readings as CSV beside the log")
we.add_argument("--no-csv", dest="csv", action="store_false", default=None,
help="no spreadsheet")
we.add_argument("--name", action="append", default=[], metavar="ID=NAME",
help="give a sensor a friendly name before listening; "
"repeatable, and the same thing the n key does while "
"the display is running")
# -- readings -------------------------------------------------------------
rd = sub.add_parser("readings",
help="read a weather log: report, and a spreadsheet")
rd.add_argument("path", nargs="*",
help="weather logs (default: the newest in the output "
"directory)")
rd.add_argument("--csv", nargs="?", const="", default=None, metavar="FILE",
help="write the readings as CSV (default: beside the log)")
rd.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in")
rd.add_argument("--sensor", default=None, metavar="NAME",
help="only this sensor, by name or by identity")
rd.add_argument("--no-report", dest="report", action="store_false",
default=True, help="write the spreadsheet and say nothing")
# -- sensors --------------------------------------------------------------
se = sub.add_parser("sensors",
help="what has been heard, and what it is called")
se.add_argument("--name", action="append", default=[], metavar="ID=NAME",
help="give a sensor a friendly name; repeatable")
se.add_argument("--note", action="append", default=[], metavar="ID=TEXT",
help="anything else worth remembering about a sensor")
se.add_argument("--forget", action="append", default=[], metavar="ID",
help="remove a sensor from the list entirely")
# -- flights --------------------------------------------------------------
fl = sub.add_parser("flights",
help="read an ADS-B log: report, map, animation")
@ -339,6 +599,25 @@ examples:
metavar="PERCENT",
help="how solid the card behind each information box is "
"(0-100; 0 puts the words straight on the map)")
fl.add_argument("--pulse", dest="pulse", action="store_true",
default=None, help="swell each aircraft from bright to "
"dim and back")
fl.add_argument("--no-pulse", dest="pulse", action="store_false",
default=None, help="draw the aircraft at a steady "
"brightness")
fl.add_argument("--pulse-rate", type=float, default=None,
metavar="SECONDS",
help="how long one swell takes, in seconds of watching")
fl.add_argument("--echo", dest="echo", action="store_true",
default=None, help="rings travelling outward from each "
"aircraft")
fl.add_argument("--no-echo", dest="echo", action="store_false",
default=None, help="no rings")
fl.add_argument("--echo-every", type=float, default=None,
metavar="SECONDS",
help="how long between one ring and the next")
fl.add_argument("--echo-size", type=int, default=None, metavar="PIXELS",
help="how far a ring gets before it has faded away")
fl.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad",
@ -373,6 +652,80 @@ examples:
a.add_argument("--freq", type=float, default=0.0,
help="centre frequency in Hz, for band-aware naming")
a.add_argument("--morse", action="store_true", help="force a CW decode")
# -- ft8 ------------------------------------------------------------------
f8 = sub.add_parser("ft8",
help="listen to FT8: fifteen-second slots, forty "
"stations at once, most of them under the noise")
f8.add_argument("--device", type=int, default=None, help="which receiver")
f8.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
f8.add_argument("--rate", type=float, default=None,
help="sample rate in Hz; 96 kS/s is the least that holds "
"three kilohertz of audio")
f8.add_argument("--band", default=None,
choices=[key for key, _hz, _n in _FT8_BANDS],
help="which FT8 channel to listen on; sets the frequency")
f8.add_argument("--frequency", "--freq", dest="frequency", type=float,
default=None, metavar="HZ",
help="the dial frequency, for an upconverter or a "
"channel the band list does not have")
f8.add_argument("--simulate", dest="simulate", action="store_true",
default=None, help="invent a band instead of using a "
"receiver")
f8.add_argument("--no-simulate", dest="simulate", action="store_false",
help="use the receiver (the default)")
f8.add_argument("--seconds", type=float, default=None,
help="stop after this long (default: until interrupted)")
f8.add_argument("--slots", type=int, default=None, metavar="N",
help="stop after this many fifteen-second slots")
f8.add_argument("--log", dest="log", action="store_true", default=None,
help="write a log of every decode (the default)")
f8.add_argument("--no-log", dest="log", action="store_false",
help="do not write a log")
f8.add_argument("--decodes-seen", dest="decodes_seen",
action="store_true", default=None,
help="print a line per decode instead of a live table")
f8.add_argument("--no-decodes-seen", dest="decodes_seen",
action="store_false", help="show the live table")
f8.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a station stays on the display")
f8.add_argument("--lowest", type=float, default=None, metavar="HZ",
help="the lowest audio frequency to search")
f8.add_argument("--highest", type=float, default=None, metavar="HZ",
help="the highest audio frequency to search")
f8.add_argument("--most", type=int, default=None, metavar="N",
help="how many candidate transmissions to try per slot")
f8.add_argument("--rounds", type=int, default=None, metavar="N",
help="how many error-correction passes before giving up")
f8.add_argument("--grid", default=None, metavar="SQUARE",
help="your own grid square, so distances can be worked "
"out (four characters, like IO91)")
f8.add_argument("--lookup", dest="lookup", action="store_true",
default=None,
help="look up who each callsign is licensed to, and "
"cache the answers (the default)")
f8.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not look callsigns up over the network")
f8.add_argument("--units", default=None, choices=("metric", "imperial"),
help="which units to show distances in")
f8.add_argument("--calls-only", dest="calls_only", action="store_true",
default=None, help="leave out free text and telemetry")
f8.add_argument("--no-calls-only", dest="calls_only",
action="store_false", help="show everything decoded")
f8.add_argument("--report", dest="report", action="store_true",
default=None, help="print the tables at the end")
f8.add_argument("--no-report", dest="report", action="store_false",
help="do not print the tables")
f8.add_argument("--csv", dest="csv", action="store_true", default=None,
help="also write a spreadsheet of every decode")
f8.add_argument("--no-csv", dest="csv", action="store_false",
help="do not write a spreadsheet")
f8.add_argument("--adif", dest="adif", action="store_true", default=None,
help="also write an ADIF of stations heard, for a "
"logging program")
f8.add_argument("--no-adif", dest="adif", action="store_false",
help="do not write an ADIF")
return p
@ -510,6 +863,33 @@ def _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
border_style="yellow", padding=(0, 1)))
def _warn_about_sensor_bands(cfg: ScanConfig) -> None:
"""The same courtesy for 433 MHz, which is in the band plan too.
A sweep of it is a fair thing to want -- there is a great deal there
besides weather -- so the sweep is not stopped, only told about.
"""
from . import weather as wx
warning = wx.scanning_sensor_band(cfg.ranges)
if not warning:
return
console.print(Panel(
Text.from_markup(
f"{escape(warning)}\n\n"
"[bold]bandsaunter weather[/bold] decodes them properly: "
"temperature, humidity, wind, rain and lightning, from each "
"sensor by name.\n"
"[bold]bandsaunter readings[/bold] then turns the log into a "
"spreadsheet.\n\n"
"[grey62]Both are in the menus as well, under Weather sensors "
"(433 MHz). Scanning it anyway is fine if what you want is the "
"raw spectrum \u2014 there are doorbells, car keys and tyre "
"sensors there too.[/grey62]"),
title="[yellow]this band needs the weather mode",
border_style="yellow", padding=(0, 1)))
def _make_device(cfg: ScanConfig, simulate: bool):
if simulate:
from .simulator import SimulatedDevice
@ -567,6 +947,7 @@ def cmd_scan(args) -> int:
return 2
_warn_about_aircraft_bands(cfg)
_warn_about_sensor_bands(cfg)
if args.dry_run:
_print_plan(cfg)
@ -1200,6 +1581,10 @@ def cmd_adsb(args) -> int:
options.theme = args.theme
if getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity
for flag in ("pulse", "pulse_rate", "echo", "echo_every", "echo_size"):
value = getattr(args, flag, None)
if value is not None:
setattr(options, flag, value)
if getattr(args, "rings", None) is not None:
options.rings = args.rings
if getattr(args, "window_rings", None) is not None:
@ -1232,6 +1617,353 @@ def cmd_adsb(args) -> int:
return 0
def _weather_options(args, options):
"""Fold whatever was given on the command line into the saved options.
Every one of these defaults to None rather than to the option's default,
so that a flag left off means "whatever was saved" rather than "the
factory setting". A person who has set the gain in the menu and then
runs `bandsaunter weather --seconds 60` should get their gain.
"""
for flag, key in (("seconds", "seconds"), ("rate", "rate"),
("gain", "gain"), ("device", "device"),
("frequency", "frequency"), ("offset", "offset"),
("simulate", "simulate"), ("log_messages", "log"),
("messages", "messages"), ("diagnose", "diagnose"),
("hold", "hold"),
("units", "units"), ("only_named", "only_named"),
("unknown", "unknown"), ("report", "report"),
("csv", "csv")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
return options
def _name_sensors(book, given, quiet: bool = False) -> int:
"""Apply every --name or --note on the command line. Returns how many.
The identity may be given as it appears on the display -- 1A2B -- or as
the whole key, tower/1A2B, which is what to use on the vanishingly rare
occasion that two models have drawn the same identity out of the hat.
"""
done = 0
for pair in given or ():
ident, _, name = str(pair).partition("=")
ident, name = ident.strip(), name.strip()
if not ident or not name:
console.print(f"[yellow]--name wants ID=NAME, not {pair!r}"
"[/yellow]")
continue
found = book.find(ident)
if not found:
# Nothing of that identity has been heard yet. Named anyway,
# under the key as typed: the sensor on the shed is on the shed
# whether or not it has been received in the last five minutes,
# and a name waiting for it is better than a name refused.
from .sensors import UNHEARD
key = ident if "/" in ident else f"{UNHEARD}/{ident.upper()}"
book.tag(key, name)
if not quiet:
console.print(f" [green]{ident} is now {name}[/green] "
f"[grey62](not heard yet)[/grey62]")
done += 1
continue
if len(found) > 1:
console.print(f"[yellow]{ident!r} matches "
f"{', '.join(s.key for s in found)} — "
"use the whole key[/yellow]")
continue
book.tag(found[0].key, name)
if not quiet:
console.print(f" [green]{found[0].sensor} is now {name}[/green]")
done += 1
return done
def cmd_aprs(args) -> int:
"""Park on the APRS channel and write down everything that passes.
A command of its own because a scan cannot do this: APRS is a two-second
transmission every few minutes from a hundred stations sharing one
frequency, and a sweep catches whichever one happened to key up while the
sweep was pointed there.
"""
from . import aprs as ap
cfg, _ = load_default()
options = ap.load_options()
for flag, key in (("seconds", "seconds"), ("rate", "rate"),
("gain", "gain"), ("device", "device"),
("region", "region"), ("frequency", "frequency"),
("simulate", "simulate"), ("log_packets", "log"),
("packets_seen", "packets_seen"), ("hold", "hold"),
("location", "location"), ("units", "units"),
("unparsed", "unparsed"), ("digipeated", "digipeated"),
("lookup", "lookup"),
("report", "report"), ("csv", "csv"), ("kml", "kml"),
("radius", "radius"), ("theme", "theme"),
("map_brightness", "map_brightness"),
("basemap", "basemap"),
("window_rings", "window_rings"),
("box_opacity", "box_opacity"), ("trails", "trails"),
("tile_url", "tile_url")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
# The region picks the frequency unless the frequency was given outright,
# which is the one order that lets both flags mean what they say.
if getattr(args, "region", None) and getattr(args, "frequency", None) is None:
ap.use_region(options, options.region)
errs = options.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
if args.find_channel is not None:
seconds = max(2.0, float(args.find_channel))
console.print(f"[grey62]each of {len(_APRS_CHANNELS)} channels for "
f"{seconds:g} s \u2014 about "
f"{seconds * len(_APRS_CHANNELS):.0f} s altogether"
f"[/grey62]")
best = ap.report_channels(console,
ap.find_channel(console, options, seconds),
seconds)
if best is None:
return 1
console.print(f"[grey62]listen on it with `bandsaunter aprs --region "
f"{best.region}`, or set it once in the menu under "
f"APRS \u2192 Channel[/grey62]")
return 0
run = ap.watch if args.window else ap.listen
heard = run(console, options, cfg.output_dir, log_path=args.log)
if not heard.stations:
console.print("[grey62]nothing decoded — `bandsaunter aprs "
"--find-channel` listens on every region's channel and "
"says which has traffic, which is the fault that looks "
"most like a dead aerial and is not[/grey62]")
return 0 if heard.stations else 1
def cmd_packets(args) -> int:
"""Read an APRS log back: what was heard, and where it was."""
from . import aprs as ap
from .aprslog import logs_in, read_logs, write_csv, write_kml
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else logs_in(cfg.output_dir)[:1]
if not paths:
console.print("[yellow]no APRS logs found. Record one with "
"`bandsaunter aprs`.[/yellow]")
return 1
for path in paths:
if not path.exists():
console.print(f"[red]no such file: {path}[/red]")
return 1
heard = read_logs(paths)
if not heard:
console.print(f"[yellow]{paths[0].name} holds no packets[/yellow]")
return 1
options = ap.load_options()
for flag, key in (("units", "units"), ("location", "location")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
if args.station:
wanted = args.station.strip().upper()
heard = [p for p in heard
if wanted in (p.source.upper(), p.station.upper())]
if not heard:
console.print(f"[yellow]nothing in the log from "
f"{args.station!r}[/yellow]")
return 1
if args.report:
ap.report(console, ap.Net.of(heard), options)
for flag, writer, suffix in ((args.csv, write_csv, ".csv"),
(args.kml, write_kml, ".kml")):
if flag is None:
continue
where = Path(flag).expanduser() if flag else paths[0].with_suffix(suffix)
try:
written = writer(where, heard, options.imperial)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return 1
if written is None:
console.print("[yellow]no station said where it was, so there "
"is no map to draw[/yellow]")
else:
console.print(f"[green]wrote {written}[/green] "
f"[grey62]{len(heard):,} packets[/grey62]")
return 0
def cmd_weather(args) -> int:
"""Park the receiver on 433.92 MHz and read the weather sensors.
A command of its own for the same reason the aircraft mode is: this does
not fit through the scanner. A sensor message is a burst of on-off
keying, and the scan path is a squelch and a recorder -- it would record
the bursts as clicks in a WAV file and decode nothing.
"""
from . import weather as wx
from .sensors import SensorBook
cfg, _ = load_default()
options = _weather_options(args, wx.load_options())
errs = options.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
device = None
if args.from_iq:
try:
device = wx.Replay(args.from_iq)
except (OSError, ValueError) as exc:
console.print(f"[red]cannot read {args.from_iq}: {exc}[/red]")
return 1
if not len(device):
console.print(f"[red]{args.from_iq} holds no samples[/red]")
return 1
# The capture decides these, not the saved settings: read at the
# wrong rate every pulse in it is the wrong length.
options.rate, options.offset = device.rate, device.offset
options.frequency, options.simulate = device.frequency, False
console.print(f"[grey62]replaying {device.seconds:.1f} s from "
f"{args.from_iq} at {device.rate/1e6:g} MS/s, "
f"offset {device.offset/1e3:g} kHz"
f"{'' if device.settings else ' (no settings beside it '
'— assuming the defaults)'}[/grey62]")
book = SensorBook()
_name_sensors(book, args.name)
heard = wx.listen(console, options, cfg.output_dir, log_path=args.log,
book=book, save_iq=args.save_iq, device=device)
if not heard.sensors and not options.diagnose:
console.print("[grey62]nothing decoded — `bandsaunter weather "
"--diagnose` says which stage it stops at[/grey62]")
return 0 if heard.sensors else 1
def cmd_readings(args) -> int:
"""Read a weather log back: what was heard, and what it said."""
from . import weather as wx
from .sensors import SensorBook
from .weatherlog import logs_in, read_logs, write_csv
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else logs_in(cfg.output_dir)[:1]
if not paths:
console.print("[yellow]no weather logs found. Record one with "
"`bandsaunter weather`.[/yellow]")
return 1
for path in paths:
if not path.exists():
console.print(f"[red]no such file: {path}[/red]")
return 1
readings = read_logs(paths)
if not readings:
console.print(f"[yellow]{paths[0].name} holds no readings[/yellow]")
return 1
options = wx.load_options()
if args.units:
options.units = args.units
book = SensorBook()
if args.sensor:
wanted = {s.key for s in book.find(args.sensor)}
wanted |= {r.key for r in readings
if args.sensor.strip().lower() in r.key.lower()}
readings = [r for r in readings if r.key in wanted]
if not readings:
console.print(f"[yellow]nothing in the log matches "
f"{args.sensor!r}[/yellow]")
return 1
if args.report:
wx.report(console, wx.Garden.of(readings), book, options.imperial)
if args.csv is not None:
where = Path(args.csv).expanduser() if args.csv \
else paths[0].with_suffix(".csv")
try:
written = write_csv(where, readings, book, options.imperial)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return 1
console.print(f"[green]wrote {written}[/green] "
f"[grey62]{len(readings):,} readings[/grey62]")
return 0
def cmd_sensors(args) -> int:
"""List what has been heard, and give things names.
The list is everything ever heard rather than everything heard lately,
because a sensor with a flat battery is exactly the one somebody wants
to look up.
"""
from .sensors import SensorBook
book = SensorBook()
changed = _name_sensors(book, args.name)
for pair in args.note or ():
ident, _, note = str(pair).partition("=")
found = book.find(ident.strip())
if len(found) == 1:
book.tag(found[0].key, found[0].name, note.strip())
changed += 1
else:
console.print(f"[yellow]--note: nothing matches "
f"{ident.strip()!r}[/yellow]")
for ident in args.forget or ():
found = book.find(str(ident).strip())
if len(found) == 1:
book.forget(found[0].key)
console.print(f" [green]forgot {found[0].sensor}[/green]")
changed += 1
else:
console.print(f"[yellow]--forget: nothing matches "
f"{ident!r}[/yellow]")
if not len(book):
console.print("[yellow]no sensors heard yet. Listen with "
"`bandsaunter weather`.[/yellow]")
return 1
t = Table(box=None, header_style="bold", pad_edge=False,
title="[bold]sensors[/bold]", title_justify="left")
t.add_column("name", overflow="fold")
t.add_column("id", style="grey62", no_wrap=True)
t.add_column("decimal", style="grey62", no_wrap=True, justify="right")
t.add_column("key", style="grey62", no_wrap=True)
t.add_column("model", style="grey62", overflow="fold")
t.add_column("ch", style="grey62", justify="center")
t.add_column("msgs", justify="right", style="grey62")
t.add_column("last heard", style="grey62", no_wrap=True)
t.add_column("note", style="grey62", overflow="fold")
for sensor in book.ordered():
last = time.strftime("%Y-%m-%d %H:%M",
time.localtime(sensor.last_heard)) \
if sensor.last_heard else ""
t.add_row(sensor.name or "[yellow]unnamed[/yellow]", sensor.sensor,
sensor.number, sensor.key, sensor.model, sensor.channel,
f"{sensor.messages:,}", last, sensor.note)
console.print(t)
console.print(f"[grey62]kept in {book.path} — name one with "
f"`bandsaunter sensors --name ID=NAME`, in either column: "
f"the decimal is the same identity, and is what rtl_433 "
f"and anything built on it prints[/grey62]")
return 0
def cmd_flights(args) -> int:
"""Turn a log of ADS-B frames into something worth looking at.
@ -1270,6 +2002,10 @@ def cmd_flights(args) -> int:
options.theme = args.theme
if getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity
for flag in ("pulse", "pulse_rate", "echo", "echo_every", "echo_size"):
value = getattr(args, flag, None)
if value is not None:
setattr(options, flag, value)
if getattr(args, "rings", None) is not None:
options.rings = args.rings
if getattr(args, "window_rings", None) is not None:
@ -1466,6 +2202,17 @@ def main(argv=None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
# After parsing, so that --help and a bad argument say their piece
# without a title screen over the top of it, and so that anything piped
# somewhere else gets nothing at all.
from . import splash
splash.show(console, splash.SCANNER_NAME,
("Conceived by: The Dust Council",
"100% AI Coded by Claude Code.",
f"{__version__} · scan, record and identify"),
no_splash=getattr(args, "no_splash", False))
if args.command is None:
cfg, source = load_default()
if is_first_run() and sys.stdin.isatty() and sys.stdout.isatty():
@ -1500,7 +2247,10 @@ def main(argv=None) -> int:
"config": cmd_config, "transcribe": cmd_transcribe,
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
"flights": cmd_flights,
"flights": cmd_flights, "weather": cmd_weather,
"readings": cmd_readings, "sensors": cmd_sensors,
"aprs": cmd_aprs, "packets": cmd_packets,
"ft8": cmd_ft8,
}
try:
return handlers[args.command](args)
@ -1511,5 +2261,43 @@ def main(argv=None) -> int:
return 0
def cmd_ft8(args) -> int:
"""Park on an FT8 channel and decode every slot.
Fifteen seconds of everybody at once: one dial frequency carries the
whole band's worth of stations, fifty hertz apart across three
kilohertz of audio, and most of them arrive below the noise.
"""
from . import ft8
cfg, _ = load_default()
options = ft8.load_options()
for flag, key in (("device", "device"), ("gain", "gain"),
("rate", "rate"), ("band", "band"),
("frequency", "frequency"), ("simulate", "simulate"),
("seconds", "seconds"), ("slots", "slots"),
("log", "log"), ("decodes_seen", "decodes_seen"),
("hold", "hold"), ("lowest", "lowest"),
("highest", "highest"), ("most", "most"),
("rounds", "rounds"), ("grid", "grid"),
("lookup", "lookup"),
("units", "units"), ("calls_only", "calls_only"),
("report", "report"), ("csv", "csv"),
("adif", "adif")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
# The band picks the frequency unless the frequency was given outright,
# which is the one order that lets both flags mean what they say.
if getattr(args, "band", None) and getattr(args, "frequency", None) is None:
ft8.use_band(options, options.band)
errs = options.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
ft8.listen(console, options, cfg.output_dir)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -44,6 +44,7 @@ __all__ = ["Animation", "Projection", "animate", "render_frame", "write_gif",
"ground_for", "background", "label_lines", "draw_flag",
"set_theme", "theme", "THEME", "bloom", "draw_home",
"draw_rings", "RING_STEPS", "ring_labels",
"pulse_at", "phase_of", "echo_age", "hot_step",
"FLAG", "FAINT", "flag_index", "dim_ground", "local_airports",
"showing", "fade_ramp",
"GROUND_BRIGHTNESS"]
@ -87,6 +88,24 @@ HOME = LEADER + 3
# theme, to be taken for one; this is the brightest red there is and the one
# furthest from every altitude colour in every theme.
HOME_RED = (255, 0, 0)
# The bright peak of a pulsing aircraft: the altitude colours pushed towards
# white, so that an aeroplane at the top of its pulse burns rather than
# merely being its own colour again.
#
# Sixteen of them rather than thirty-two, because sixteen is what is left of
# the palette -- 255 is the transparent index and can never be drawn with.
# Sixteen steps of altitude at the top of a pulse is plenty: it is a moment
# of a cycle, and the aeroplane spends the rest of the cycle in the full
# thirty-two.
HOT = HOME + 3
HOT_STEPS = 16
HOT_LIFT = 0.55 # how far towards white the peak is pushed
# How long one pulse of an aircraft takes, how often an echo leaves it, and
# how far an echo gets before it has faded to nothing.
PULSE_SECONDS = 2.2
ECHO_SECONDS = 3.0
ECHO_REACH = 46 # pixels
RAMP_STEPS = 32
GROUND_SHADES = 32
TRANSPARENT = 255 # never drawn with: it means "as the frame before"
@ -234,6 +253,11 @@ def _palette(theme=None) -> np.ndarray:
for colour in (theme.leader, HOME_RED):
table += [colour, _dimmed([colour], part)[0],
_dimmed([colour], part * part)[0]]
# The pulse's bright peak: each altitude colour lifted towards white.
for i in range(HOT_STEPS):
base = ramp[min(RAMP_STEPS - 1,
round(i * (RAMP_STEPS - 1) / max(1, HOT_STEPS - 1)))]
table.append(tuple(int(round(c + (255 - c) * HOT_LIFT)) for c in base))
table += [(0, 0, 0)] * (256 - len(table))
return np.array(table[:256], dtype=np.uint8)
@ -968,7 +992,9 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
project: bool = True, known=None,
fade: float = 0.0, places: "LabelPlaces | None" = None,
step: float = 0.0, home=None,
box_opacity: float = 0.0) -> np.ndarray:
box_opacity: float = 0.0, beat: float = 0.0,
pulse: float = 0.0, echo: float = 0.0,
echo_reach: float = ECHO_REACH) -> np.ndarray:
"""The map at one moment: where everything was, and where it had been.
``project`` is what makes an animation an animation: between reports an
@ -1011,7 +1037,19 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
_line(img, x0, y0, x1, y1, shade)
colour = faded + altitude_step(now.altitude_ft)
x, y = view.xy(now.latitude, now.longitude)
beats = phase_of(track.icao)
# The echo goes down before the aircraft, so the ring passes under
# the thing that sent it out rather than over it.
if echo > 0 and strength >= 1.0:
_echo(img, x, y, beat, echo, echo_reach, beats,
altitude_step(now.altitude_ft))
_marker(img, x, y, now.track_deg, colour)
# Only an aircraft still being heard pulses. One that has gone
# quiet is fading, and a thing that is fading and beating at once
# says two contradictory things about itself.
if pulse > 0 and strength >= 1.0:
_pulse(img, x, y, now.track_deg, now.altitude_ft,
pulse_at(beat, pulse, beats))
if labels and strength > LABEL_WHILE:
_label(img, x, y, track, now, colour, taken, unit,
entry=(known.get(track.icao)
@ -1062,6 +1100,166 @@ def showing(track: Track, when: float, stale: float = 300.0,
return last, max(0.0, 1.0 - gone / fade)
def pulse_at(beat: float, rate: float = PULSE_SECONDS,
phase: float = 0.0) -> float:
"""How far up its pulse a thing is now: nought dimmest, one brightest.
A raised cosine rather than a sawtooth, so that the bright end is a
swell and not a flash -- what a phosphor does when the beam lingers, not
what a warning light does.
"""
if rate <= 0:
return 1.0
turn = (beat / rate + phase) * math.tau
return 0.5 - 0.5 * math.cos(turn)
def phase_of(icao: str) -> float:
"""Where in its cycle one aircraft is, so they do not all beat as one.
Taken from the address, which never changes, so an aeroplane keeps its
own rhythm from one frame to the next and from one drawing of the same
log to the next. Aircraft pulsing in step read as one flashing display
rather than as a sky full of separate things.
"""
return (int(icao[-4:], 16) % 997) / 997.0 if icao else 0.0
def echo_age(beat: float, every: float, phase: float = 0.0) -> float:
"""How long ago the echo now travelling outward left the aircraft.
One at a time: a new one leaves as the last reaches the end of its
reach, so the sky has one ring per aircraft rather than a stack of them
to draw and read.
"""
if every <= 0:
return 0.0
return ((beat / every + phase) % 1.0) * every
def hot_step(feet: float) -> int:
"""Which of the sixteen peak colours a height falls in."""
return int(min(HOT_STEPS - 1,
max(0, round(altitude_step(feet)
* (HOT_STEPS - 1) / (RAMP_STEPS - 1)))))
def _ring(img: np.ndarray, cx: int, cy: int, radius: int,
colour: int) -> None:
"""A circle, one pixel thick, clipped to the canvas.
The midpoint algorithm, which is the circle's answer to Bresenham: no
trigonometry per pixel and no gaps, since it steps one pixel at a time
round an eighth of the circle and mirrors that eighth into the other
seven.
"""
height, width = img.shape
r = int(radius)
if r < 1:
return
x, y, err = r, 0, 1 - r
while x >= y:
for px, py in ((cx + x, cy + y), (cx + y, cy + x),
(cx - y, cy + x), (cx - x, cy + y),
(cx - x, cy - y), (cx - y, cy - x),
(cx + y, cy - x), (cx + x, cy - y)):
if 0 <= px < width and 0 <= py < height:
img[py, px] = colour
y += 1
if err < 0:
err += 2 * y + 1
else:
x -= 1
err += 2 * (y - x) + 1
def _pulse(img: np.ndarray, x: int, y: int, heading: float, feet: float,
level: float) -> None:
"""Redraw one aircraft at whatever point of its pulse it has reached.
An indexed picture cannot dim a colour by a fraction, so a swell here is
two things at once: which family of colours the aeroplane is drawn from,
and how far the halo round it reaches. Between them they give six
steps rather than two, which at a couple of seconds a cycle reads as a
swell rather than as a blink.
At the top of it the aeroplane is drawn in the peak colours and carries
two rings of halo, which is the raster burn of a beam that has sat in
one place a little too long.
"""
step = altitude_step(feet)
if level >= 0.72:
_burn(img, x, y, heading, 2, TRAIL + step, OLD + step)
_marker(img, x, y, heading, HOT + hot_step(feet))
elif level >= 0.45:
_burn(img, x, y, heading, 1, TRAIL + step, OLD + step)
_marker(img, x, y, heading, RAMP + step)
elif level >= 0.22:
_marker(img, x, y, heading, RAMP + step)
else:
_marker(img, x, y, heading, TRAIL + step)
def _burn(img: np.ndarray, x: int, y: int, heading: float, rings: int,
near: int, far: int) -> None:
"""The halo round a burning aircraft: the shape itself, spread outwards.
Grown from the aeroplane rather than drawn as a circle round it, so the
glow has the shape of the thing casting it -- which is what a phosphor
does, and what a ring of dots emphatically does not. The marker is
stamped into a scrap of its own, spread a pixel at a time, and only the
spread part is painted back, and only where the picture was still empty.
Never over anything else that was drawn: a halo is what light does to
the dark around a thing, and painting it over a neighbouring aeroplane
would be light doing something light does not do.
"""
height, width = img.shape
pad = rings + 8
left, top = x - pad, y - pad
right, bottom = x + pad + 1, y + pad + 1
if right <= 0 or bottom <= 0 or left >= width or top >= height:
return
scrap = np.zeros((2 * pad + 1, 2 * pad + 1), dtype=np.uint8)
_marker(scrap, pad, pad, heading, 1)
grown = scrap.copy()
for ring in range(1, rings + 1):
spread = grown.copy()
for dy, dx in ((0, 1), (0, -1), (1, 0), (-1, 0)):
shifted = np.roll(grown, (dy, dx), axis=(0, 1))
spread = np.where((spread == 0) & (shifted != 0), ring + 1, spread)
grown = spread
patch = img[max(0, top):min(height, bottom),
max(0, left):min(width, right)]
cut = grown[max(0, -top):max(0, -top) + patch.shape[0],
max(0, -left):max(0, -left) + patch.shape[1]]
empty = ((patch == BG) | (patch == GRID)
| ((patch >= GROUND) & (patch < GROUND + GROUND_SHADES)))
for ring in range(rings, 0, -1):
colour = near if ring == 1 else far
patch[:] = np.where(empty & (cut == ring + 1), colour, patch)
def _echo(img: np.ndarray, x: int, y: int, beat: float, every: float,
reach: float, phase: float, step: int) -> None:
"""One ring travelling outward from an aircraft, dimming as it grows.
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, which is how it got here at all.
"""
age = echo_age(beat, every, phase)
part = age / max(1e-9, every)
radius = int(round(reach * part))
if radius < 3:
return
# Dimmer the further out it has got, in the three steps the palette
# holds: the ring is the aircraft's own colour, spent.
shade = TRAIL if part < 0.34 else (OLD if part < 0.67 else FAINT)
_ring(img, x, y, radius, shade + step)
def _marker(img: np.ndarray, x: int, y: int, heading: float,
colour: int) -> None:
"""A little arrowhead, pointing the way the aircraft is going.
@ -1656,7 +1854,10 @@ def ground_for(view: Projection, fetch=None, url: str = "") -> tuple:
extra = {"fetch": fetch} if fetch is not None else {}
if url:
extra["url"] = url
levels = basemap.ground_under(
# A still picture gets one attempt at the tiles -- there is no
# window to ask again from -- so whether the answer was complete
# changes nothing here. It already retried the misses itself.
levels, _settled = basemap.ground_under(
view.south, view.west, view.north, view.east,
view.width, view.height, shades=GROUND_SHADES, **extra)
except Exception:
@ -1673,7 +1874,8 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
radius_nm: float = 0.0, centre=None,
brightness: float = GROUND_BRIGHTNESS,
airports: bool = False, ask=None, rings: bool = False,
box_opacity: float = 0.0,
box_opacity: float = 0.0, pulse: float = 0.0,
echo: float = 0.0, echo_reach: float = ECHO_REACH,
fade: float = 0.0) -> Animation | None:
"""Draw the whole log as a moving map.
@ -1764,7 +1966,14 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
clock=clock, unit=unit, known=known,
fade=fade, places=places, step=step,
home=centre,
box_opacity=box_opacity),
box_opacity=box_opacity,
# Seconds of watching, not of flying:
# a pulse is meant to look the same
# whatever speed the evening is being
# run through.
beat=i / fps if fps else 0.0,
pulse=pulse, echo=echo,
echo_reach=echo_reach),
canvas_w, canvas_h)
path = Path(out_path)

1013
bandsaunter/ft8.py Normal file

File diff suppressed because it is too large Load diff

712
bandsaunter/ft8code.py Normal file
View file

@ -0,0 +1,712 @@
"""What an FT8 transmission carries, and how it is wrapped.
No radio in here at all. This is the part between a string of one hundred
and seventy-four bits and a line somebody can read: the checksum, the
error-correcting code that makes FT8 work at signal levels where the
operator hears nothing, the Gray mapping onto eight tones, and the
seventy-seven bits that hold two callsigns and a grid square.
The shape of it, outward from the message:
77 bits what was said: two callsigns and a report, or free text
+ 14 bits a CRC, so a wrong answer is caught rather than printed
= 91 bits
+ 83 bits LDPC parity, so a wrong answer is usually *repaired*
= 174 bits
/ 3 three bits to a tone, in Gray order
= 58 tones
+ 21 tones three Costas arrays, at the start, the middle and the end
= 79 tones at 6.25 Hz apart and 6.25 to the second: 12.64 seconds
The error correction is the whole trick. Fifty-eight tones of payload
carried in one hundred and seventy-four bits is a rate of about one half:
half of what is transmitted is redundancy, and that is what buys a protocol
that decodes at twenty-odd decibels below the noise in the same bandwidth.
A receiver that only took the strongest tone in each symbol and hoped would
decode almost nothing -- which is why the tone detector below reports how
confident it is, bit by bit, rather than just what it thinks it heard.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from .ft8tables import (BITS, CHECKS, COSTAS, CRC_BITS, CRC_POLYNOMIAL,
GENERATOR, GRAY, PARITY, PAYLOAD, TONES)
__all__ = ["crc14", "with_crc", "crc_holds", "encode", "repair",
"parity_holds", "tones_of",
"bits_of", "unpack", "pack", "CallBook", "Message", "MESSAGE_BITS",
"SYMBOL_HZ", "SYMBOL_S", "SLOT_S", "SENDING_S", "COSTAS", "TONES",
"hash_of", "free_text", "is_standard_call"]
MESSAGE_BITS = 77 # what is actually said, before the checksum
# The channel, in numbers. Six and a quarter of everything: the tones are
# 6.25 Hz apart and there are 6.25 of them a second, which makes each tone
# exactly one cycle of separation long and the whole thing orthogonal.
SYMBOL_HZ = 6.25
SYMBOL_S = 1.0 / SYMBOL_HZ
SENDING_S = TONES * SYMBOL_S # 12.64 s of transmission
SLOT_S = 15.0 # in a slot of fifteen
# ---------------------------------------------------------------------------
# The checksum
# ---------------------------------------------------------------------------
def crc14(bits) -> int:
"""CRC-14 over a sequence of bits, most significant first.
The one thing standing between a repaired codeword and a confident lie.
The error correction below will happily converge on *a* valid codeword
from noise; whether it is the codeword that was sent is what this
answers, and one in sixteen thousand wrong answers gets through, which
over an evening is a handful of lines nobody should trust. Every
decoder prints them anyway, and so does this one -- but only after the
CRC has agreed, which is the difference between a handful and a flood.
"""
top = 1 << (CRC_BITS - 1)
mask = (top << 1) - 1
remainder = 0
for i, bit in enumerate(bits):
if i % 8 == 0:
# A byte at a time into the top of the register, exactly as the
# specification frames it: the message is a byte sequence and
# the odd bits at the end are zeros.
byte = 0
for k in range(8):
byte = (byte << 1) | (bits[i + k] if i + k < len(bits) else 0)
remainder ^= byte << (CRC_BITS - 8)
remainder = ((remainder << 1) ^ CRC_POLYNOMIAL if remainder & top
else remainder << 1)
return remainder & mask
def with_crc(payload) -> np.ndarray:
"""The seventy-seven bits said, plus the fourteen that check them.
The checksum covers eighty-two bits rather than seventy-seven: the
message zero-extended by five, which is what the specification says and
is not the same answer as checksumming seventy-seven.
"""
payload = np.asarray(payload, dtype=np.uint8)
if payload.size != MESSAGE_BITS:
raise ValueError(f"a message is {MESSAGE_BITS} bits, not "
f"{payload.size}")
extended = np.concatenate([payload, np.zeros(5, dtype=np.uint8)])
check = crc14(extended.tolist())
tail = np.array([(check >> (CRC_BITS - 1 - k)) & 1
for k in range(CRC_BITS)], dtype=np.uint8)
return np.concatenate([payload, tail])
def crc_holds(bits91) -> bool:
"""Whether the checksum on a decoded block agrees with its message."""
bits91 = np.asarray(bits91, dtype=np.uint8)
if bits91.size != PAYLOAD:
return False
return bool((with_crc(bits91[:MESSAGE_BITS]) == bits91).all())
# ---------------------------------------------------------------------------
# The error-correcting code
# ---------------------------------------------------------------------------
def _generator() -> np.ndarray:
"""The parity half of the generator, unpacked to bits."""
rows = np.zeros((PARITY, PAYLOAD), dtype=np.uint8)
for m, row in enumerate(GENERATOR):
packed = bytes.fromhex(row)
for k in range(PAYLOAD):
rows[m, k] = (packed[k // 8] >> (7 - k % 8)) & 1
return rows
GEN = _generator()
def encode(bits91) -> np.ndarray:
"""Ninety-one bits in, one hundred and seventy-four out.
Systematic: the message comes out unchanged at the front and the parity
is appended, which is why the decoder can read the message straight off
a repaired codeword without undoing anything.
"""
bits91 = np.asarray(bits91, dtype=np.uint8)
if bits91.size != PAYLOAD:
raise ValueError(f"expected {PAYLOAD} bits, got {bits91.size}")
parity = (GEN @ bits91) & 1
return np.concatenate([bits91, parity.astype(np.uint8)])
def _sparse():
"""The parity-check matrix as flat slots, for the message passing.
Every codeword bit sits in exactly three checks and every check covers
six or seven bits, so the whole graph fits in two small index arrays and
the decoding never needs a scatter-add. The short checks are padded to
seven with a slot pointing at a dummy bit that is held at no opinion.
"""
width = max(len(c) for c in CHECKS)
bit_of_slot = np.full(PARITY * width, BITS, dtype=np.int64) # BITS = dummy
live = np.zeros(PARITY * width, dtype=bool)
for m, check in enumerate(CHECKS):
for j, one_based in enumerate(check):
bit_of_slot[m * width + j] = one_based - 1
live[m * width + j] = True
# And the other way: for each bit, the slots that talk about it.
per_bit = [[] for _ in range(BITS)]
for slot in np.flatnonzero(live):
per_bit[bit_of_slot[slot]].append(slot)
degree = max(len(s) for s in per_bit)
slots_of_bit = np.zeros((BITS, degree), dtype=np.int64)
for n, slots in enumerate(per_bit):
slots_of_bit[n] = slots + [slots[-1]] * (degree - len(slots))
return width, bit_of_slot, live, slots_of_bit
WIDTH, BIT_OF_SLOT, LIVE, SLOTS_OF_BIT = _sparse()
def parity_holds(bits174) -> bool:
"""Whether every one of the eighty-three checks comes out even.
Here rather than left to callers because the padding convention -- short
checks point their spare slots at a dummy bit past the end -- is an
implementation detail of the message passing, and anything outside this
module that had to know about it would be a bug waiting to be written.
"""
bits174 = np.asarray(bits174, dtype=np.uint8)
if bits174.size != BITS:
return False
padded = np.concatenate([bits174, np.zeros(1, dtype=np.uint8)])
sums = padded[BIT_OF_SLOT].copy()
sums[~LIVE] = 0
return bool((sums.reshape(PARITY, WIDTH).sum(axis=1) % 2 == 0).all())
def repair(llr, rounds: int = 30, alpha: float = 0.75):
"""Belief propagation over one or many candidate codewords.
``llr`` is how strongly each bit is believed to be a zero: positive for
zero, negative for one, and the size of it is the confidence. Shape
(174,) for one candidate or (n, 174) for a batch, which is how it is
actually used -- a busy slot throws up hundreds of candidates and doing
them one at a time is most of the decoding time.
Normalised min-sum rather than the exact sum-product: it is within a
few tenths of a decibel of it, and it is multiplication-free in the
inner loop, which at this size matters more than the tenths.
Returns the repaired bits, or None where the checks never came out
even. An answer here still has to pass the CRC before it is believed:
this finds *a* codeword, and noise has valid codewords in it too.
"""
single = np.ndim(llr) == 1
belief = np.atleast_2d(np.asarray(llr, dtype=np.float32))
n = belief.shape[0]
if belief.shape[1] != BITS:
raise ValueError(f"a codeword is {BITS} bits, not {belief.shape[1]}")
messages = np.zeros((n, PARITY * WIDTH), dtype=np.float32)
dead = ~LIVE
dead_grid = dead.reshape(PARITY, WIDTH)
out = np.zeros((n, BITS), dtype=np.uint8)
done = np.zeros(n, dtype=bool)
def totals(msgs):
"""What every bit is believed to be, once its three checks are in.
A gather and a sum rather than a scatter-add: every bit sits in
exactly three checks, so the three places to look are known in
advance and np.add.at -- which is where an earlier version of this
spent most of its time -- is not needed at all.
"""
return belief + msgs[:, SLOTS_OF_BIT].sum(axis=2)
for _ in range(rounds):
total = totals(messages)
# What each check hears about a bit, less what it said itself.
wide = np.concatenate([total, np.zeros((n, 1), dtype=np.float32)],
axis=1)
heard = (wide[:, BIT_OF_SLOT] - messages).reshape(n, PARITY, WIDTH)
size = np.abs(heard)
size[:, dead_grid] = np.inf
# The smallest and the next smallest in each check, so "the
# smallest of the others" is a lookup rather than a loop.
order = np.argsort(size, axis=2)
smallest = np.take_along_axis(size, order[:, :, :1], axis=2)
next_up = np.take_along_axis(size, order[:, :, 1:2], axis=2)
others = np.where(size == smallest, next_up, smallest)
odd = (heard < 0).sum(axis=2, keepdims=True) % 2 == 1
mine = np.where(heard < 0, -1.0, 1.0).astype(np.float32)
whole = np.where(odd, -1.0, 1.0).astype(np.float32)
messages = (alpha * whole * mine
* np.minimum(others, 1e4)).astype(np.float32)
messages = messages.reshape(n, -1)
messages[:, dead] = 0.0
hard = (totals(messages) < 0).astype(np.uint8)
wide_hard = np.concatenate([hard, np.zeros((n, 1), dtype=np.uint8)],
axis=1)
sums = wide_hard[:, BIT_OF_SLOT].reshape(n, PARITY, WIDTH).copy()
sums[:, dead_grid] = 0
even = (sums.sum(axis=2) % 2 == 0).all(axis=1)
fresh = even & ~done
if fresh.any():
out[fresh] = hard[fresh]
done |= fresh
if done.all():
break
if single:
return out[0] if done[0] else None
return out, done
# ---------------------------------------------------------------------------
# Tones
# ---------------------------------------------------------------------------
UNGRAY = np.zeros(8, dtype=np.uint8)
for _value, _tone in enumerate(GRAY):
UNGRAY[_tone] = _value
SYNC_AT = (0, 36, 72) # where the three Costas arrays sit
def tones_of(bits174) -> np.ndarray:
"""The seventy-nine tones that carry these bits, sync included."""
bits174 = np.asarray(bits174, dtype=np.uint8)
if bits174.size != BITS:
raise ValueError(f"a codeword is {BITS} bits, not {bits174.size}")
trips = bits174.reshape(-1, 3)
values = trips[:, 0] * 4 + trips[:, 1] * 2 + trips[:, 2]
data = np.array([GRAY[v] for v in values], dtype=np.uint8)
out = np.zeros(TONES, dtype=np.uint8)
costas = np.array(COSTAS, dtype=np.uint8)
taken = 0
for i in range(TONES):
which = i // 36 if i % 36 < 7 else -1
if i in range(0, 7) or i in range(36, 43) or i in range(72, 79):
out[i] = costas[(i - (0 if i < 7 else 36 if i < 43 else 72))]
else:
out[i] = data[taken]
taken += 1
return out
def bits_of(tones) -> np.ndarray:
"""The bits those tones carry, sync thrown away."""
tones = np.asarray(tones, dtype=np.uint8)
if tones.size != TONES:
raise ValueError(f"a transmission is {TONES} tones, not {tones.size}")
data = [tones[i] for i in range(TONES)
if not (i < 7 or 36 <= i < 43 or 72 <= i)]
out = np.zeros(BITS, dtype=np.uint8)
for k, tone in enumerate(data):
value = UNGRAY[tone]
out[3 * k] = (value >> 2) & 1
out[3 * k + 1] = (value >> 1) & 1
out[3 * k + 2] = value & 1
return out
# ---------------------------------------------------------------------------
# Callsigns
# ---------------------------------------------------------------------------
ALPHANUM_SPACE = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 37
ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 36
NUMERIC = "0123456789" # 10
LETTERS_SPACE = " ABCDEFGHIJKLMNOPQRSTUVWXYZ" # 27
FULL = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+-./?" # 42
WITH_SLASH = " 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ/" # 38
TOKENS = 2_063_592 # DE, QRZ, CQ and the CQ-with-an-argument forms
MAX22 = 4_194_304 # room for a hashed callsign
MAX_GRID = 32_400 # four-character grids, before the report forms
def hash_of(call: str, width: int = 22) -> int:
"""The short hash a compound callsign is carried as.
A callsign that will not fit in twenty-eight bits -- anything with a
slash in it, and anything long -- travels as a hash, and the receiver
is expected to have heard the full call earlier in the exchange and to
remember it. That is why a fresh receiver prints <...>: not a decoding
failure, but a station whose full name has not been said yet in
anything this receiver heard.
"""
packed = 0
padded = (call.upper() + " ")[:11]
for ch in padded:
packed = packed * 38 + (WITH_SLASH.index(ch) if ch in WITH_SLASH else 0)
return int((47_055_833_459 * packed) >> (64 - width)) & ((1 << width) - 1)
def is_standard_call(call: str) -> bool:
"""Whether a callsign fits the twenty-eight bit form.
One or two characters of prefix, a digit, and up to three letters. Most
callsigns on earth; not the ones with a slash in them.
"""
return _pack_standard(call) is not None
def _pack_standard(call: str):
"""A plain callsign as its number, or None if it is not a plain one.
The form is one or two characters of prefix, exactly one digit, and up
to three letters -- and the twenty-eight bit packing wants the digit in
the third place. Which character is *the* digit cannot be found by
looking at the second one: Z33Z has a digit there and it belongs to the
prefix. It is the last character that is not one of the trailing
letters, so that is how it is found.
"""
call = (call or "").strip().upper()
if not call or "/" in call or len(call) > 6:
return None
at = len(call) - 1
while at >= 0 and call[at].isalpha():
at -= 1
if at < 0 or at > 2 or not call[at].isdigit():
return None
call = " " * (2 - at) + call
if len(call) > 6:
return None
call = (call + " ")[:6]
if (call[0] not in ALPHANUM_SPACE or call[1] not in ALPHANUM
or call[2] not in NUMERIC):
return None
if any(c not in LETTERS_SPACE for c in call[3:6]):
return None
n = ALPHANUM_SPACE.index(call[0])
n = n * 36 + ALPHANUM.index(call[1])
n = n * 10 + NUMERIC.index(call[2])
n = n * 27 + LETTERS_SPACE.index(call[3])
n = n * 27 + LETTERS_SPACE.index(call[4])
n = n * 27 + LETTERS_SPACE.index(call[5])
return n
def _unpack_standard(n: int) -> str:
out = [""] * 6
out[5] = LETTERS_SPACE[n % 27]
n //= 27
out[4] = LETTERS_SPACE[n % 27]
n //= 27
out[3] = LETTERS_SPACE[n % 27]
n //= 27
out[2] = NUMERIC[n % 10]
n //= 10
out[1] = ALPHANUM[n % 36]
n //= 36
out[0] = ALPHANUM_SPACE[n % 37]
return "".join(out).strip()
@dataclass
class CallBook:
"""Full callsigns heard earlier, so a hash can be turned back into one.
A compound callsign travels as a hash and is expected to be remembered
from earlier in the same exchange. This is that memory. It is only
ever added to by callsigns that arrived in full and passed their CRC,
so a station that was never heard properly stays <...> rather than being
guessed at -- a wrong callsign in a log is worse than a missing one.
"""
by_hash: dict = field(default_factory=dict)
def remember(self, call: str) -> None:
call = (call or "").strip().upper()
if len(call) < 3 or call in ("CQ", "DE", "QRZ"):
return
for width in (10, 12, 22):
self.by_hash[(width, hash_of(call, width))] = call
def look_up(self, value: int, width: int = 22) -> str:
return self.by_hash.get((width, value), "<...>")
def _unpack28(n: int, book: CallBook) -> str:
"""One of the two callsign fields of a standard message."""
if n < TOKENS:
if n == 0:
return "DE"
if n == 1:
return "QRZ"
if n == 2:
return "CQ"
if n <= 1002:
return f"CQ {n - 3:03d}"
if n <= 532_443:
rest = n - 1003
letters = [""] * 4
for i in (3, 2, 1, 0):
letters[i] = LETTERS_SPACE[rest % 27]
rest //= 27
return "CQ " + "".join(letters).strip()
return "<...>"
n -= TOKENS
if n < MAX22:
return book.look_up(n, 22)
return _unpack_standard(n - MAX22)
def _unpack_grid(value: int, rover: int) -> str:
"""The fifteen-bit field: a grid square, a signal report, or a sign-off."""
if value <= MAX_GRID:
n = value
out = ["", "", "", ""]
out[3] = NUMERIC[n % 10]
n //= 10
out[2] = NUMERIC[n % 10]
n //= 10
out[1] = chr(ord("A") + n % 18)
n //= 18
out[0] = chr(ord("A") + n % 18)
grid = "".join(out)
return ("R " + grid) if rover else grid
report = value - MAX_GRID
if report == 1:
return ""
if report == 2:
return "RRR"
if report == 3:
return "RR73"
if report == 4:
return "73"
decibels = report - 35
return f"{'R' if rover else ''}{decibels:+03d}"
def _pack_grid(text: str):
"""The inverse: a grid, a report or a token as fifteen bits and a flag."""
text = (text or "").strip().upper()
if not text:
return MAX_GRID + 1, 0
if text == "RRR":
return MAX_GRID + 2, 0
if text == "RR73":
return MAX_GRID + 3, 0
if text == "73":
return MAX_GRID + 4, 0
rover = 0
if text.startswith("R ") or (text.startswith("R") and len(text) > 1
and text[1] in "+-"):
rover, text = 1, text[2:].strip() if text[1] == " " else text[1:]
if (len(text) == 4 and "A" <= text[0] <= "R" and "A" <= text[1] <= "R"
and text[2].isdigit() and text[3].isdigit()):
value = ((ord(text[0]) - 65) * 18 + (ord(text[1]) - 65)) * 10
value = (value + int(text[2])) * 10 + int(text[3])
return value, rover
try:
return MAX_GRID + 35 + int(text), rover
except ValueError:
return None
# ---------------------------------------------------------------------------
# Messages
# ---------------------------------------------------------------------------
@dataclass
class Message:
"""One decoded line, and what could be picked out of it."""
text: str = ""
kind: str = "" # standard, free text, telemetry, ...
calls: tuple = () # the callsigns in it, in order
grid: str = "" # a four-character grid, where there was one
report: str = "" # a signal report, where there was one
calling: bool = False # whether this is a CQ
def _number(bits, start: int, count: int) -> int:
value = 0
for k in range(count):
value = (value << 1) | int(bits[start + k])
return value
def _put(bits, start: int, count: int, value: int) -> None:
for k in range(count):
bits[start + k] = (value >> (count - 1 - k)) & 1
def free_text(bits77) -> str:
"""The thirteen-character form: anything at all, at a price.
Base forty-two over seventy-one bits, which is thirteen characters and
no callsign structure -- so a receiver cannot tell who sent it from the
message alone, and nor can the network that reports these things.
"""
n = _number(bits77, 0, 71)
out = [""] * 13
for i in range(12, -1, -1):
out[i] = FULL[n % 42]
n //= 42
return "".join(out).strip()
def unpack(bits77, book: CallBook | None = None) -> Message:
"""Seventy-seven bits as something to read.
The type is the last three bits, which is an odd place for it until you
remember that everything before it is a different length in each form.
"""
bits77 = np.asarray(bits77, dtype=np.uint8)
book = book or CallBook()
i3 = _number(bits77, 74, 3)
if i3 == 0:
n3 = _number(bits77, 71, 3)
if n3 == 0:
return Message(text=free_text(bits77), kind="free text")
if n3 == 5:
value = _number(bits77, 0, 71)
return Message(text=f"{value:018X}", kind="telemetry")
return Message(text=free_text(bits77), kind="free text")
if i3 in (1, 2):
a = _number(bits77, 0, 28)
a_rover = _number(bits77, 28, 1)
b = _number(bits77, 29, 28)
b_rover = _number(bits77, 57, 1)
rover = _number(bits77, 58, 1)
grid = _number(bits77, 59, 15)
one = _unpack28(a, book)
two = _unpack28(b, book)
if a_rover:
one += "/R"
if b_rover:
two += "/R"
extra = _unpack_grid(grid, rover)
text = " ".join(x for x in (one, two, extra) if x)
looks_like_grid = (len(extra) == 4 and extra[:2].isalpha()
and extra[2:].isdigit())
return Message(text=text, kind="standard",
calls=tuple(c for c in (one, two)
if c not in ("CQ", "DE", "QRZ")
and not c.startswith("CQ ")),
grid=extra if looks_like_grid else "",
report="" if looks_like_grid else extra,
calling=one.startswith("CQ") or one == "QRZ")
if i3 == 4:
# A compound callsign: one hashed, one carried in full as eleven
# characters of base thirty-eight.
short = _number(bits77, 0, 12)
n58 = _number(bits77, 12, 58)
flip = _number(bits77, 70, 1)
kind = _number(bits77, 71, 2)
rest = n58
letters = [""] * 11
for i in range(10, -1, -1):
letters[i] = WITH_SLASH[rest % 38]
rest //= 38
full = "".join(letters).strip()
other = book.look_up(short, 12)
one, two = (full, other) if flip else (other, full)
tail = {1: "RRR", 2: "RR73", 3: "73"}.get(kind, "")
text = " ".join(x for x in (one, two, tail) if x)
return Message(text=text, kind="non-standard",
calls=tuple(c for c in (one, two) if c != "<...>"),
report=tail, calling=one.startswith("CQ"))
return Message(text=free_text(bits77), kind=f"type {i3}")
def pack(text: str, book: CallBook | None = None):
"""The inverse, for the standard and free-text forms.
Here so the decoder can be tested against something that is not itself.
A receiver never needs to build a message -- this one does not transmit
-- but a test that encodes with the same misunderstanding it decodes
with agrees with itself about anything they are both wrong about.
"""
text = (text or "").strip().upper()
bits = np.zeros(MESSAGE_BITS, dtype=np.uint8)
words = text.split()
if len(words) in (2, 3) or (len(words) == 4 and words[0] == "CQ"):
made = _pack_standard_message(words, bits)
if made is not None:
return made
return _pack_free_text(text, bits)
def _pack_standard_message(words, bits):
if words[0] == "CQ" and len(words) >= 3 and len(words[1]) <= 4 \
and not _is_call(words[1]):
one, rest = f"CQ {words[1]}", words[2:]
else:
one, rest = words[0], words[1:]
if not rest:
return None
two, extra = rest[0], (rest[1] if len(rest) > 1 else "")
a = _pack28(one)
b = _pack28(two)
if a is None or b is None:
return None
grid = _pack_grid(extra)
if grid is None:
return None
value, rover = grid
_put(bits, 0, 28, a)
_put(bits, 28, 1, 0)
_put(bits, 29, 28, b)
_put(bits, 57, 1, 0)
_put(bits, 58, 1, rover)
_put(bits, 59, 15, value)
_put(bits, 74, 3, 1)
return bits
def _is_call(word: str) -> bool:
return any(c.isdigit() for c in word) and _pack_standard(word) is not None
def _pack28(call: str):
call = call.strip().upper()
if call == "DE":
return 0
if call == "QRZ":
return 1
if call == "CQ":
return 2
if call.startswith("CQ "):
rest = call[3:].strip()
if rest.isdigit() and len(rest) == 3:
return 3 + int(rest)
if 1 <= len(rest) <= 4 and all(c in LETTERS_SPACE for c in rest):
n = 0
for ch in (rest + " ")[:4]:
n = n * 27 + LETTERS_SPACE.index(ch)
return 1003 + n
return None
plain = _pack_standard(call)
if plain is not None:
return TOKENS + MAX22 + plain
# Deliberately not a hash. A hash always succeeds, and a packer that
# always succeeds would send "HELLO WORLD" as two hashed callsigns
# rather than as the free text it plainly is.
return None
def _pack_free_text(text: str, bits):
text = "".join(c if c in FULL else " " for c in text)[:13]
text = text.rjust(13)
n = 0
for ch in text:
n = n * 42 + FULL.index(ch)
_put(bits, 0, 71, n)
_put(bits, 71, 3, 0)
_put(bits, 74, 3, 0)
return bits

177
bandsaunter/ft8log.py Normal file
View file

@ -0,0 +1,177 @@
"""Writing FT8 decodes down.
Three forms, because three different things want to read them. The log is
for a person and for this program's own report; the CSV is for a
spreadsheet; the ADIF is for the logging and spotting programs every
amateur already has.
The ADIF is the one worth a note. It is the format for recording contacts,
and nothing here makes a contact -- this receiver does not transmit. So
what is written is a record of a station *heard*, marked as such, which is
what a propagation map or a reverse-beacon feed wants. Passing it off as a
worked contact would put claims into somebody's log that they cannot make.
"""
from __future__ import annotations
import csv
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from . import __version__
__all__ = ["Ft8Log", "open_log", "write_csv", "write_adif", "read_log",
"LOG_VERSION"]
LOG_VERSION = 1
@dataclass
class Ft8Log:
"""A file of decodes, one to a line, written as they arrive."""
path: Path
handle: object = None
lines: int = 0
def append(self, found) -> None:
if self.handle is None:
return
when = datetime.fromtimestamp(found.at, timezone.utc)
self.handle.write(
f"{when.strftime('%Y-%m-%d %H:%M:%S')} "
f"{found.snr_db:>4.0f} {found.offset:>5.1f} "
f"{found.hertz:>7.1f} ~ {found.text}\n")
self.handle.flush()
self.lines += 1
def close(self) -> None:
if self.handle is not None:
self.handle.close()
self.handle = None
def open_log(directory, started: float, frequency: float,
band: str = "") -> Ft8Log | None:
"""A new log for this run, or nothing if it cannot be opened."""
when = datetime.fromtimestamp(started, timezone.utc)
path = Path(directory) / f"ft8-{when.strftime('%Y%m%d-%H%M%S')}.txt"
try:
path.parent.mkdir(parents=True, exist_ok=True)
handle = open(path, "w")
except OSError:
return None
handle.write(f"# bandsaunter {__version__} FT8 log v{LOG_VERSION}\n")
handle.write(f"# {frequency / 1e6:g} MHz"
f"{(' ' + band) if band else ''}, times are UTC\n")
handle.write("# when snr dt hz ~ message\n")
handle.flush()
return Ft8Log(path=path, handle=handle)
def read_log(path) -> list[dict]:
"""A log back off the disk, for reading an evening again.
Deliberately forgiving: a line that cannot be parsed is skipped rather
than raising, because a log is often read while it is still being
written and the last line may be half there.
"""
out = []
try:
text = Path(path).read_text()
except OSError:
return out
for line in text.splitlines():
if line.startswith("#") or "~" not in line:
continue
head, message = line.split("~", 1)
parts = head.split()
if len(parts) < 5:
continue
try:
when = datetime.strptime(f"{parts[0]} {parts[1]}",
"%Y-%m-%d %H:%M:%S")
out.append({"at": when.replace(tzinfo=timezone.utc).timestamp(),
"snr_db": float(parts[2]), "offset": float(parts[3]),
"hertz": float(parts[4]), "text": message.strip()})
except ValueError:
continue
return out
def write_csv(path, decodes) -> Path | None:
try:
with open(path, "w", newline="") as fh:
writer = csv.writer(fh)
writer.writerow(["utc", "snr_db", "offset_s", "hertz", "kind",
"calls", "grid", "report", "cq", "message"])
for d in decodes:
when = datetime.fromtimestamp(d.at, timezone.utc)
writer.writerow([when.strftime("%Y-%m-%d %H:%M:%S"),
f"{d.snr_db:.0f}", f"{d.offset:.1f}",
f"{d.hertz:.1f}", d.kind,
" ".join(d.calls), d.grid, d.report,
"yes" if d.calling else "", d.text])
except OSError:
return None
return Path(path)
def _adif(field: str, value: str) -> str:
value = str(value)
return f"<{field}:{len(value)}>{value} "
def write_adif(path, decodes, band: str = "", frequency: float = 0.0,
my_grid: str = "") -> Path | None:
"""The decodes as ADIF records of stations heard.
One record per station rather than per decode: a logging program that
was handed forty identical records for one evening's CQ calls would be
right to complain. The strongest report is the one kept, that being
the one worth passing on.
"""
best: dict = {}
for d in decodes:
sender = d.calls[1] if len(d.calls) > 1 else (
d.calls[0] if d.calls else "")
if not sender or sender == "<...>":
continue
was = best.get(sender)
if was is None or d.snr_db > was.snr_db:
best[sender] = d
if d.grid and not getattr(best[sender], "grid", ""):
best[sender].grid = d.grid
try:
with open(path, "w") as fh:
fh.write(f"bandsaunter {__version__} — FT8 stations heard.\n")
fh.write("These are receptions, not contacts: this program "
"does not transmit.\n")
fh.write(_adif("ADIF_VER", "3.1.4") + "\n")
fh.write(_adif("PROGRAMID", "bandsaunter") + "\n")
fh.write(_adif("PROGRAMVERSION", __version__) + "\n")
fh.write("<EOH>\n")
for call, d in sorted(best.items()):
when = datetime.fromtimestamp(d.at, timezone.utc)
row = (_adif("CALL", call)
+ _adif("QSO_DATE", when.strftime("%Y%m%d"))
+ _adif("TIME_ON", when.strftime("%H%M%S"))
+ _adif("MODE", "FT8"))
if band:
row += _adif("BAND", band)
if frequency:
row += _adif("FREQ", f"{frequency / 1e6:.6f}")
if d.grid:
row += _adif("GRIDSQUARE", d.grid)
if my_grid:
row += _adif("MY_GRIDSQUARE", my_grid.upper())
row += _adif("RST_RCVD", f"{d.snr_db:.0f}")
# Said plainly in the record itself, so that a log this is
# imported into cannot quietly become a claim.
row += _adif("COMMENT", "heard only, not worked")
row += _adif("QSO_RANDOM", "Y")
fh.write(row + "<EOR>\n")
except OSError:
return None
return Path(path)

172
bandsaunter/ft8sim.py Normal file
View file

@ -0,0 +1,172 @@
"""A made-up FT8 band, on a real clock.
For trying the display, the report and the exports without an aerial, and
for testing the whole path from samples to tables. It stands in for a
receiver: same `tune`, `read_samples` and `close`, same sample rate, same
complex samples.
What it cannot tell you is whether your receiver hears anything, which is
the one question a simulator is never allowed to answer. It is also the
lesson this project learned expensively on another band: an encoder tested
against its own decoder agrees with it about anything they are both wrong
about. So the decoding is tested against real off-air recordings; this is
here for the parts above the decoding.
"""
from __future__ import annotations
import math
import time
import numpy as np
from . import ft8code as code
from . import ft8wave as wave
__all__ = ["SimulatedBand", "CALLS", "GRIDS"]
# A plausible spread: a few continents, a few strengths, a few habits.
CALLS = ("G4UJS", "SP4FCA", "IK4LZH", "EA1ABT", "DL1UDO", "OH8GDU",
"JA2GQT", "VK4BLE", "RA6ABO", "F4FSY", "K1ABC", "W2XYZ",
"PA3EPP", "SM0ABC", "LZ1LZ", "9A9TT", "ZS6ABC", "LU1AAA")
GRIDS = ("IO83", "KO03", "JN54", "IN73", "JO31", "KP24",
"PM85", "QG62", "KN96", "JN25", "FN42", "FN20",
"JO21", "JO99", "KN12", "JN75", "KG33", "GF05")
class SimulatedBand:
"""A receiver that invents a band of FT8 stations.
Paced against the real clock, because everything about FT8 is: the
slots are quarter-minutes of UTC, and a simulator that produced fifteen
seconds of audio instantly would make the section look as though it
worked while testing none of the timing that makes it work.
"""
def __init__(self, rate: float = 240_000.0, seed: int = 0,
stations: int = 12, realtime: bool = True,
clock=time.time, sleep=time.sleep):
self.rate = float(rate)
self.frequency = 0.0
self.rng = np.random.default_rng(seed)
self.stations = max(1, int(stations))
self.realtime = realtime
self.clock = clock
self.sleep = sleep
self.closed = False
self._audio_rate = 12_000
self._made: dict[float, np.ndarray] = {}
self._one_sided: dict[float, np.ndarray] = {}
self._at = None
# -- the receiver's shape -------------------------------------------
def tune(self, hz: float) -> None:
self.frequency = float(hz)
def close(self) -> None:
self.closed = True
def read_samples(self, count: int):
"""A block of samples, paced to take as long as it would on the air."""
if self.closed:
return None
now = self.clock()
if self._at is None:
self._at = now
wanted = count / self.rate
if self.realtime:
behind = self._at + wanted - self.clock()
if behind > 0:
self.sleep(behind)
block = self._samples_for(self._at, count)
self._at += wanted
return block
# -- making the band ------------------------------------------------
def _slot_audio(self, slot_at: float) -> np.ndarray:
"""One slot of the band, at twelve kilohertz, made once and kept."""
have = self._made.get(slot_at)
if have is not None:
return have
rng = np.random.default_rng(int(slot_at) & 0xFFFFFFFF)
total = int(self._audio_rate * code.SLOT_S)
audio = rng.standard_normal(total).astype(np.float32) * 0.02
# Stations pick a frequency and keep it, mostly, and take turns.
even = int(slot_at / code.SLOT_S) % 2 == 0
for i in range(self.stations):
if (i % 2 == 0) != even:
continue
call = CALLS[(i + int(slot_at / code.SLOT_S) // 2) % len(CALLS)]
grid = GRIDS[CALLS.index(call)]
other = CALLS[(i * 7 + 3) % len(CALLS)]
pick = rng.integers(0, 3)
if pick == 0:
text = f"CQ {call} {grid}"
elif pick == 1:
text = f"{other} {call} {grid}"
else:
text = f"{other} {call} {rng.integers(-24, 5):+03d}"
hertz = 300.0 + i * 190.0 + float(rng.integers(-30, 30))
strength = float(rng.uniform(-18.0, 10.0))
one = wave.transmit(text, rate=float(self._audio_rate),
hertz=hertz,
offset=0.5 + float(rng.uniform(-0.3, 0.6)),
seconds=code.SLOT_S)
scale = 10.0 ** (strength / 20.0) * 0.05
audio[:one.size] += (one * scale).astype(np.float32)
if len(self._made) > 2:
self._made.clear()
self._made[slot_at] = audio
return audio
def _samples_for(self, at: float, count: int):
"""Complex samples covering ``count`` samples from ``at``.
The band is made as audio and then put back on a carrier, which is
the reverse of what the section does to it. One-sided, so that
what comes out of the demodulator is what went in: a real audio
tone at 1000 Hz has to arrive as a complex tone at +1000 Hz, or
upper sideband and lower sideband would be the same thing.
Held at the audio rate and stepped through, rather than properly
resampled. The images that leaves sit at multiples of twelve
kilohertz and the demodulator's own decimation filter removes them
-- which is worth saying out loud, because it means this simulator
leans on the thing it is being used to test. That is tolerable for
the display and the report, and is exactly why the decoding itself
is tested against real recordings instead.
"""
out = np.zeros(count, dtype=np.complex64)
step = 1.0 / self.rate
ratio = self._audio_rate / self.rate
for k in range(0, count, 4096):
piece = min(4096, count - k)
when = at + k * step
slot_at = math.floor(when / code.SLOT_S) * code.SLOT_S
audio = self._analytic_slot(slot_at)
into = (when - slot_at) * self._audio_rate
idx = np.clip((into + np.arange(piece) * ratio).astype(np.int64),
0, audio.size - 1)
out[k:k + piece] = audio[idx].astype(np.complex64)
return out
def _analytic_slot(self, slot_at: float) -> np.ndarray:
have = self._one_sided.get(slot_at)
if have is None:
have = _analytic(self._slot_audio(slot_at))
# Two slots kept: a block of samples commonly straddles a
# boundary, and rebuilding a slot for every such block is most
# of the simulator's time.
if len(self._one_sided) > 2:
self._one_sided.clear()
self._one_sided[slot_at] = have
return have
def _analytic(real_audio: np.ndarray) -> np.ndarray:
"""The one-sided version of a real signal: no energy below zero."""
spectrum = np.fft.fft(real_audio)
n = spectrum.size
spectrum[n // 2 + 1:] = 0.0
spectrum[1:(n + 1) // 2] *= 2.0
return np.fft.ifft(spectrum)

219
bandsaunter/ft8tables.py Normal file
View file

@ -0,0 +1,219 @@
"""The two fixed tables the FT8 code is defined by.
Everything else in this section is worked out from first principles -- the
tones, the sync, the parity arithmetic, the way a callsign is squeezed into
twenty-eight bits. These two cannot be, because they are not derived from
anything. They are the code, chosen once by its designers and published, and
a receiver that guessed at them would be speaking a different protocol.
Taken from ft8_lib (https://github.com/kgoba/ft8_lib), MIT licensed,
Copyright (c) 2018 K\u0101rlis Goba, which took them in turn from WSJT-X.
Reproduced here under that licence; the MIT terms permit it and this file
carries the attribution they ask for. Nothing else in bandsaunter comes from
there: the decoder below is its own.
GENERATOR is the parity half of the systematic generator: row m gives the
message bits that are exclusive-ored to make parity bit m, as ninety-one bits
in twelve bytes, most significant bit first.
CHECKS is the sparse parity-check matrix, as the bit positions each of the
eighty-three checks covers, one-based as published. It is *not* derivable
from GENERATOR even though both describe the same code: the generator's
parity half runs to a weight of fifty-odd bits per row, and belief
propagation on a matrix that dense says nothing useful. The sparse one is a
different basis for the same dual space, six or seven bits to a check, and it
is the one the decoding works on. That they agree is checked by a test
rather than assumed -- encode at random, and every sparse check must come out
even.
"""
__all__ = ["GENERATOR", "CHECKS", "COSTAS", "GRAY", "BITS", "PAYLOAD",
"PARITY", "TONES", "CRC_POLYNOMIAL", "CRC_BITS"]
# The 7x7 Costas array that starts, middles and ends every transmission.
COSTAS = (3, 1, 4, 0, 6, 5, 2)
# Three bits to a tone, in Gray order, so that mistaking a tone for its
# neighbour costs one bit rather than three.
GRAY = (0, 1, 3, 2, 5, 6, 4, 7)
BITS = 174 # bits in a codeword
PAYLOAD = 91 # of which are message and checksum
PARITY = BITS - PAYLOAD
TONES = 79 # channel symbols, sync included
# CRC-14, without the leading bit.
CRC_POLYNOMIAL = 0x2757
CRC_BITS = 14
GENERATOR: tuple[str, ...] = (
"8329CE11BF31EAF509F27FC0",
"761C264E25C2593354931320",
"DC265902FB277C6410A1BDC0",
"1B3F417858CD2DD33EC7F620",
"09FDA4FEE04195FD034783A0",
"077CCCC11B8873ED5C3D48A0",
"29B62AFE3CA036F4FE1A9DA0",
"6054FAF5F35D96D3B0C8C3E0",
"E20798E4310EED27884AE900",
"775C9C08E80E26DDAE563180",
"B0B811028C2BF997213487C0",
"18A0C9231FC60ADF5C5EA320",
"76471E8302A0721E01B12B80",
"FFBCCB80CA8341FAFB47B2E0",
"66A72A158F9325A2BF671700",
"C4243689FE85B1C51363A180",
"0DFF739414D1A1B34B1C2700",
"15B48830636C8B99894972E0",
"29A89C0D3DE81D665489B0E0",
"4F126F37FA51CBE61BD6B940",
"99C47239D0D97D3C84E09400",
"1919B75119765621BB4F1E80",
"09DB12D731FAEE0B86DF6B80",
"488FC33DF43FBDEEA4EAFB40",
"827423EE40B675F756EB5FE0",
"ABE197C484CB74757144A9A0",
"2B500E4BC0EC5A6D2BDBDD00",
"C474AA53D702187616693600",
"8EBA1A13DB3390BD6718CEC0",
"753844673A27782CC42012E0",
"06FF83A145C37035A5C12680",
"3B37417858CC2DD33EC3F620",
"9A4A5A28EE17CA9C324842C0",
"BC29F465309C977E89610A40",
"2663AE6DDF8B5CE2BB294880",
"46F231EFE457034C18144180",
"3FB2CE85ABE9B0C72E06FBE0",
"DE87481F282C153971A0A2E0",
"FCD7CCF23C69FA99BBA14120",
"F0261447E9490CA8E474CEC0",
"4410115818196F95CDD70120",
"088FC31DF4BFBDE2A4EAFB40",
"B8FEF1B6307729FB0A078C00",
"5AFEA7ACCCB77BBC9D99A900",
"49A7016AC653F65ECDC90760",
"1944D085BE4E7DA8D6CC7D00",
"251F62ADC4032F0EE7140020",
"56471F8702A0721E00B12B80",
"2B8E4923F2DD51E2D537FA00",
"6B550A40A66F4755DE95C260",
"A18AD28D4E27FE92A4F6C840",
"10C2E586388CB82A3D807580",
"EF34A41817EE02133DB2EB00",
"7E9C0C54325A9C15836E0000",
"3693E572D1FDE4CDF079E860",
"BFB2CEC5ABE1B0C72E07FBE0",
"7EE18230C583CCCC57D4B080",
"A066CB2FEDAFC9F526641260",
"BB23725ABC47CC5F4CC4CD20",
"DED9DBA3BEE40C59B5609B40",
"D9A7016AC653E6DECDC90360",
"9AD46AED5F707F280AB5FC40",
"E5921C77822587316D7D3C20",
"4F14DA8242A8B86DCA733520",
"8B8B507AD467D4441DF770E0",
"22831C9CF1169467AD04B680",
"213B838FE2AE54C38EE71800",
"5D926B6DD71F085181A4E120",
"66AB79D4B29EE6E69509E560",
"958148682D748A38DD68BAA0",
"B8CE020CF069C32A723AB140",
"F4331D6D461607E957527460",
"6DA23BA424B9596133CF9C80",
"A636BCBC7B30C5FBEAE67FE0",
"5CB0D86A07DF654A9089A200",
"F11F106848780FC9ECDD80A0",
"1FBB5364FB8D2C9D730D5BA0",
"FCB86BC70A50C9D02A5D0340",
"A534433029EAC15F322E34C0",
"C989D9C7C3D3B8C55D751300",
"7BB38B2F0186D46643AE9620",
"2644EBADEB44B9467D1F42C0",
"608CC857594BFBB55D696000",
)
CHECKS: tuple[tuple[int, ...], ...] = (
(4, 31, 59, 91, 92, 96, 153),
(5, 32, 60, 93, 115, 146),
(6, 24, 61, 94, 122, 151),
(7, 33, 62, 95, 96, 143),
(8, 25, 63, 83, 93, 96, 148),
(6, 32, 64, 97, 126, 138),
(5, 34, 65, 78, 98, 107, 154),
(9, 35, 66, 99, 139, 146),
(10, 36, 67, 100, 107, 126),
(11, 37, 67, 87, 101, 139, 158),
(12, 38, 68, 102, 105, 155),
(13, 39, 69, 103, 149, 162),
(8, 40, 70, 82, 104, 114, 145),
(14, 41, 71, 88, 102, 123, 156),
(15, 42, 59, 106, 123, 159),
(1, 33, 72, 106, 107, 157),
(16, 43, 73, 108, 141, 160),
(17, 37, 74, 81, 109, 131, 154),
(11, 44, 75, 110, 121, 166),
(45, 55, 64, 111, 130, 161, 173),
(8, 46, 71, 112, 119, 166),
(18, 36, 76, 89, 113, 114, 143),
(19, 38, 77, 104, 116, 163),
(20, 47, 70, 92, 138, 165),
(2, 48, 74, 113, 128, 160),
(21, 45, 78, 83, 117, 121, 151),
(22, 47, 58, 118, 127, 164),
(16, 39, 62, 112, 134, 158),
(23, 43, 79, 120, 131, 145),
(19, 35, 59, 73, 110, 125, 161),
(20, 36, 63, 94, 136, 161),
(14, 31, 79, 98, 132, 164),
(3, 44, 80, 124, 127, 169),
(19, 46, 81, 117, 135, 167),
(7, 49, 58, 90, 100, 105, 168),
(12, 50, 61, 118, 119, 144),
(13, 51, 64, 114, 118, 157),
(24, 52, 76, 129, 148, 149),
(25, 53, 69, 90, 101, 130, 156),
(20, 46, 65, 80, 120, 140, 170),
(21, 54, 77, 100, 140, 171),
(35, 82, 133, 142, 171, 174),
(14, 30, 83, 113, 125, 170),
(4, 29, 68, 120, 134, 173),
(1, 4, 52, 57, 86, 136, 152),
(26, 51, 56, 91, 122, 137, 168),
(52, 84, 110, 115, 145, 168),
(7, 50, 81, 99, 132, 173),
(23, 55, 67, 95, 172, 174),
(26, 41, 77, 109, 141, 148),
(2, 27, 41, 61, 62, 115, 133),
(27, 40, 56, 124, 125, 126),
(18, 49, 55, 124, 141, 167),
(6, 33, 85, 108, 116, 156),
(28, 48, 70, 85, 105, 129, 158),
(9, 54, 63, 131, 147, 155),
(22, 53, 68, 109, 121, 174),
(3, 13, 48, 78, 95, 123),
(31, 69, 133, 150, 155, 169),
(12, 43, 66, 89, 97, 135, 159),
(5, 39, 75, 102, 136, 167),
(2, 54, 86, 101, 135, 164),
(15, 56, 87, 108, 119, 171),
(10, 44, 82, 91, 111, 144, 149),
(23, 34, 71, 94, 127, 153),
(11, 49, 88, 92, 142, 157),
(29, 34, 87, 97, 147, 162),
(30, 50, 60, 86, 137, 142, 162),
(10, 53, 66, 84, 112, 128, 165),
(22, 57, 85, 93, 140, 159),
(28, 32, 72, 103, 132, 166),
(28, 29, 84, 88, 117, 143, 150),
(1, 26, 45, 80, 128, 147),
(17, 27, 89, 103, 116, 153),
(51, 57, 98, 163, 165, 172),
(21, 37, 73, 138, 152, 169),
(16, 47, 76, 130, 137, 154),
(3, 24, 30, 72, 104, 139),
(9, 40, 90, 106, 134, 151),
(15, 58, 60, 74, 111, 150, 163),
(18, 42, 79, 144, 146, 152),
(25, 38, 65, 99, 122, 160),
(17, 42, 75, 129, 170, 172),
)

411
bandsaunter/ft8wave.py Normal file
View file

@ -0,0 +1,411 @@
"""Finding FT8 in fifteen seconds of audio.
The coding is in ft8code; this is the part that has to deal with the air.
A slot of audio three kilohertz wide holds anything from nothing to forty
transmissions, each fifty hertz wide, each starting when its operator's
clock said to rather than when ours did, and most of them below the noise.
The shape of the search:
1. a waterfall -- the whole slot as power against time and frequency, at
half a symbol and half a tone, so nothing falls between two stools;
2. the sync search -- every place a Costas array could be, scored;
3. for each candidate, the eight tone powers of each of seventy-nine
symbols, turned into how strongly each bit is believed;
4. the error correction, which either converges or does not;
5. the checksum, which decides whether to believe it.
Steps four and five are why this works at all. Nothing up to step three is
clever enough to read a signal that is twenty decibels under the noise; what
it produces is a wash of barely-tilted opinions, and the code turns a
hundred and seventy-four of those into ninety-one bits of certainty -- or
into nothing, which is the other important answer.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
from . import ft8code as code
from .ft8code import COSTAS, SYMBOL_S, TONES
__all__ = ["Waterfall", "Candidate", "Decode", "listen_to", "waterfall",
"candidates", "soft_bits", "TIME_STEPS", "FREQ_STEPS"]
# Half a symbol and half a tone. A transmission that started a quarter of a
# symbol late, or sits a quarter of a tone off, is still caught: with steps
# a whole symbol wide the worst case loses most of the signal to the gap
# between two bins, and the worst case is common because nobody's clock and
# nobody's dial agree with ours.
TIME_STEPS = 2
FREQ_STEPS = 2
SYNC_AT = (0, 36, 72) # where the three Costas arrays sit, in symbols
# A perfectly-timed station does not start at the top of the slot. The
# transmission is 12.64 seconds long in a slot of fifteen, and the
# convention every FT8 program follows is to begin half a second in and to
# report lateness against that -- so a station whose clock is right reports
# as dt 0.0 rather than dt 0.5. Followed here so the figures mean the same
# thing as everybody else's.
NOMINAL_START = 0.5
@dataclass
class Waterfall:
"""The slot as power against time and frequency."""
power: np.ndarray # (times, bins), already in decibels
rate: float
hz_per_bin: float
seconds_per_step: float
@property
def times(self) -> int:
return self.power.shape[0]
@property
def bins(self) -> int:
return self.power.shape[1]
@dataclass
class Candidate:
"""Somewhere a transmission might start, and how much it looks like one."""
step: int # in half-symbols from the top of the slot
bin: int # in half-tones up the waterfall
score: float
def seconds(self, fall: Waterfall) -> float:
"""How late this started, against the nominal start of sending."""
return self.step * fall.seconds_per_step - NOMINAL_START
def hertz(self, fall: Waterfall) -> float:
return self.bin * fall.hz_per_bin
@dataclass
class Decode:
"""One transmission, read."""
text: str = ""
kind: str = ""
calls: tuple = ()
grid: str = ""
report: str = ""
calling: bool = False
hertz: float = 0.0
offset: float = 0.0 # how late it started, in seconds
snr_db: float = 0.0
score: float = 0.0
at: float = 0.0 # when the slot began, as a clock time
def waterfall(audio, rate: float) -> Waterfall:
"""The slot, as power against time and frequency.
One symbol of samples to a row, stepped half a symbol at a time, and
zero-padded to twice its length before the transform so the bins come
out half a tone apart. Padding rather than a longer window on purpose:
a longer window would average two symbols together, and the thing being
looked for changes every symbol.
"""
audio = np.asarray(audio, dtype=np.float32)
per_symbol = int(round(rate * SYMBOL_S))
if per_symbol < 8:
raise ValueError("the audio rate is too low to hold FT8 tones")
step = per_symbol // TIME_STEPS
size = per_symbol * FREQ_STEPS
window = np.hanning(per_symbol).astype(np.float32)
rows = 1 + max(0, (audio.size - per_symbol) // step)
if rows < 1:
raise ValueError("not enough audio for a single symbol")
# One strided view of the whole slot, transformed in a single call:
# a loop over two hundred rows in Python is most of the decode time.
frames = np.lib.stride_tricks.sliding_window_view(
audio, per_symbol)[::step][:rows]
spectrum = np.fft.rfft(frames * window, n=size, axis=1)
power = np.abs(spectrum).astype(np.float32)
# Decibels, because everything downstream adds and subtracts these and
# the useful comparisons are all ratios.
floor = max(float(np.median(power)) * 1e-4, 1e-12)
power = 20.0 * np.log10(np.maximum(power, floor))
return Waterfall(power=power, rate=float(rate),
hz_per_bin=rate / size,
seconds_per_step=step / rate)
def _sync_offsets():
"""Where the twenty-one sync tones sit, relative to a candidate."""
steps, bins = [], []
for block in SYNC_AT:
for k, tone in enumerate(COSTAS):
steps.append((block + k) * TIME_STEPS)
bins.append(tone * FREQ_STEPS)
return np.array(steps), np.array(bins)
SYNC_STEP, SYNC_BIN = _sync_offsets()
def candidates(fall: Waterfall, most: int = 300, lowest: float = 200.0,
highest: float = 3000.0, earliest: float = -2.5,
latest: float = 3.5) -> list[Candidate]:
"""Every place a transmission plausibly starts, best first.
Scored as how far the twenty-one sync tones stand above the other seven
tones at the same instants. Not above the noise floor of the band --
that would rank a strong signal's neighbours above a weak signal, and
the weak ones are the whole point of the protocol.
Only local peaks are kept. A real transmission lights up every
neighbouring offset as well, and without this one loud station would
fill the list and crowd out forty quiet ones.
"""
power = fall.power
span = TONES * TIME_STEPS
first = int(math.floor(earliest / fall.seconds_per_step))
last = int(math.ceil(latest / fall.seconds_per_step))
starts = np.arange(first, last + 1)
starts = starts[(starts >= 0) | (starts + SYNC_STEP.min() >= 0)]
starts = starts[starts + span <= power.shape[0]]
starts = starts[starts >= 0]
low = max(0, int(lowest / fall.hz_per_bin))
high = min(power.shape[1] - 8 * FREQ_STEPS,
int(highest / fall.hz_per_bin))
if starts.size == 0 or high <= low:
return []
tops = np.arange(low, high)
# The sync tones, and all eight tones at the same instants, for every
# (start, frequency) at once.
times = starts[:, None] + SYNC_STEP[None, :] # (S, 21)
here = power[times] # (S, 21, bins)
want = tops[None, None, :] + SYNC_BIN[None, :, None]
sync = np.take_along_axis(here, want, axis=2).mean(axis=1) # (S, tops)
whole = np.zeros_like(sync)
for tone in range(8):
idx = tops[None, None, :] + tone * FREQ_STEPS
whole += np.take_along_axis(here, idx, axis=2).mean(axis=1)
score = sync - whole / 8.0
# Local peaks only, in both directions.
keep = np.ones_like(score, dtype=bool)
keep[1:, :] &= score[1:, :] >= score[:-1, :]
keep[:-1, :] &= score[:-1, :] >= score[1:, :]
keep[:, 1:] &= score[:, 1:] >= score[:, :-1]
keep[:, :-1] &= score[:, :-1] >= score[:, 1:]
rows, cols = np.nonzero(keep)
values = score[rows, cols]
order = np.argsort(values)[::-1][:most]
return [Candidate(step=int(starts[rows[i]]), bin=int(tops[cols[i]]),
score=float(values[i])) for i in order]
def soft_bits(fall: Waterfall, spot: Candidate) -> np.ndarray:
"""How strongly each of the hundred and seventy-four bits is a zero.
Every symbol gives eight tone powers. A bit is a zero in four of the
eight tones and a one in the other four, so its evidence is the best of
the four against the best of the other four -- in decibels, which is
already a log-likelihood up to a scale factor, and the scale does not
matter to the min-sum decoding downstream.
Taking only the loudest tone and calling that three bits, which is the
obvious thing to do, throws away exactly the information the error
correction runs on, and decodes almost nothing.
"""
power = fall.power
steps = spot.step + np.arange(TONES) * TIME_STEPS
bins = spot.bin + np.arange(8) * FREQ_STEPS
if steps[-1] >= power.shape[0] or bins[-1] >= power.shape[1]:
return np.zeros(code.BITS, dtype=np.float32)
grid = power[np.ix_(steps, bins)] # (79, 8)
# Sync symbols carry nothing; drop them and put the tones back in the
# order the bits were in before the Gray mapping.
data = np.array([i for i in range(TONES)
if not (i < 7 or 36 <= i < 43 or 72 <= i)])
values = grid[data][:, list(code.GRAY)] # (58, 8) by value
out = np.zeros(code.BITS, dtype=np.float32)
for b in range(3):
mask = (np.arange(8) >> (2 - b)) & 1
zero = values[:, mask == 0].max(axis=1)
one = values[:, mask == 1].max(axis=1)
out[b::3] = zero - one
return out
# Worked out by measuring against transmissions of known strength and
# against off-air recordings with published readings, not derived. Turning
# a ratio of bins into the decibels-in-2500-Hz everybody quotes depends on
# the window, the padding and the shape of the noise, and calibrating it is
# both easier and more honest than deriving it and hoping.
SNR_TRIM = -37.0
# Making the transmitter above label its signals the way the reading below
# reports them. The reading is the one tied to reality -- it matches the
# published figures for the off-air recordings to within half a decibel
# across a hundred of them -- and a bare ratio of signal power to noise in
# 2500 Hz comes out eight decibels away from it, consistently enough that
# the difference is a definition rather than an error. Whose definition is
# "right" is not a question this can settle; what matters is that a signal
# built here at -15 reads as -15, so a test of sensitivity means what it
# says.
SNR_MATCHES = 10.0 ** (8.0 / 10.0)
SNR_NEAR_HZ = 200.0 # how far either side to look for the noise
SNR_PERCENTILE = 30 # low enough to ignore the neighbours
def _snr(fall: Waterfall, spot: Candidate, tones) -> float:
"""A signal-to-noise in the usual 2500 Hz reference bandwidth.
Measured in the tone that was actually sent, which is known by the time
this is called because the message decoded. Taking the loudest of the
eight instead looks like the same thing and is not: the largest of
eight noisy numbers is well above their mean even when there is no
signal at all, so weak transmissions all read as though they were
stronger than they are, and the scale compresses where it matters.
The noise is measured beside the signal rather than across the band. A
receiver with a three-kilohertz passband has nothing above it but the
noise floor of the sound card, and a median taken over the whole
spectrum sits twelve to sixteen decibels below the real one -- which is
a receiver flattering every station it hears by that much.
Good to a few decibels, honestly: the readings here track the published
ones for the same recordings with a correlation of about 0.83 and no
systematic bias between -25 and +10, which is the range that matters.
Very strong signals read a little low, as they do everywhere.
"""
power = (10.0 ** (fall.power / 20.0)) ** 2
steps = spot.step + np.arange(TONES) * TIME_STEPS
live = (steps >= 0) & (steps < power.shape[0])
steps, sent = steps[live], np.asarray(tones)[live]
bins = spot.bin + np.arange(8) * FREQ_STEPS
if steps.size == 0 or bins[-1] >= power.shape[1]:
return -99.0
near = int(SNR_NEAR_HZ / fall.hz_per_bin)
lo = max(0, spot.bin - near)
high = min(power.shape[1], bins[-1] + near)
columns = np.arange(lo, high)
# A guard either side of the signal: its own skirts are not noise.
beside = ((columns < spot.bin - 2 * FREQ_STEPS)
| (columns > bins[-1] + 2 * FREQ_STEPS))
if beside.sum() < 8:
return -99.0
noise = float(np.percentile(power[np.ix_(steps, columns[beside])],
SNR_PERCENTILE))
grid = power[np.ix_(steps, bins)]
here = float(grid[np.arange(grid.shape[0]), sent].mean())
if noise <= 0 or here <= noise:
return -99.0
return round(10.0 * math.log10((here - noise) / noise) + SNR_TRIM, 0)
def listen_to(audio, rate: float, book=None, most: int = 300,
rounds: int = 30, at: float = 0.0) -> list[Decode]:
"""Everything decodable in one slot of audio.
Candidates are decoded in one batch rather than one at a time: a busy
slot throws up two or three hundred of them, the error correction is
most of the work, and doing them together is the difference between a
slot that decodes in a second and one that does not keep up with the
air.
"""
book = book if book is not None else code.CallBook()
fall = waterfall(audio, rate)
spots = candidates(fall, most=most)
if not spots:
return []
beliefs = np.stack([soft_bits(fall, spot) for spot in spots])
repaired, worked = code.repair(beliefs, rounds=rounds)
out: list[Decode] = []
seen: dict[str, Decode] = {}
for i, spot in enumerate(spots):
if not worked[i]:
continue
bits = repaired[i]
if not code.crc_holds(bits[:code.PAYLOAD]):
continue
message = code.unpack(bits[:code.MESSAGE_BITS], book)
if not message.text:
continue
found = Decode(text=message.text, kind=message.kind,
calls=message.calls, grid=message.grid,
report=message.report, calling=message.calling,
hertz=round(spot.hertz(fall), 1),
offset=round(spot.seconds(fall), 2),
snr_db=_snr(fall, spot, code.tones_of(bits)),
score=spot.score, at=at)
# The same transmission is found at several neighbouring offsets.
# Keep the one with the best sync, which is the one nearest the
# truth about where and when it actually was.
was = seen.get(found.text)
if was is None or found.score > was.score:
seen[found.text] = found
out = sorted(seen.values(), key=lambda d: d.hertz)
for found in out:
for call in found.calls:
book.remember(call)
return out
def transmit(text: str, rate: float = 12000.0, hertz: float = 1000.0,
offset: float = 0.5, seconds: float = code.SLOT_S,
snr_db: float | None = None, seed: int = 0,
book=None) -> np.ndarray:
"""A slot of audio with one transmission in it.
Here rather than in a test because the tests need it, the simulator
needs it, and something that generates the signal this module claims to
understand is the only way to ask a question of the decoder whose answer
is known in advance -- where a transmission started, to the sample.
It does not transmit anything anywhere. It builds samples.
"""
payload = code.pack(text, book)
tones = code.tones_of(code.encode(code.with_crc(payload)))
per_symbol = int(round(rate * SYMBOL_S))
total = int(round(rate * seconds))
audio = np.zeros(total, dtype=np.float64)
# Continuous phase across the whole transmission. A tone that restarted
# its phase every symbol would splatter across the band, which is both
# unneighbourly on the air and not what a decoder should be tested with.
phase = 0.0
start = int(round(offset * rate))
for i, tone in enumerate(tones):
freq = hertz + float(tone) * code.SYMBOL_HZ
at = start + i * per_symbol
if at >= total or at + per_symbol <= 0:
phase += 2 * math.pi * freq * per_symbol / rate
continue
step = 2 * math.pi * freq / rate
angles = phase + step * np.arange(per_symbol)
phase = angles[-1] + step
first = max(0, at)
last = min(total, at + per_symbol)
audio[first:last] += np.sin(angles[first - at:last - at])
if snr_db is not None:
# The usual reference: the signal's own power against the noise in
# 2500 Hz, which is how every FT8 program quotes a report. The
# noise added here is white across the whole sampled band, so the
# part of it that lands in the reference 2500 Hz is that fraction
# of the total -- which is the step it is easy to get wrong, and
# getting it wrong makes a decoder look deaf when it is the test
# signal that was quietly attenuated.
rng = np.random.default_rng(seed)
signal = float(np.mean(audio[audio != 0] ** 2)) if audio.any() else 1.0
bandwidth = rate / 2.0
whole = (signal / (10.0 ** (snr_db / 10.0))
* (bandwidth / 2500.0) / SNR_MATCHES)
audio = audio + rng.standard_normal(total) * math.sqrt(whole)
return audio.astype(np.float32)

View file

@ -11,7 +11,11 @@ reads, and whether the tamper switches have been tripped.
**AcuRite weather sensors.** The 433.92 MHz outdoor sensors sold with every
consumer weather station send temperature, humidity, battery state and a
channel letter every sixteen seconds.
channel letter every sixteen seconds. Reading them is a section of this
program in its own right -- see :mod:`bandsaunter.acurite`, which knows five
models and the two ways they draw a bit -- and what is here is the doorway
to it, so that a burst caught by an ordinary scan of 433 MHz gets named
instead of being reported as hexadecimal.
Neither is guessed at. A meter message carries a sixteen-bit BCH checksum
and a sensor message carries a checksum and four parity bits, and nothing is
@ -27,6 +31,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
from . import acurite as _acurite
__all__ = ["decode_ism", "IsmReading", "decode_scm", "decode_acurite",
"scm_frame", "acurite_frame", "SCM_PREAMBLE", "ERT_TYPES"]
@ -142,77 +148,42 @@ def scm_frame(meter: int, consumption: int, ert_type: int = 4,
# AcuRite
# ---------------------------------------------------------------------------
# The messages themselves, the checks on them and the arithmetic all live in
# :mod:`bandsaunter.acurite`, which reads five models rather than the one
# this used to, and is the section of the program devoted to them. What is
# left here is the shape the generic classifier wants: a run of bits in, one
# ``IsmReading`` out, so that a burst caught by a scan of 433 MHz is named
# without the scanner having to know anything about weather.
ACURITE_BYTES = 7
ACURITE_CHANNELS = "ABCD"
ACURITE_CHANNELS = "".join(_acurite.CHANNELS)
def _parity(value: int) -> int:
value ^= value >> 4
value ^= value >> 2
value ^= value >> 1
return value & 1
"""Kept under its old name; the implementation is in :mod:`acurite`."""
return _acurite.parity8(value)
def decode_acurite(bits: str) -> IsmReading | None:
"""Read one AcuRite 592TXR / Tower outdoor sensor message.
Seven bytes: fourteen bits of sensor number with the channel above them,
a status byte, humidity, temperature in tenths of a degree offset by a
hundred, and a checksum that is the sum of the six bytes before it. The
four middle bytes each carry odd parity in their top bit, which is what
makes a seven-byte message safe to accept on a band this crowded.
"""
need = ACURITE_BYTES * 8
if len(bits) < need:
"""Whichever AcuRite sensor a run of bits turns out to be, or None."""
reading = _acurite.decode(bits)
if reading is None:
return None
# Every offset, because what reaches here has a sync pattern of some
# length in front of it and the message does not begin on a byte
# boundary of the recovered bits. The checksum and the four parity bits
# are what make that affordable.
for at in range(len(bits) - need + 1):
data = [int(bits[at + i * 8:at + (i + 1) * 8], 2)
for i in range(ACURITE_BYTES)]
if (sum(data[:6]) & 0xFF) == data[6] and any(data) and \
all(_parity(byte) == 1 for byte in data[2:6]):
break
else:
return None
bits = bits[at:at + need]
channel = ACURITE_CHANNELS[(data[0] >> 6) & 0x03]
sensor = ((data[0] & 0x3F) << 8) | data[1]
humidity = data[3] & 0x7F
raw = ((data[4] & 0x0F) << 7) | (data[5] & 0x7F)
celsius = raw / 10.0 - 100.0
if not (-40.0 <= celsius <= 70.0) or humidity > 100:
return None # outside what the sensor can report
fields = [("temperature", f"{celsius:.1f} C"),
("humidity", f"{humidity}%"),
("channel", channel)]
if data[2] & 0x40:
fields = [(m.name, _acurite.format_measure(m)) for m in reading.measures]
if reading.channel:
fields.append(("channel", reading.channel))
if reading.battery_low:
fields.append(("battery", "low"))
return IsmReading(kind="AcuRite", device="AcuRite sensor",
identifier=f"{sensor:04X}", fields=fields,
bits=bits,
checks=["checksum-8", "parity"])
return IsmReading(kind="AcuRite", device=reading.model,
identifier=reading.sensor, fields=fields,
bits=reading.bits, checks=list(reading.checks))
def acurite_frame(sensor: int, celsius: float, humidity: int,
channel: str = "A", battery_low: bool = False) -> str:
"""Build one AcuRite sensor message, checksum and parity included."""
raw = int(round((celsius + 100.0) * 10.0))
data = [((ACURITE_CHANNELS.index(channel) & 3) << 6) | ((sensor >> 8) & 0x3F),
sensor & 0xFF,
0x04 | (0x40 if battery_low else 0x00),
humidity & 0x7F,
(raw >> 7) & 0x0F,
raw & 0x7F]
for i in range(2, 6):
if _parity(data[i]) != 1:
data[i] |= 0x80
data.append(sum(data[:6]) & 0xFF)
return "".join(format(byte, "08b") for byte in data)
"""Build one 592TXR tower message, checksum and parity included."""
return _acurite.tower_frame(sensor, celsius, humidity, channel,
battery_low)
# ---------------------------------------------------------------------------

View file

@ -72,6 +72,24 @@ RESETTLE_NM = 12.0
# the map settling rather than wandering, a little is enough.
GROUND_MARGIN = 0.12
# How much stretching of the map underneath to put up with before fetching a
# sharper one. Something is needed: a window dragged one pixel wider must not
# refetch, and a window taken from a quarter of the screen to all of it must.
# Fifteen per cent is about where the lettering on a coastline starts to look
# soft, and is comfortably more than any resize that was not deliberate.
GROUND_STRETCH = 0.15
# What to do about a map that came back with squares missing from it. The
# usual cause is a resize: the window grows, a sharper zoom is chosen, and a
# hundred tiles that have never been on this disk are asked for at once --
# whereupon a volunteer-funded server refuses some of them. The fetch
# retries its own misses, so anything left is a server asking to be left
# alone for a bit, and the wait is long enough to be that. Counted as well
# as timed because a square can be missing for good, and asking all night
# for a tile that does not exist is the same discourtesy more slowly.
GROUND_RETRY_S = 25.0
GROUND_TRIES = 4
# The widest a line in a box is allowed to get before it is folded. An
# airport's full name and the town it is in run to forty characters on their
# own and a route is two of them, so one flight from Los Angeles to Dallas
@ -201,6 +219,18 @@ class Blip:
# A callsign is a flight number rather than a leg, so often it could not.
route_fits: bool = True
# The three hooks that let this window draw something that is not an
# aeroplane. All defaulted, so an aircraft is exactly what it was.
#
# A mark on a map wants three things decided: what shape it is, what
# colour, and what its box says. For an aeroplane all three follow from
# the aeroplane -- a triangle along the heading, a colour off the
# altitude ramp, and a box built from the registers. For anything else
# they have to be given, and giving them is cheaper than a second window.
shape: str = "aircraft" # aircraft, vehicle or station
colour_index: int | None = None # a palette index, or off the ramp
details: tuple = () # (label, value, flag) rows for the box
@property
def located(self) -> bool:
return bool(self.latitude or self.longitude)
@ -212,6 +242,10 @@ class Blip:
def lines(self, unit: str, home=None) -> list[tuple[str, str, str]]:
"""The box's contents: a label, a value and a flag, a line at a time.
Given outright where the mark is not an aeroplane, because there is
nothing about a weather station that an altitude ramp and an airline
register can say.
The flag is a two-letter country code the drawing turns into twelve
pixels of one, and is empty for every line that is only words.
@ -219,6 +253,8 @@ class Blip:
a register that has not answered yet would leave a box full of gaps
that never fill in.
"""
if self.details:
return list(self.details)
out: list[tuple[str, str, str]] = []
# What sort of aircraft it is: the emitter category, which came off
# the air with the callsign, and whether the address is a military
@ -361,10 +397,37 @@ class Sky:
home=None, radius_nm: float = 100.0,
brightness: float = 0.70, fade: float = 20.0,
airports: bool = False, rings: bool = False,
box_opacity: float = 0.85):
box_opacity: float = 0.85, pulse: float = 0.0,
echo: float = 0.0, echo_reach: float = 0.0,
fades: bool = True, channel: str = "1090 MHz",
subject: str = "overhead", counted: str = "frames",
waiting: str = ""):
import threading
self.unit = unit
# Whether a mark that has gone quiet leaves the picture.
#
# An aeroplane that stops transmitting has flown out of range, and
# showing it an hour later where it was would be drawing something
# that is certainly not there. A fixed amateur station that stops
# transmitting is still where it was -- it beacons every half hour,
# and the gaps are silence rather than absence -- so the picture
# accumulates instead, and an evening's listening fills a map.
self.fades = bool(fades)
# What the strip along the top says it is looking at.
self.channel = channel
self.subject = subject
self.counted = counted
# What the empty picture says while there is nothing to draw on it.
# Said here rather than in the drawing, because what has to arrive
# before a mark can be placed is a fact about the signal and not
# about the window: an aeroplane needs two position frames of
# opposite parity, and an amateur station needs to have mentioned
# where it is, which not all of them ever do.
self.waiting = waiting or (
f"listening on {channel}\n\n"
"nothing placed yet — an aircraft is on the map once an even\n"
"and an odd position frame have both arrived")
self.hold = hold
self.home = home
self.radius_nm = radius_nm
@ -379,6 +442,10 @@ class Sky:
# all puts the words straight on the map, which is readable over
# water and not over a city.
self.box_opacity = max(0.0, min(1.0, float(box_opacity)))
# Seconds a pulse takes and seconds between echoes; nought for off.
self.pulse = max(0.0, float(pulse))
self.echo = max(0.0, float(echo))
self.echo_reach = max(0.0, float(echo_reach))
self.frames = 0
self.aircraft_seen = 0
self.started = time.time()
@ -397,6 +464,12 @@ class Sky:
self._ground_for = None
self._ground_box = None
self._ground_serial = 0
# Whether the map in hand is the whole of what was asked for, when
# it arrived, and how many times it has been asked for since.
self._ground_settled = True
self._ground_at = 0.0
self._ground_tries = 0
self._ground_ask = None
# The aerodromes under the view, fetched with the map and kept the
# same way: they come from the same place, cover the same box, and
# go stale at the same moment.
@ -420,15 +493,26 @@ class Sky:
if not trail or trail[-1][:2] != here[:2]:
trail.append(here)
def set_ground(self, levels, key, box=None) -> None:
def set_ground(self, levels, key, box=None, settled: bool = True) -> None:
"""Keep the map that was fetched, and the piece of world it covers.
A failed fetch is kept too, as nothing: otherwise a machine with no
network asks for the same tiles five times a second all night.
``settled`` is whether this is the whole map or only most of it.
Most of it is still worth drawing -- the alternative is a bare grid
-- but it is not worth keeping for the life of the view, so an
unsettled map may be asked for again.
"""
with self._lock:
if key != self._ground_for:
self._ground_tries = 0
self._ground, self._ground_for = levels, key
self._ground_box = box
self._ground_settled = bool(settled)
self._ground_at = time.time()
if not settled:
self._ground_tries += 1
# Counted rather than compared: the drawing side keeps the
# dimmed pixels it made last time, and needs to know whether
# what it made them from is still the same map.
@ -483,11 +567,41 @@ class Sky:
with self._lock:
return self._ground_serial
def ground_settled(self) -> bool:
"""Whether the map in hand is the whole of what was asked for."""
with self._lock:
return self._ground_settled
def want_ground(self, key, box, size) -> None:
"""Say which map is needed. Painting must never wait on a network."""
with self._lock:
if self._ground_for != key:
self._wanted = (key, box, size)
self._ground_ask = (key, box, size)
def reask_ground(self) -> None:
"""Ask again for a map that came back with squares missing from it.
For the map already in hand, under the key it was fetched with,
rather than for whatever the view happens to be this frame. The
view drifts a pixel at a time inside the box that was fetched and is
answered from it without asking for anything; asking on each of
those drifts would be a refetch every frame rather than a retry.
There was a check here that the remembered request was still the one
the map in hand came from. It could not be made to fail: a request
for a different view is only ever taken up after ``_wanted`` has
been filled, and a filled ``_wanted`` has already returned above.
Code that cannot be made to matter is code that is not doing
anything, so it went.
"""
with self._lock:
if self._ground_settled or self._wanted is not None:
return
if self._ground_tries >= GROUND_TRIES:
return
if time.time() - self._ground_at >= GROUND_RETRY_S:
self._wanted = self._ground_ask
def wanted_ground(self):
with self._lock:
@ -519,6 +633,15 @@ class Sky:
range; it stays in the log and fades off the picture.
"""
now = time.time() if now is None else now
if not self.fades:
with self._lock:
out = [b for b in self._blips.values() if b.located]
# Most recently heard first, because that is the order the boxes
# are laid out in and a map that has been accumulating all
# evening has more marks than it has room for boxes. The ones
# worth reading are the ones that just spoke.
out.sort(key=lambda b: (-b.last_seen, b.name))
return out
limit = self.hold + max(0.0, self.fade)
with self._lock:
out = [b for b in self._blips.values()
@ -533,6 +656,8 @@ class Sky:
says it stopped existing. Fading it says it stopped talking, which
is what actually happened.
"""
if not self.fades:
return 1.0
now = time.time() if now is None else now
age = max(0.0, now - blip.last_seen)
if age <= self.hold or self.fade <= 0:
@ -694,6 +819,28 @@ def _build():
def craft_colour(feet, alpha=255) -> QColor:
return rgb(RAMP + altitude_step(feet), alpha)
def _lifted(colour, level: float) -> QColor:
"""One colour walked towards white, and dimmed below the middle.
The bright end is a swell towards the peak colour the pictures use;
the dim end is the same colour with the life taken out of it, so
that a pulse is a change in brightness rather than in hue.
"""
from .flightmap import HOT_LIFT
out = QColor(colour)
if level >= 0.5:
part = (level - 0.5) * 2.0 * HOT_LIFT
out.setRgb(*(int(round(c + (255 - c) * part))
for c in (colour.red(), colour.green(),
colour.blue())), colour.alpha())
else:
part = 0.45 + 0.55 * (level * 2.0)
out.setRgb(*(int(round(c * part))
for c in (colour.red(), colour.green(),
colour.blue())), colour.alpha())
return out
def glow_line(painter, colour, width: float, draw, core=True,
dash=None) -> None:
"""Lay one line down two or three times, wider and fainter each pass.
@ -855,9 +1002,16 @@ def _build():
self.sky.strength(blip, now)) for blip in flying]
taken: list[tuple[int, int, int, int]] = []
for blip, (x, y), strength in placed:
self._draw_symbol(painter, x, y, blip.track_deg,
craft_colour(blip.altitude_ft,
_alpha(strength)), strength)
colour = (rgb(blip.colour_index, _alpha(strength))
if blip.colour_index is not None
else craft_colour(blip.altitude_ft,
_alpha(strength)))
# The echo goes down before the aeroplane, so the ring
# passes under the thing that sent it out.
if strength >= 1.0:
self._draw_echo(painter, x, y, colour, blip.icao)
self._draw_symbol(painter, x, y, blip.track_deg, colour,
strength, blip.icao, blip.shape)
taken.append((x - 11, y - 11, 22, 22))
labels = [self._lay_out(blip, x, y, taken, strength)
for blip, (x, y), strength in placed]
@ -890,7 +1044,13 @@ def _build():
painter.end()
# Only while something is actually moving: the rest of the time
# the ordinary five-a-second redraw is what runs.
if self._box_moving and not self._glide_timer.isActive():
# A pulse and an echo are never finished, so while either is
# on the window keeps asking for frames the way it does while a
# box is on its way somewhere.
alive = (self._box_moving
or ((self.sky.pulse > 0 or self.sky.echo > 0)
and any(s >= 1.0 for _b, _xy, s in placed)))
if alive and not self._glide_timer.isActive():
self._glide_timer.start(BOX_GLIDE_MS)
def _draw_waiting(self, painter) -> None:
@ -898,10 +1058,7 @@ def _build():
painter.setPen(rgb(INK))
painter.drawText(self.rect(), _enum(Qt, "AlignmentFlag",
"AlignCenter"),
"listening on 1090 MHz\n\n"
"nothing placed yet — an aircraft is on the map "
"once an even\nand an odd position frame have "
"both arrived")
self.sky.waiting)
def _draw_ground(self, painter, view) -> None:
levels, box = self.sky.ground_covering(view.south, view.west,
@ -920,6 +1077,25 @@ def _build():
(int(view.width * scale),
int(view.height * scale)))
return
# Most of a map, with squares missing where tiles did not
# arrive. Drawn -- it is most of a map -- and asked for again,
# which is rate-limited inside and does nothing at all once the
# map is whole.
self.sky.reask_ground()
# And a map fetched for a smaller window is still a map of the
# right piece of world, so nothing above notices that it is now
# being stretched. A window opened at its default size and then
# taken to the whole screen used to keep the map it started with
# until an aircraft wandered far enough to move the view out of
# the fetched box, which on a quiet band is a long time to look
# at a blurred coastline.
if self._stretched(levels, box, view):
scale = 1.0 + 2.0 * GROUND_MARGIN
self.sky.want_ground(self.ground_key(view),
self.ground_box(view),
(int(view.width * scale),
int(view.height * scale)))
# Cutting the view out of the fetched map, dimming it and
# looking every level up in the palette is about seventy
# milliseconds over two megapixels, and none of it changes
@ -943,6 +1119,33 @@ def _build():
image = QImage(pixels.data, width, height, 3 * width, RGB888)
painter.drawImage(0, 0, image)
@staticmethod
def _stretched(levels, box, view: Projection) -> bool:
"""Whether the map in hand is being blown up to fill the window.
Measured as pixels of map per degree of world, in hand against
wanted, which is the thing that actually shows: a map fetched
for eleven hundred pixels across and drawn across nineteen
hundred is the same map with each of its pixels covering nearly
two.
Saying yes here cannot loop. The request that follows is keyed
on the window's size, so once it has been answered the key
matches and nothing more is asked -- which is what stops a
window bigger than the tile budget can cover from asking
forever. At that size the map is enlarged by design, and the
readme has always said so.
"""
if levels is None or box is None or view.width < 1:
return False
across = box[3] - box[1]
shown = view.east - view.west
if across <= 0 or shown <= 0:
return False
have = levels.shape[1] / across
want = view.width / shown
return want > have * (1.0 + GROUND_STRETCH)
@staticmethod
def _crop(levels, box, view: Projection):
"""The part of the fetched map this view is looking at.
@ -1238,8 +1441,50 @@ def _build():
self._box_moving = True
return int(round(x + here[0])), int(round(y + here[1]))
def _draw_echo(self, painter, x, y, colour, icao: str) -> None:
"""One ring travelling outward from an aircraft, dimming as it
grows. What a radar repeater does, and what the eye reads as
"this thing is transmitting" -- which is what an aeroplane on
this picture is doing, twice a second."""
from .flightmap import echo_age, phase_of
every = self.sky.echo
reach = self.sky.echo_reach
if every <= 0 or reach <= 0:
return
age = echo_age(time.time() - self.sky.started, every,
phase_of(icao))
part = age / max(1e-9, every)
radius = reach * part
if radius < 2:
return
ring = QColor(colour)
# Dimmer the further out it has got, and gone by the time it
# arrives: a ring that vanished at full strength would read as
# something switching off rather than something spending itself.
ring.setAlpha(int(colour.alpha() * (1.0 - part) ** 1.6 * 0.75))
painter.setPen(QPen(ring, 1.2))
painter.setBrush(_NO_BRUSH)
painter.drawEllipse(QPointF(x, y), radius, radius)
def _pulse_level(self, icao: str):
"""How far up its pulse this aircraft is, or None for no pulse.
Each aeroplane is offset by its own address, so a sky full of
them swells and fades separately rather than beating as one --
which reads as a display flashing rather than as a lot of
separate things transmitting.
"""
from .flightmap import phase_of, pulse_at
if self.sky.pulse <= 0:
return None
return pulse_at(time.time() - self.sky.started, self.sky.pulse,
phase_of(icao))
def _draw_symbol(self, painter, x, y, heading, colour,
strength: float = 1.0) -> None:
strength: float = 1.0, icao: str = "",
shape: str = "aircraft") -> None:
angle = math.radians(heading % 360.0)
sin, cos = math.sin(angle), math.cos(angle)
@ -1247,23 +1492,58 @@ def _build():
return QPointF(x + side * cos + ahead * sin,
y + side * sin - ahead * cos)
shape = QPolygonF([point(9, 0), point(-6, 5),
point(-3, 0), point(-6, -5)])
level = self._pulse_level(icao) if strength >= 1.0 else None
if level is not None:
# A painter can blend, so the swell here is continuous
# rather than the handful of steps an indexed picture is
# held to: the colour walks from dim towards white and the
# halo widens with it, which is a beam sitting in one place
# a little too long.
colour = _lifted(colour, level)
if shape == "aircraft":
outline_of = QPolygonF([point(9, 0), point(-6, 5),
point(-3, 0), point(-6, -5)])
elif shape == "vehicle":
# A thing going somewhere that is not an aeroplane: a body
# with a stalk, so the direction reads without the mark
# claiming to be flying.
outline_of = QPolygonF([point(8, 0), point(1, 4),
point(-5, 3), point(-5, -3),
point(1, -4)])
else:
# Somewhere rather than something: a diamond, which is the
# one shape on this picture that has no front.
outline_of = QPolygonF([point(0, 6), point(6, 0),
point(0, -6), point(-6, 0)])
# The halo is stroked round the outline rather than filled, so
# that it spreads outwards from the symbol instead of merely
# making it bigger.
from .flightmap import THEME
if THEME.glow:
burn = 0.0 if level is None else max(0.0, level - 0.25) * 5.0
if THEME.glow or burn > 0:
def outline(pen):
painter.setPen(pen)
painter.setBrush(_NO_BRUSH)
painter.drawPolygon(shape)
painter.drawPolygon(outline_of)
glow_line(painter, colour, 0.1, outline, core=False)
if burn > 0:
# The raster burn: the shape itself, laid down wide and
# faint under the aeroplane rather than a circle drawn
# round it, so the glow has the shape of the thing
# casting it.
for ring in (2.0, 1.0):
halo = QColor(colour)
halo.setAlpha(int(min(150, 60 * burn) / ring))
pen = QPen(halo, burn * 2.2 * ring)
pen.setJoinStyle(ROUND_JOIN)
pen.setCapStyle(ROUND_CAP)
outline(pen)
if THEME.glow:
glow_line(painter, colour, 0.1, outline, core=False)
painter.setPen(NO_PEN)
painter.setBrush(colour)
painter.drawPolygon(shape)
painter.drawPolygon(outline_of)
painter.setBrush(QColor(255, 255, 255, int(200 * strength)))
painter.drawEllipse(QPointF(x, y), 1.6, 1.6)
@ -1410,9 +1690,9 @@ def _build():
painter.setBrush(QColor(10, 12, 18, 205))
painter.drawRect(0, 0, self.width(), height)
elapsed = max(0.001, time.time() - self.sky.started)
told = (f"1090 MHz {flying} overhead "
told = (f"{self.sky.channel} {flying} {self.sky.subject} "
f"{self.sky.aircraft_seen} seen "
f"{self.sky.frames:,} frames "
f"{self.sky.frames:,} {self.sky.counted} "
f"{self.sky.frames / elapsed:.0f}/s "
f"{_clock(elapsed)}")
if self.sky.log_name:
@ -1422,7 +1702,7 @@ def _build():
painter.setFont(self.head_font)
painter.setPen(rgb(INK))
painter.drawText(10, metrics.ascent() + 4, told)
keys = ("d detail t trails g map [ ] bright "
keys = ("d detail t trails g map f full [ ] bright "
"+/- range q quit")
painter.setPen(rgb(GRID))
painter.drawText(self.width() - 8
@ -1438,7 +1718,16 @@ def _build():
self.view = SkyView(sky, self)
self.setCentralWidget(self.view)
self.setWindowTitle(title)
# The size to come back to when the window is un-maximised, set
# before maximising so that there is one.
self.resize(1100, 800)
# Maximised rather than a fixed size: this is a map, and the
# thing somebody wants more of is map. Maximised rather than
# true full screen, because the title bar is where the band and
# the frequency are written, and a window with no frame is one
# somebody has to know a key to get out of -- f is that key,
# for anybody who wants the last few rows as well.
self.showMaximized()
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
self._timer.start(REDRAW_MS)
@ -1460,6 +1749,14 @@ def _build():
self.view.trails = not self.view.trails
elif text == "g":
self.view.show_ground = not self.view.show_ground
elif text == "f":
# Back to maximised rather than to the small size it was
# built at: leaving full screen should not shrink the map to
# a quarter of the screen.
if self.isFullScreen():
self.showMaximized()
else:
self.showFullScreen()
elif text in ("+", "="):
self.sky.radius_nm = max(5.0, self.sky.radius_nm / 1.5)
elif text == "-":
@ -1532,14 +1829,18 @@ def fetch_ground(sky: Sky, url: str = "", fetch=None) -> None:
# the screen. Rendering the wider box into the window's pixels
# and stretching it back was a whole-map upscale of a fifth,
# which is what a sharp map looks like when it looks blurred.
levels = basemap.ground_under(south, west, north, east,
width, height,
shades=_ground_shades(), **extra)
levels, settled = basemap.ground_under(
south, west, north, east, width, height,
shades=_ground_shades(), **extra)
except Exception:
levels = None
levels, settled = None, True
# Remembered either way: a map that could not be fetched must not be
# asked for again every fifth of a second for the rest of the night.
sky.set_ground(levels, key, box if levels is not None else None)
# A map that came back with squares missing is remembered too -- it
# is most of a map and it gets drawn -- but not as the last word,
# so the window can ask for the rest of it in a moment.
sky.set_ground(levels, key, box if levels is not None else None,
settled=settled)
def _airports(sky: Sky) -> None:

1213
bandsaunter/packets.py Normal file

File diff suppressed because it is too large Load diff

326
bandsaunter/register.py Normal file
View file

@ -0,0 +1,326 @@
"""Everything this program has ever found out about a callsign or an aircraft.
Both caches, read back and made browsable. The scanner, the recording
browser, APRS and FT8 all resolve callsigns into the same file, and the
aircraft side keeps its own; between them they are a record of who and what
has been heard from this aerial, and until now the only way to read either
was to open the JSON.
Nothing here writes. It reads two caches and turns them into rows and
panels, which keeps it testable without a network, a receiver or a terminal,
and means a mistake in here cannot lose an evening's lookups.
The addresses are licence records, public by law and already printed by the
scanner's own reports; this shows them the same way rather than more
prominently.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from urllib.parse import quote
__all__ = ["Entry", "load_callsigns", "load_aircraft", "load_all",
"maps_url", "search", "KINDS", "cache_paths"]
KINDS = ("callsigns", "aircraft")
# Google's documented form for "show me this point". Coordinates only: the
# callsign and the name stay on this machine, because a map does not need
# them to show a place and a URL is the one part of this that leaves.
MAPS = "https://www.google.com/maps/search/?api=1&query={lat:.6f},{lon:.6f}"
def cache_paths() -> dict:
root = Path(os.environ.get("XDG_CACHE_HOME") or "~/.cache").expanduser()
root = root / "bandsaunter"
return {"callsigns": root / "callsigns.json",
"aircraft": root / "flights.json"}
@dataclass
class Entry:
"""One thing that was looked up, flattened for showing.
``rows`` is every field that had a value, in the order a person would
read them, rather than the order the register happened to send them.
"""
kind: str = ""
key: str = "" # what it is filed under
title: str = "" # the callsign or the registration
what: str = "" # a name, or a make and model
where: str = "" # a town, or a route
when: float = 0.0 # when it was looked up
status: str = ""
position: tuple | None = None
rows: list = field(default_factory=list)
@property
def mappable(self) -> bool:
return self.position is not None
def maps(self) -> str:
return maps_url(self)
def maps_url(entry: Entry) -> str:
"""A link to where this is, or "" where there is nowhere to point at."""
if entry is None or entry.position is None:
return ""
lat, lon = entry.position
return MAPS.format(lat=float(lat), lon=float(lon))
def _when(value) -> str:
try:
value = float(value or 0)
except (TypeError, ValueError):
return ""
if value <= 0:
return ""
return datetime.fromtimestamp(value).strftime("%Y-%m-%d %H:%M")
def _read(path: Path):
try:
body = json.loads(Path(path).read_text())
except (OSError, ValueError):
return None
return body if isinstance(body, dict) else None
# ---------------------------------------------------------------------------
# Callsigns
# ---------------------------------------------------------------------------
# Label, field, and whether it is worth a line of its own. Ordered the way
# somebody reads a licence rather than the way the register sends one.
_CALL_FIELDS = (
("licensed to", "name"),
("class", "oper_class"),
("licence", "licence_type"),
("street", "street"),
("town", "location"),
("postcode", "postcode"),
("country", "country"),
("district", "district"),
("grid", "grid"),
("expires", "expires"),
("previously", "previous"),
("trustee", "trustee"),
)
def load_callsigns(path: Path | None = None) -> list[Entry]:
"""Every callsign in the cache, whether or not it resolved.
The ones that did not are kept on purpose. "Asked about and not in any
register" is a fact about a station, and a list that quietly dropped
them would make a long evening of unlisted foreign stations look like an
evening when nothing was looked up.
"""
body = _read(path or cache_paths()["callsigns"])
if not body:
return []
out = []
for call, one in body.items():
if not isinstance(one, dict):
continue
rows = []
for label, field_name in _CALL_FIELDS:
value = str(one.get(field_name) or "").strip()
if value:
rows.append((label, value))
lat = _float(one.get("latitude"))
lon = _float(one.get("longitude"))
position = (lat, lon) if (lat or lon) else None
if position is not None:
how = " (from the grid square)" if one.get("from_grid") else ""
rows.append(("position", f"{lat:.5f}, {lon:.5f}{how}"))
rows.append(("status", _explain(str(one.get("status") or ""))))
if one.get("source"):
rows.append(("answered by", str(one["source"])))
looked = _when(one.get("fetched_at"))
if looked:
rows.append(("looked up", looked))
out.append(Entry(
kind="callsigns", key=str(call), title=str(call),
what=str(one.get("name") or ""),
where=str(one.get("location") or one.get("country") or ""),
when=_float(one.get("fetched_at")),
status=str(one.get("status") or ""),
position=position, rows=rows))
return sorted(out, key=lambda e: e.title)
def _explain(status: str) -> str:
"""What a status means, since four of the five are not self-evident."""
return {
"found": "found in a register",
"unlisted": "asked about, in no register reachable from here",
"service": "not an amateur callsign — a business or GMRS licence",
"offline": "could not be asked: nothing reachable",
"pending": "the lookup is still out",
}.get(status, status)
def _float(value) -> float:
try:
return float(value or 0.0)
except (TypeError, ValueError):
return 0.0
# ---------------------------------------------------------------------------
# Aircraft
# ---------------------------------------------------------------------------
_PLANE_FIELDS = (
("registration", "registration"),
("type", "type_code"),
("model", "model"),
("made by", "manufacturer"),
("operator", "operator"),
("airline", "airline"),
("callsign", "callsign"),
("country", "country"),
("owner's country", "owner_country"),
)
def load_aircraft(path: Path | None = None) -> list[Entry]:
"""Every aircraft in the cache, with its route where one is known.
The route is filed against the flight number rather than the airframe,
because one aeroplane flies four different routes in a day -- so the two
halves are joined here rather than assumed to be one record.
"""
body = _read(path or cache_paths()["aircraft"])
if not body:
return []
planes = body.get("aircraft") or {}
routes = body.get("routes") or {}
out = []
for icao, one in planes.items():
if not isinstance(one, dict):
continue
rows = [("ICAO address", str(icao).upper())]
for label, field_name in _PLANE_FIELDS:
value = str(one.get(field_name) or "").strip()
if value:
rows.append((label, value))
route = routes.get(str(one.get("callsign") or "").upper()) \
if isinstance(routes, dict) else None
leg = _leg(one, route if isinstance(route, dict) else {})
for label, value in leg:
rows.append((label, value))
rows.append(("status", _explain_plane(str(one.get("status") or ""))))
looked = _when(one.get("fetched_at"))
if looked:
rows.append(("looked up", looked))
position = _airport_of(one, route if isinstance(route, dict) else {})
if position is not None:
rows.append(("origin airport at",
f"{position[0]:.4f}, {position[1]:.4f}"))
out.append(Entry(
kind="aircraft", key=str(icao).upper(),
title=str(one.get("registration") or "").strip()
or str(icao).upper(),
what=" ".join(x for x in (one.get("manufacturer"),
one.get("model")) if x).strip()
or str(one.get("type_code") or ""),
where=_where_plane(one, route if isinstance(route, dict) else {}),
when=_float(one.get("fetched_at")),
status=str(one.get("status") or ""),
position=position, rows=rows))
return sorted(out, key=lambda e: e.title)
def _leg(one: dict, route: dict) -> list:
out = []
origin = one.get("origin") or route.get("origin") or ""
origin_code = one.get("origin_code") or route.get("origin_code") or ""
dest = one.get("destination") or route.get("destination") or ""
dest_code = one.get("destination_code") or route.get("destination_code") \
or ""
if origin or origin_code:
out.append(("from", f"{origin} ({origin_code})".strip()
if origin_code else str(origin)))
if dest or dest_code:
out.append(("to", f"{dest} ({dest_code})".strip()
if dest_code else str(dest)))
stops = one.get("stops") or route.get("stops") or ()
if len(stops) > 2:
out.append(("all stops", " — ".join(str(s) for s in stops)))
source = one.get("route_source") or route.get("route_source") or ""
if source:
out.append(("route from", str(source)))
return out
def _where_plane(one: dict, route: dict) -> str:
a = one.get("origin_code") or route.get("origin_code") or ""
b = one.get("destination_code") or route.get("destination_code") or ""
if a and b:
return f"{a} → {b}"
return str(one.get("operator") or one.get("country") or "")
def _airport_of(one: dict, route: dict):
"""Where the flight started, which is the only place an aircraft record
can honestly be put on a map.
The aircraft itself is not anywhere -- this is a register of airframes,
not a position report -- so pointing a map at its origin airport is the
nearest true thing. Labelled as such where it is shown.
"""
lat = _float(one.get("origin_lat") or route.get("origin_lat"))
lon = _float(one.get("origin_lon") or route.get("origin_lon"))
return (lat, lon) if (lat or lon) else None
def _explain_plane(status: str) -> str:
return {
"found": "found in a register",
"local": "worked out from the address and callsign alone",
"unlisted": "asked about, in no register reachable from here",
"offline": "could not be asked: nothing reachable",
"pending": "the lookup is still out",
}.get(status, status)
# ---------------------------------------------------------------------------
# Browsing
# ---------------------------------------------------------------------------
def load_all(paths: dict | None = None) -> dict:
"""Both caches, as lists of entries, keyed by kind."""
paths = paths or cache_paths()
return {"callsigns": load_callsigns(paths.get("callsigns")),
"aircraft": load_aircraft(paths.get("aircraft"))}
def search(entries, query: str) -> list:
"""The entries a typed query matches, over every field shown.
Every field rather than the title, because the interesting question is
usually not "which callsign" but "who was in Arizona" or "what
Bombardiers have gone over", and both of those live in the detail.
"""
q = (query or "").strip().lower()
if not q:
return list(entries)
out = []
for entry in entries:
haystack = " ".join([entry.title, entry.what, entry.where,
entry.status]
+ [f"{label} {value}"
for label, value in entry.rows]).lower()
if all(word in haystack for word in q.split()):
out.append(entry)
return out

311
bandsaunter/sensors.py Normal file
View file

@ -0,0 +1,311 @@
"""What each sensor is called, which is the only thing the sensor cannot say.
A weather sensor broadcasts an identity -- fourteen bits on a tower sensor,
eight on a 609 -- and that identity is a number drawn at random in a factory,
or redrawn at random the next time somebody changes the batteries. It is
enough to tell one sensor from another and it is no use at all for telling
which is which: 1A2B is not a place.
So this keeps a small file saying that 1A2B is the back fence. It is the
only part of the weather section that holds anything a person typed, which
makes it the only part worth being careful with:
* Names are written the moment they are given, not when the program exits.
A listening session ends when the operator gets bored and presses
control-C, and a file that only reached the disk on a clean shutdown would
lose exactly the names that had just been thought of.
* Writing is done to a neighbouring file which is then renamed over the old
one, so that a machine losing power halfway through leaves either the old
names or the new ones and never half of each.
* Nothing is ever removed for being stale. A sensor whose battery ran out
two winters ago keeps its name, because the alternative is that putting a
battery back in loses it.
The file is YAML with one entry per sensor, meant to be opened and edited by
hand -- it is a list of things in a garden, and typing them is often quicker
than tagging them one at a time off the air.
"""
from __future__ import annotations
import os
import time
from dataclasses import asdict, dataclass, field
from pathlib import Path
import yaml
__all__ = ["Sensor", "SensorBook", "names_path", "UNHEARD"]
# The family part of the key given to a sensor named before it has been
# heard. Nobody knows yet which model it is -- that is in the message, and
# there has not been one -- so the name waits under this until there is.
UNHEARD = "?"
@dataclass
class Sensor:
"""One sensor: what it calls itself, and what its owner calls it."""
key: str = "" # family and identity: "tower/1A2B"
name: str = "" # what a person calls it
note: str = "" # anything else worth remembering
model: str = "" # what it said it was, when last heard
channel: str = "" # the switch position, when last heard
first_heard: float = 0.0
last_heard: float = 0.0
messages: int = 0
@property
def sensor(self) -> str:
"""The identity on its own, without the family in front of it."""
return self.key.split("/", 1)[-1]
@property
def family(self) -> str:
return self.key.split("/", 1)[0] if "/" in self.key else ""
@property
def number(self) -> str:
"""The same identity written in decimal, or "" if it is not a number.
Which is what rtl_433 and everything built on it prints, so it is
what anybody arriving here with a list of their own sensors already
has written down. This program shows hexadecimal because the
identity is a bit field with a channel packed above it and the shape
shows in hex -- and then presents a person holding a list of decimal
numbers with a list of hexadecimal ones and no hint that they are the
same sensors. They are: 0x3935 is 14645. Both are shown everywhere
a person reads, and either can be typed.
"""
try:
return str(int(self.sensor, 16))
except ValueError:
return ""
@property
def named(self) -> bool:
return bool(self.name.strip())
def label(self) -> str:
"""What to put at the front of a line about this sensor.
The name where there is one, because that is what the person watching
is looking for; the identity where there is not, because that is all
there is and pretending otherwise would make two nameless sensors
look like one.
"""
return self.name.strip() or self.sensor
def describe(self) -> str:
bits = [self.label()]
if self.named:
bits.append(f"({self.sensor})")
if self.model:
bits.append(self.model)
if self.channel:
bits.append(f"ch {self.channel}")
return " ".join(bits)
def names_path(directory=None) -> Path:
from .config import DEFAULT_CONFIG_DIR
return Path(directory or DEFAULT_CONFIG_DIR) / "sensors.yaml"
class SensorBook:
"""Every sensor ever heard, and whatever it has been called.
Two jobs, deliberately in one place. It remembers the names, and it
remembers when each sensor was last heard and how often -- because the
second is what makes the first usable: a list of eleven identities is
unnameable, and a list of eleven identities with "last heard four
seconds ago" beside one of them is a sensor somebody can walk out and
look at.
"""
def __init__(self, path=None, directory=None):
self.path = Path(path) if path is not None else names_path(directory)
self.sensors: dict[str, Sensor] = {}
self.dirty = False
self.load()
# -- the file ---------------------------------------------------------
def load(self) -> "SensorBook":
"""Read the names. A file with a mistake in it costs no names.
A broken file is not an error here for the same reason it is not one
anywhere else in this program: it is hand-edited, the mistake is
usually one line of it, and refusing to listen to the weather because
of a stray colon would be the wrong trade. What is unreadable is
left alone rather than overwritten, so the mistake can be found.
"""
self.sensors = {}
try:
body = yaml.safe_load(self.path.read_text(encoding="utf8")) or {}
except (OSError, ValueError, yaml.YAMLError):
return self
entries = body.get("sensors") if isinstance(body, dict) else body
if not isinstance(entries, list):
return self
known = set(Sensor().__dict__)
for entry in entries:
if not isinstance(entry, dict) or not entry.get("key"):
continue
sensor = Sensor()
for field_name, value in entry.items():
if field_name in known and value is not None:
try:
setattr(sensor, field_name,
type(getattr(sensor, field_name))(value))
except (TypeError, ValueError):
pass
self.sensors[sensor.key] = sensor
return self
def save(self) -> Path:
"""Write the names, whole or not at all."""
self.path.parent.mkdir(parents=True, exist_ok=True)
body = {"sensors": [asdict(s) for s in self.ordered()]}
beside = self.path.with_name(self.path.name + ".new")
with open(beside, "w", encoding="utf8") as fh:
fh.write("# What each weather sensor is called. Edit the names "
"freely; the rest is\n# filled in from what was heard "
"and will be overwritten.\n")
yaml.safe_dump(body, fh, sort_keys=False, allow_unicode=True,
default_flow_style=False)
os.replace(beside, self.path)
self.dirty = False
return self.path
# -- what is in it ----------------------------------------------------
def __len__(self) -> int:
return len(self.sensors)
def __contains__(self, key: str) -> bool:
return key in self.sensors
def get(self, key: str) -> Sensor | None:
return self.sensors.get(key)
def name_for(self, key: str) -> str:
sensor = self.sensors.get(key)
return sensor.name if sensor is not None else ""
def label_for(self, key: str) -> str:
sensor = self.sensors.get(key)
return sensor.label() if sensor is not None else key.split("/")[-1]
def ordered(self) -> list[Sensor]:
"""Named ones first, then by how recently they were heard.
The named ones are at the top because they are the ones being
watched; the nameless ones are sorted by when they were last heard
because that is the order in which somebody would want to name them.
"""
return sorted(self.sensors.values(),
key=lambda s: (not s.named, -s.last_heard, s.key))
def unnamed(self) -> list[Sensor]:
return [s for s in self.ordered() if not s.named]
def find(self, text: str) -> list[Sensor]:
"""Sensors matching what somebody typed: a key, a name, or an id.
An identity may be given in either base. Somebody who has been
watching these sensors with another tool has a list of decimal
numbers and no reason to convert it, and somebody reading this
program's own output has hexadecimal; both work.
The two can collide -- "3935" is a hexadecimal identity and also a
decimal one -- so both readings are looked for, and if they land on
two different sensors both are returned and the caller says it is
ambiguous rather than picking one. An exact match otherwise wins
outright, so naming a sensor whose identity reads like a word does
not turn into a list of everything in the garden.
"""
wanted = (text or "").strip().lower()
if not wanted:
return []
exact = [s for s in self.ordered()
if wanted in (s.key.lower(), s.sensor.lower(),
s.name.strip().lower())
or (wanted.isdigit() and wanted == s.number)]
if exact:
return exact
return [s for s in self.ordered()
if wanted in s.key.lower() or wanted in s.name.lower()
or wanted in s.note.lower()]
# -- changing it ------------------------------------------------------
def heard(self, reading, when: float = 0.0) -> Sensor:
"""Note one reception. Returns the sensor it belonged to.
This does not save. A sensor reports every sixteen seconds and there
may be a dozen of them, and rewriting the file for each would be a
few thousand writes an hour to record nothing a person typed. The
counts are saved when the listening stops, and the names -- which are
the part that matters -- are saved the moment they are given.
"""
key = getattr(reading, "key", "") or ""
sensor = self.sensors.get(key) or self._claim(key)
if sensor is None:
sensor = self.sensors[key] = Sensor(key=key)
at = when or getattr(reading, "at", 0.0) or time.time()
sensor.first_heard = sensor.first_heard or at
sensor.last_heard = max(sensor.last_heard, at)
sensor.messages += 1
sensor.model = getattr(reading, "model", "") or sensor.model
sensor.channel = getattr(reading, "channel", "") or sensor.channel
self.dirty = True
return sensor
def _claim(self, key: str) -> Sensor | None:
"""Hand a waiting name to the sensor it turns out to belong to.
Somebody who knows there is a sensor on the shed can name it before
it has ever been received, and that name is filed under the identity
alone because nothing yet knows which model it is. The first message
from it says, and this is the moment the name moves across -- so the
display says "shed" from the first reception rather than listing the
shed and the sensor on it as two separate things.
"""
identity = key.split("/", 1)[-1]
waiting = self.sensors.pop(f"{UNHEARD}/{identity}", None)
if waiting is None:
return None
waiting.key = key
self.sensors[key] = waiting
self.dirty = True
return waiting
def tag(self, key: str, name: str, note: str | None = None,
save: bool = True) -> Sensor:
"""Give a sensor a name, and put it on the disk straight away."""
sensor = self.sensors.get(key)
if sensor is None:
sensor = self.sensors[key] = Sensor(key=key)
sensor.name = (name or "").strip()
if note is not None:
sensor.note = note.strip()
self.dirty = True
if save:
self.save()
return sensor
def forget(self, key: str, save: bool = True) -> bool:
"""Remove a sensor entirely. Returns whether there was one."""
if key not in self.sensors:
return False
del self.sensors[key]
self.dirty = True
if save:
self.save()
return True
def flush(self) -> Path | None:
"""Save if anything has changed since the last write."""
return self.save() if self.dirty else None

295
bandsaunter/splash.py Normal file
View file

@ -0,0 +1,295 @@
"""The title screen.
Drawn in braille, two dots wide and four tall to a character, which is eight
times the detail a block gives and is what makes room for capitals and
lowercase at the same height. It also reads as something drawn rather than
as masonry, which is the point.
The font is cut by hand rather than pulled in: it is twelve letters at cap
height seven, and a figlet library to draw that would be the largest thing in
the package's requirements. Cut small on purpose -- at seven rows there is
only one way to draw each letter, and so no room to draw it badly -- and then
doubled, because a stroke two dots thick reads as a line where one dot thick
reads as a dotted line.
Shown only on a terminal. Everything in this program can be piped into
something else -- the band table, the sensor list, a line per packet -- and a
title screen in the middle of that is corruption rather than decoration, so
anything that is not a terminal gets nothing at all. ``--no-splash`` turns
it off on a terminal too, for anybody who would rather it did not.
The gradient runs deep blue through cyan to a cool white across the width.
Not the waterfall ramp the rest of the program draws in -- that one goes on
through green and yellow to red because it stands in for a spectrum and has
to mean something. This means nothing, so it is allowed to be a colour
scheme.
"""
from __future__ import annotations
import os
import sys
__all__ = ["banner", "pixels", "to_braille", "show", "wanted",
"FONT", "CAPS", "LOWER", "ASCENDERS", "ROWS", "HEIGHT",
"SCANNER_NAME", "BROWSER_NAME",
"BANDSAUNTER", "SAUNTERBROWSE"]
# Four rows of braille, each cell two dots wide and four tall. Braille
# because the dots read as a drawn line rather than as masonry, and because
# eight dots to a character is four times the vertical detail a block gives
# -- which is what makes room for capitals and lowercase at the same time.
ROWS = 4 # braille rows a banner occupies
HEIGHT = 16 # pixel rows, which is ROWS * 4
BASELINE = 14 # the row under the last one any letter puts ink on
# Where each of a braille cell's eight dots lives, as (row, column).
# 1 4 0x01 0x08
# 2 5 0x02 0x10
# 3 6 0x04 0x20
# 7 8 0x40 0x80
DOTS = ((0x01, 0x08), (0x02, 0x10), (0x04, 0x20), (0x40, 0x80))
BRAILLE = 0x2800
BLANK = chr(BRAILLE) # a cell with no dots in it
# Cut at cap height seven and x-height five -- the size small bitmap fonts
# have always been cut at, where there is only one way to draw each letter
# and so no room to draw it badly -- and then doubled. Doubling is what
# makes a stroke two dots thick in both directions, and a stroke two dots
# thick reads as a line where one dot thick reads as a dotted line.
CAPS: dict[str, tuple[str, ...]] = {
"B": ("####.",
"#...#",
"#...#",
"####.",
"#...#",
"#...#",
"####."),
"S": (".####",
"#....",
"#....",
".###.",
"....#",
"....#",
"####."),
}
# Letters that sit on the baseline and reach the x-height, and no further.
LOWER: dict[str, tuple[str, ...]] = {
"a": (".###.",
"....#",
".####",
"#...#",
".####"),
"e": (".###.",
"#...#",
"#####",
"#....",
".###."),
"n": ("#.##.",
"##..#",
"#...#",
"#...#",
"#...#"),
"o": (".###.",
"#...#",
"#...#",
"#...#",
".###."),
"r": ("#.##.",
"##..#",
"#....",
"#....",
"#...."),
"s": (".####",
"#....",
".###.",
"....#",
"####."),
"u": ("#...#",
"#...#",
"#...#",
"#..##",
".##.#"),
"w": ("#...#",
"#...#",
"#.#.#",
"##.##",
"#...#"),
}
# Letters that reach above the x-height: a full ascender, and a t, which is
# neither one thing nor the other and has always needed its own entry.
ASCENDERS: dict[str, tuple[str, ...]] = {
"d": ("....#",
"....#",
".###.",
"#...#",
"#...#",
"#...#",
".###."),
"t": (".#...",
".#...",
"####.",
".#...",
".#...",
".###."),
}
FONT = {**CAPS, **LOWER, **ASCENDERS}
SCANNER_NAME = "BandSaunter"
BROWSER_NAME = "SaunterBrowse"
# What the two names used to be called, when the font was capitals only.
BANDSAUNTER = SCANNER_NAME
SAUNTERBROWSE = BROWSER_NAME
def _double(glyph) -> list[str]:
"""Twice the size in both directions. See the note on stroke width."""
out = []
for row in glyph:
wide = "".join(c * 2 for c in row)
out.extend((wide, wide))
return out
def pixels(word: str) -> list[str]:
"""One word as a bitmap, each letter on its proper line.
Capitals and ascenders rise from the baseline to the cap height and the
rest only to the x-height, which is the whole point of doing this in
braille: a block font has five rows to spend and cannot afford two
heights.
"""
rows = [""] * HEIGHT
for letter in word or "":
if letter in CAPS:
glyph = _double(CAPS[letter])
elif letter in ASCENDERS:
glyph = _double(ASCENDERS[letter])
elif letter in LOWER:
glyph = _double(LOWER[letter])
else:
continue # no glyph: shorter, rather than gappy
top = BASELINE - len(glyph)
wide = max(len(r) for r in glyph)
for y in range(HEIGHT):
rows[y] += (glyph[y - top] if top <= y < top + len(glyph)
else " " * wide) + " "
return rows
def to_braille(rows) -> list[str]:
"""A bitmap as braille, two dots across and four down to a character."""
if not rows:
return []
wide = max(len(r) for r in rows)
if wide % 2:
wide += 1
grid = [r.ljust(wide) for r in rows]
while len(grid) % 4:
grid.append(" " * wide)
out = []
for top in range(0, len(grid), 4):
line = ""
for left in range(0, wide, 2):
bits = 0
for dy in range(4):
for dx in range(2):
if grid[top + dy][left + dx] not in " .":
bits |= DOTS[dy][dx]
line += chr(BRAILLE + bits)
out.append(line.rstrip() or " ")
return out
def banner(word: str) -> list[str]:
"""One word, as rows of braille ready to print."""
drawn = pixels(word)
if not any(r.strip() for r in drawn):
return []
return to_braille(drawn)
# Deep blue, through electric blue and azure, to cyan and a cool white at the
# far end. Deliberately *not* the waterfall ramp the rest of this program
# draws in: that one runs through green and yellow to red because it is
# standing in for a spectrum and has to mean something. A title screen means
# nothing, so it is free to be a colour scheme, and this is the one a terminal
# has always had. Blue is never the weakest channel and red is never the
# strongest, which is what keeps it cold all the way along.
_RAMP = ((10, 40, 140), (0, 90, 210), (0, 160, 245),
(0, 225, 255), (210, 250, 255))
def _colour(fraction: float) -> str:
"""A point on the ramp, as a style rich understands."""
fraction = max(0.0, min(1.0, fraction))
span = len(_RAMP) - 1
at = fraction * span
first = min(int(at), span - 1)
mix = at - first
a, b = _RAMP[first], _RAMP[first + 1]
rgb = tuple(int(round(a[i] + (b[i] - a[i]) * mix)) for i in range(3))
return f"rgb({rgb[0]},{rgb[1]},{rgb[2]})"
def wanted(console=None, no_splash: bool = False) -> bool:
"""Whether to draw one at all.
Three ways to say no, because a title screen that cannot be turned off
is a title screen somebody will end up grepping out of a log:
``--no-splash``, the environment, and not being a terminal.
"""
if no_splash:
return False
if os.environ.get("BANDSAUNTER_NO_SPLASH"):
return False
if console is not None:
return bool(getattr(console, "is_terminal", False))
return bool(sys.stdout.isatty())
def show(console, word: str, lines=(), *, no_splash: bool = False,
pause: float = 0.0, force: bool = False) -> bool:
"""Draw the title screen. Returns whether anything was drawn.
``pause`` is for a program that is about to take the whole screen over:
without it the banner is replaced in the same tenth of a second it was
drawn in, which is a waste of everybody's terminal.
"""
from rich.text import Text
if not force and not wanted(console, no_splash):
return False
rows = banner(word)
if not rows:
return False
width = max(len(row) for row in rows)
console.print()
for row in rows:
text = Text()
for i, char in enumerate(row):
# A braille blank is U+2800 rather than a space, and colouring
# something with no dots in it spends an escape sequence to no
# effect -- which on four rows of seventy-odd cells is most of
# what would be written.
if char in (" ", BLANK):
text.append(char)
else:
text.append(char, style=_colour(i / max(1, width - 1)))
console.print(text, justify="center")
for i, line in enumerate(lines):
# The first line under the banner is the one that gets read; the
# rest are dimmer, in the order they were given.
style = "bold white" if i == 0 else "grey62"
console.print(Text(line, style=style), justify="center")
console.print()
if pause > 0:
import time
time.sleep(pause)
return True

View file

@ -24,7 +24,9 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
from .ranges import RangeError, ScanRange, parse_frequency
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
"aircraft_menu", "first_run_setup", "TUIAbort"]
"aircraft_menu", "weather_menu", "aprs_menu", "ft8_menu",
"first_run_setup",
"TUIAbort"]
_BACK = ("", "b", "back", "q", "quit", "x")
@ -646,13 +648,62 @@ A picture takes minutes rather than seconds, so 'Max record time' has to be
long enough or what arrives is the top of one. A partial picture is kept and
labelled partial.
Utility meters on 900 MHz and AcuRite weather sensors on 433 MHz are named
rather than reported as hexadecimal, and neither is believed without its own
checksum.
Utility meters on 900 MHz are named rather than reported as hexadecimal, and
are not believed without their own checksum.
Aircraft are a separate command: `bandsaunter adsb` parks the receiver on
1090 MHz. ADS-B is a megabit a second and will not go through a channel
twelve and a half kilohertz wide, which is what a scan is made of."""),
Two things are separate commands, because neither fits through a scan.
`bandsaunter adsb` parks the receiver on 1090 MHz: ADS-B is a megabit a
second and will not go through a channel twelve and a half kilohertz wide.
`bandsaunter weather` parks it on 433.92 MHz for the weather sensors, whose
messages are bursts of a carrier switched on and off, which a scan records as
clicks."""),
"13": ("Weather sensors on 433 MHz", """
The plastic box on a fence post that came with a consumer weather station
broadcasts what it can see every sixteen seconds, in the clear, on 433.92
MHz. `bandsaunter weather` reads it, and reads five families of them:
Tower 592TXR temperature, humidity
5-in-1 06014RM wind speed, wind direction, rainfall, temperature, humidity
Lightning 6045M temperature, humidity, strike count, how far off the storm is
609TXC temperature, humidity
606TX temperature
Battery state comes from all of them. Nothing is reported that has not
satisfied its own checksum and, on the older two models, arrived twice.
The identity in the message is a number that came out of a hat in a factory,
so press n while listening to name whichever sensor is on the screen -- the
shed, the greenhouse -- and it keeps the name from then on. Names live in
sensors.yaml beside the settings and can be edited by hand.
`bandsaunter readings --csv` turns a log into a spreadsheet: a column per
quantity, a row per reading, the name in the second column."""),
"14": ("APRS on 144 MHz", """
One channel, one frequency, everybody: 144.390 MHz across North America and a
different number in every other region. `bandsaunter aprs` parks on it and
writes down everything that passes -- positions, weather, messages, objects,
telemetry -- from every amateur station in earshot and every digipeater
repeating them onward, which is most of what you will hear.
The frequency is agreed between amateurs rather than allocated, so check
--region first: on the wrong channel there is silence, not a bad signal.
north-america is 144.390, europe 144.800, australia 145.175.
Nothing here needs naming. A station broadcasts a callsign issued by a
government, which is already the name.
What it reads: positions both uncompressed and compressed; Mic-E, which every
Kenwood and Yaesu mobile sends and which hides half the position inside the
destination callsign; weather; messages, acknowledgements and bulletins;
objects and items; status; telemetry; and traffic relayed in from another
network. A packet in a format it cannot read keeps its text and says so,
rather than being reported as a position it never claimed.
Three tables when it stops: who was heard and how well, what they said, and
the messages in order. --at LAT,LON adds distance and bearing.
--direct-only leaves out anything that came through a digipeater, which is the
honest measure of what your aerial reaches. `bandsaunter packets --csv --kml`
turns a log into a spreadsheet and something Google Earth opens."""),
"11": ("Keys during a scan", """
q stop the scan
p pause and resume
@ -687,11 +738,27 @@ _AIRCRAFT_INTRO = (
)
def _options_table(console: Console, options, group: str) -> list[st.Setting]:
def _section(section=None):
"""The module that owns a set of options: aircraft unless told otherwise.
Every helper below works off ``OPTIONS``, ``OPTION_GROUPS``, ``in_group``,
``defaults`` and ``format_option``, which both sections provide and which
say nothing about aircraft or weather. That is what lets one set of
menus drive both, and what will let it drive a third.
"""
if section is not None:
return section
from . import aircraft as air
return air
def _options_table(console: Console, options, group: str,
section=None) -> list[st.Setting]:
air = _section(section)
items = air.in_group(group)
default = air.AircraftOptions()
default = air.defaults()
t = Table(box=None, header_style="bold", pad_edge=False,
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
t.add_column("#", style="grey62", width=3, justify="right")
@ -782,15 +849,15 @@ def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
_pick_option(console, options)
def _option_groups(console: Console, options) -> None:
def _option_groups(console: Console, options, section=None) -> None:
"""The groups, and how many of each has been changed from the default.
A list of six lines rather than a table of thirty-three: the options
are all still there, and this is the way in to them.
"""
from . import aircraft as air
air = _section(section)
default = air.AircraftOptions()
default = air.defaults()
t = Table(box=None, header_style="bold", pad_edge=False,
title="[bold]options[/bold]", title_justify="left")
t.add_column("#", style="grey62", width=3, justify="right")
@ -810,7 +877,7 @@ def _option_groups(console: Console, options) -> None:
console.print(t)
def _find_options(text: str) -> list:
def _find_options(text: str, section=None) -> list:
"""Every option this could mean, nearest match first.
An exact name wins outright. Typing "seconds" should reach the setting
@ -818,7 +885,7 @@ def _find_options(text: str) -> list:
to mention the word -- so a name that matches exactly is the answer, and
the wider search is only what happens when nothing does.
"""
from . import aircraft as air
air = _section(section)
wanted = text.strip().lower()
if not wanted:
@ -832,11 +899,12 @@ def _find_options(text: str) -> list:
or wanted in o.help.lower()]
def _option_list(console: Console, options, items, title: str) -> None:
def _option_list(console: Console, options, items, title: str,
section=None) -> None:
"""One table of whichever options were asked for."""
from . import aircraft as air
air = _section(section)
default = air.AircraftOptions()
default = air.defaults()
t = Table(box=None, header_style="bold", pad_edge=False,
title=f"[bold]{title}[/bold]", title_justify="left")
t.add_column("#", style="grey62", width=3, justify="right")
@ -852,24 +920,25 @@ def _option_list(console: Console, options, items, title: str) -> None:
console.print(t)
def _pick_option(console: Console, options) -> None:
def _pick_option(console: Console, options, section=None) -> None:
"""Ask which of the options just listed to change, and change it."""
console.print("[grey62]Enter an option number to change it, "
"[cyan]?N[/cyan] for what it does, or blank to go back."
"[/grey62]")
answer = _ask(console, " option").strip().lower()
if answer and answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer)
_edit_option(console, options, answer, section)
def _option_group_menu(console: Console, options, group: str) -> None:
def _option_group_menu(console: Console, options, group: str,
section=None) -> None:
"""One group of options, on a screen of its own."""
from . import aircraft as air
air = _section(section)
while True:
_rule(console, group.lower())
items = air.in_group(group)
_option_list(console, options, items, group.lower())
_option_list(console, options, items, group.lower(), air)
console.print("\n[grey62]Enter an option number to change it, "
"[cyan]?N[/cyan] for what it does, or [cyan]b[/cyan] "
"to go back.[/grey62]")
@ -877,15 +946,16 @@ def _option_group_menu(console: Console, options, group: str) -> None:
if not answer or answer in _BACK:
return
if answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer)
_edit_option(console, options, answer, air)
else:
console.print(" [yellow]enter a number from the list, "
"or b[/yellow]")
def _edit_option(console: Console, options, answer: str) -> None:
def _edit_option(console: Console, options, answer: str,
section=None) -> None:
"""Change one option, or explain it when asked with a question mark."""
from . import aircraft as air
air = _section(section)
want_help = answer.startswith("?")
index = int(answer.lstrip("?").strip())
@ -894,15 +964,16 @@ def _edit_option(console: Console, options, answer: str) -> None:
return
option = air.OPTIONS[index - 1]
if want_help:
option_help(console, option, options)
option_help(console, option, options, air)
else:
edit_setting(console, option, options,
default=air.AircraftOptions(), show_help=option_help)
edit_setting(console, option, options, default=air.defaults(),
show_help=lambda c, o, v: option_help(c, o, v, air))
def option_help(console: Console, option: st.Setting, options) -> None:
"""The same help panel the settings menu shows, for an aircraft option."""
from . import aircraft as air
def option_help(console: Console, option: st.Setting, options,
section=None) -> None:
"""The same help panel the settings menu shows, for a section option."""
air = _section(section)
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
"", option.help.capitalize() + "."]
@ -910,7 +981,7 @@ def option_help(console: Console, option: st.Setting, options) -> None:
body += ["", option.detail]
if option.guidance and option.guidance != option.detail:
body += ["", f"[grey62]{option.guidance}[/grey62]"]
default = air.AircraftOptions()
default = air.defaults()
body += ["", f"[grey62]now:[/grey62] "
f"{air.format_option(option, getattr(options, option.key))}"
f" [grey62]default:[/grey62] "
@ -927,6 +998,485 @@ def option_help(console: Console, option: st.Setting, options) -> None:
border_style="blue", padding=(0, 1)))
# ---------------------------------------------------------------------------
# APRS
# ---------------------------------------------------------------------------
_APRS_INTRO = (
"One channel, one frequency, everybody: 144.390 MHz across North "
"America and a different number in every other region, carrying "
"position reports, weather, messages, objects and telemetry from every "
"amateur station within earshot \u2014 and from every hilltop "
"digipeater repeating them onward, which is most of what you will "
"hear.\n\n"
"Unlike the other two modes this is a conversation rather than a "
"broadcast. Stations address each other, acknowledge each other and "
"relay for each other, so what is worth showing is not only who is out "
"there but what was said.\n\n"
"Nothing here has to be named. A weather sensor broadcasts a number out "
"of a hat; an APRS station broadcasts a callsign issued by a "
"government, which is already the name."
)
def aprs_menu(console: Console, cfg: ScanConfig) -> None:
"""Listen to the APRS channel, without a command line."""
from . import aprs as ap
options = ap.load_options()
while True:
_rule(console, "APRS (144 MHz packet)")
console.print(Panel(Text.from_markup(_APRS_INTRO),
border_style="blue", padding=(0, 1)))
_option_groups(console, options, ap)
logs = ap.logs_in(cfg.output_dir)
kept = "no logs yet" if not logs else \
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
window = "opens a window" if ap.windowed() else "needs Qt \u2014 see ?"
console.print(
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
f" [grey62]{ap.describe(options)}[/grey62]\n"
f" [cyan]w[/cyan] [bold green]Realtime map[/bold green]"
f" [grey62]{window}, the stations on it, and they stay"
f"[/grey62]\n"
# The channel is the setting that decides whether anything is
# heard at all, and it cannot be discovered from the air on any
# one frequency, so it sits here rather than a level down among
# the gain and the sample rate.
f" [cyan]c[/cyan] Channel / region "
f"[bold]{ap.channel_text(options)}[/bold]\n"
f" [cyan]f[/cyan] Find the channel [grey62]listen on each "
f"region's in turn and see which has traffic[/grey62]\n"
f" [cyan]r[/cyan] Read a log back [grey62]{kept} in "
f"{cfg.output_dir}[/grey62]\n"
f" [cyan]N[/cyan] open group N "
f"[grey62]or type part of an option's name to find it[/grey62]\n"
f" [cyan]s[/cyan] Save these as default "
f"[grey62]kept in {ap.options_path()}[/grey62]\n"
f" [cyan]d[/cyan] Reset them\n"
f" [cyan]b[/cyan] Back\n")
answer = _ask(console, " choice", "l").strip().lower()
if answer in _BACK:
return
if answer in ("l", "listen", "p"):
_aprs_listen(console, cfg, options)
elif answer in ("w", "window", "map", "realtime"):
_aprs_watch(console, cfg, options)
elif answer in ("c", "channel", "region"):
_aprs_channel(console, options)
elif answer in ("f", "find", "search", "scan"):
_aprs_find(console, options)
elif answer in ("r", "read", "packets", "m"):
_aprs_read(console, cfg, options, logs)
elif answer == "s":
try:
where = ap.save_options(options)
console.print(f" [green]saved to {where}[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
elif answer == "d":
if _confirm(" reset every APRS option"):
options = ap.AprsOptions()
console.print(" [green]reset[/green]")
elif answer.isdigit() and 1 <= int(answer) <= len(ap.OPTION_GROUPS):
_option_group_menu(console, options,
ap.OPTION_GROUPS[int(answer) - 1], ap)
elif answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer, ap)
elif answer:
found = _find_options(answer, ap)
if not found:
console.print(f" [yellow]nothing matches {answer!r} \u2014 "
f"enter a group number, or l, w, c, f, r, s, "
f"d or b[/yellow]")
elif len(found) == 1:
_edit_option(console, options,
str(ap.OPTIONS.index(found[0]) + 1), ap)
else:
_option_list(console, options, found, f"matching {answer!r}",
ap)
_pick_option(console, options, ap)
def _aprs_watch(console: Console, cfg: ScanConfig, options) -> None:
"""Open the window, and come back to the menu when it is closed."""
from . import aprs as ap
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
console.print("[grey62]closing the window stops the listening and "
"writes the log and the report, exactly as listening "
"without one does. Stations stay on the map once they have "
"been heard \u2014 they do not fade off it.[/grey62]")
try:
ap.watch(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
def _aprs_channel(console: Console, options) -> None:
"""Choose the region, which sets the frequency with it.
On the front page of the menu because it is the one setting that decides
whether anything is heard at all, and because being on the wrong channel
sounds exactly like having no aerial.
"""
from . import aprs as ap
from .ax25 import APRS_CHANNELS
_rule(console, "APRS channel")
console.print(Panel(Text.from_markup(
"The frequency is agreed between amateurs rather than allocated, so "
"it differs by region and there is no way to discover it from the "
"air: on the wrong channel there is [bold]silence, not a bad "
"signal[/bold].\n\n"
"If you do not know which applies, [cyan]f[/cyan] on the previous "
"screen listens on each in turn and tells you which has traffic."),
border_style="blue", padding=(0, 1)))
t = Table(box=None, header_style="bold", pad_edge=False)
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("region", width=15)
t.add_column("frequency", justify="right", width=12)
t.add_column("used in", style="grey62", overflow="fold")
for i, (region, hz, where) in enumerate(APRS_CHANNELS, 1):
here = abs(hz - options.frequency) < 1.0
t.add_row(str(i),
Text(region, style="bold cyan" if here else "white"),
f"{hz / 1e6:.3f} MHz", where + (" ← now" if here else ""))
console.print(t)
console.print("\n[grey62]Enter a number, a frequency in MHz for a "
"channel that is not a region's, or blank to go back."
"[/grey62]")
answer = _ask(console, " channel").strip()
if not answer or answer in _BACK:
return
if answer.isdigit() and 1 <= int(answer) <= len(APRS_CHANNELS):
ap.use_region(options, APRS_CHANNELS[int(answer) - 1][0])
console.print(f" [green]{ap.channel_text(options)}[/green]")
return
try:
megahertz = float(answer)
except ValueError:
console.print(" [yellow]enter a number from the list, or a "
"frequency in MHz[/yellow]")
return
hz = megahertz * 1e6 if megahertz < 1e6 else megahertz
options.frequency = hz
console.print(f" [green]{ap.channel_text(options)}[/green]")
def _aprs_find(console: Console, options) -> None:
"""Listen on every region's channel and say which has traffic."""
from . import aprs as ap
_rule(console, "find the channel")
console.print(Panel(Text.from_markup(
"Each region's channel in turn, for a few seconds each. This answers "
"the one question about APRS that cannot be answered on any single "
"frequency, because the answer [bold]is[/bold] a frequency.\n\n"
"A quiet channel is not proof of an empty one \u2014 a fixed station "
"beacons every half hour \u2014 so what this finds is traffic, and "
"what it misses is only the absence of traffic while it listened."),
border_style="blue", padding=(0, 1)))
answer = _ask(console, " seconds on each", "20").strip()
try:
seconds = max(2.0, float(answer))
except ValueError:
console.print(" [yellow]that is not a number of seconds[/yellow]")
return
console.print(f"[grey62]about {seconds * len(ap.ax25.APRS_CHANNELS):.0f} "
f"seconds altogether \u2014 control-C stops it[/grey62]")
try:
found = ap.find_channel(console, options, seconds)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
return
best = ap.report_channels(console, found, seconds)
if best is None:
return
if _confirm(f" listen on {best.region} from now on"):
ap.use_region(options, best.region)
console.print(f" [green]{ap.channel_text(options)}[/green] "
f"[grey62]\u2014 press s to keep it[/grey62]")
def _aprs_listen(console: Console, cfg: ScanConfig, options) -> None:
from . import aprs as ap
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
console.print("[grey62]control-C stops listening and comes back here. "
"Stations beacon every few minutes, so give it a while."
"[/grey62]")
try:
ap.listen(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
def _aprs_read(console: Console, cfg: ScanConfig, options, logs) -> None:
"""Pick a log and read it back, newest first."""
from . import aprs as ap
from .aprslog import read_logs, write_csv, write_kml
if not logs:
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
"listen first, or turn the invented channel on"
"[/yellow]")
return
t = Table(box=None, header_style="bold")
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("log")
t.add_column("when", style="grey62")
t.add_column("size", style="grey62", justify="right")
for i, path in enumerate(logs[:12], 1):
stat = path.stat()
t.add_row(str(i), path.name, _when(stat.st_mtime),
f"{stat.st_size / 1e6:.2f} MB")
console.print(t)
answer = _ask(console, " which log", "1").strip()
if answer in _BACK or not answer.isdigit():
return
index = int(answer)
if not 1 <= index <= min(12, len(logs)):
console.print(" [yellow]no such number[/yellow]")
return
path = logs[index - 1]
heard = read_logs([path])
if not heard:
console.print(f" [yellow]{path.name} holds no packets[/yellow]")
return
ap.report(console, ap.Net.of(heard), options)
if _confirm(" write a map and a spreadsheet too"):
for writer, suffix in ((write_csv, ".csv"), (write_kml, ".kml")):
try:
where = writer(path.with_suffix(suffix), heard,
options.imperial)
except OSError as exc:
console.print(f" [red]could not write it: {exc}[/red]")
continue
if where is not None:
console.print(f" [green]wrote {where}[/green]")
# ---------------------------------------------------------------------------
# Weather sensors
# ---------------------------------------------------------------------------
_WEATHER_INTRO = (
"Every consumer weather station has a plastic box on a fence post which "
"says what it can see, in the clear, on 433.92 MHz, every sixteen "
"seconds \u2014 to the display in the kitchen and to anyone else "
"listening. This reads the box.\n\n"
"Temperature and humidity from all of them; wind speed, wind direction "
"and rainfall from a 5-in-1; lightning strikes and how far off the storm "
"is from a 6045M. Battery state from every one.\n\n"
"What a sensor cannot tell you is which sensor it is: the identity in "
"the message came out of a hat in a factory. So [bold]press n while "
"listening[/bold] to give whichever is on the screen a name \u2014 the "
"shed, the greenhouse, the back fence \u2014 and it keeps it from then "
"on, in the display, in the log and in the spreadsheet."
)
def weather_menu(console: Console, cfg: ScanConfig) -> None:
"""Listen to the weather sensors, and name them, without a command line."""
from . import weather as wx
from .sensors import SensorBook
options = wx.load_options()
while True:
_rule(console, "weather sensors (433 MHz)")
console.print(Panel(Text.from_markup(_WEATHER_INTRO),
border_style="blue", padding=(0, 1)))
_option_groups(console, options, wx)
logs = wx.logs_in(cfg.output_dir)
book = SensorBook()
kept = "no logs yet" if not logs else \
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
named = sum(1 for s in book.ordered() if s.named)
known = (f"{len(book)} heard, {named} named" if len(book)
else "none heard yet")
console.print(
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
f" [grey62]{wx.describe(options)}[/grey62]\n"
f" [cyan]n[/cyan] Name the sensors [grey62]{known}"
f"[/grey62]\n"
f" [cyan]r[/cyan] Read a log back [grey62]{kept} in "
f"{cfg.output_dir}[/grey62]\n"
f" [cyan]N[/cyan] open group N "
f"[grey62]or type part of an option's name to find it[/grey62]\n"
f" [cyan]s[/cyan] Save these as default "
f"[grey62]kept in {wx.options_path()}[/grey62]\n"
f" [cyan]d[/cyan] Reset them\n"
f" [cyan]b[/cyan] Back\n")
answer = _ask(console, " choice", "l").strip().lower()
if answer in _BACK:
return
if answer in ("l", "listen", "p"):
_weather_listen(console, cfg, options)
elif answer in ("n", "name", "names"):
_sensor_names(console, book)
elif answer in ("r", "read", "readings", "m"):
_weather_readings(console, cfg, options, logs)
elif answer == "s":
try:
where = wx.save_options(options)
console.print(f" [green]saved to {where}[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
elif answer == "d":
if _confirm(" reset every weather option"):
options = wx.WeatherOptions()
console.print(" [green]reset[/green]")
elif answer.isdigit() and 1 <= int(answer) <= len(wx.OPTION_GROUPS):
_option_group_menu(console, options,
wx.OPTION_GROUPS[int(answer) - 1], wx)
elif answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer, wx)
elif answer:
found = _find_options(answer, wx)
if not found:
console.print(f" [yellow]nothing matches {answer!r} \u2014 "
f"enter a group number, or l, n, r, s, d or b"
f"[/yellow]")
elif len(found) == 1:
_edit_option(console, options,
str(wx.OPTIONS.index(found[0]) + 1), wx)
else:
_option_list(console, options, found, f"matching {answer!r}",
wx)
_pick_option(console, options, wx)
def _weather_listen(console: Console, cfg: ScanConfig, options) -> None:
"""Run a listening session from the menu and come back afterwards."""
from . import weather as wx
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
console.print("[grey62]control-C stops listening and comes back here. "
"Press [cyan]n[/cyan] while it runs to name a sensor."
"[/grey62]")
try:
wx.listen(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
def _sensor_names(console: Console, book) -> None:
"""The list of everything ever heard, and what it is called.
Everything ever heard rather than everything heard lately, because a
sensor with a flat battery is exactly the one somebody wants to look up.
"""
while True:
_rule(console, "sensor names")
sensors = book.ordered()
if not sensors:
console.print(" [yellow]nothing heard yet. Listen first, or "
"turn the invented garden on.[/yellow]")
return
t = Table(box=None, header_style="bold", pad_edge=False)
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("name", width=18)
t.add_column("id", style="grey62", width=6)
t.add_column("model", style="grey62", width=18)
t.add_column("msgs", style="grey62", justify="right", width=7)
t.add_column("last heard", style="grey62")
t.add_column("note", style="grey62", overflow="fold")
for i, sensor in enumerate(sensors, 1):
t.add_row(str(i),
Text(sensor.name, style="bold") if sensor.named
else Text("unnamed", style="yellow"),
sensor.sensor, sensor.model, f"{sensor.messages:,}",
_when(sensor.last_heard) if sensor.last_heard else "",
sensor.note)
console.print(t)
console.print(f"\n[grey62]Enter a number to name it, "
f"[cyan]-N[/cyan] to forget it, or [cyan]b[/cyan] to go "
f"back. Kept in {book.path}.[/grey62]")
answer = _ask(console, " sensor", "b").strip().lower()
if not answer or answer in _BACK:
return
forget = answer.startswith("-")
index = answer.lstrip("-").strip()
if not index.isdigit() or not 1 <= int(index) <= len(sensors):
console.print(" [yellow]enter a number from the list[/yellow]")
continue
sensor = sensors[int(index) - 1]
if forget:
if _confirm(f" forget {sensor.label()}"):
book.forget(sensor.key)
console.print(" [green]forgotten[/green]")
continue
name = _ask(console, f" a name for {sensor.sensor}",
sensor.name).strip()
note = _ask(console, " a note (optional)", sensor.note).strip()
try:
book.tag(sensor.key, name, note)
console.print(f" [green]saved[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
def _weather_readings(console: Console, cfg: ScanConfig, options,
logs) -> None:
"""Pick a log and read it back, newest first."""
from . import weather as wx
from .sensors import SensorBook
from .weatherlog import read_logs, write_csv
if not logs:
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
"listen first, or turn the invented garden on[/yellow]")
return
t = Table(box=None, header_style="bold")
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("log")
t.add_column("when", style="grey62")
t.add_column("size", style="grey62", justify="right")
for i, path in enumerate(logs[:12], 1):
stat = path.stat()
t.add_row(str(i), path.name, _when(stat.st_mtime),
f"{stat.st_size / 1e6:.2f} MB")
console.print(t)
answer = _ask(console, " which log", "1").strip()
if answer in _BACK or not answer.isdigit():
return
index = int(answer)
if not 1 <= index <= min(12, len(logs)):
console.print(" [yellow]no such number[/yellow]")
return
path = logs[index - 1]
readings = read_logs([path])
if not readings:
console.print(f" [yellow]{path.name} holds no readings[/yellow]")
return
book = SensorBook()
wx.report(console, wx.Garden.of(readings), book, options.imperial)
if _confirm(" write it as a spreadsheet too"):
try:
where = write_csv(path.with_suffix(".csv"), readings, book,
options.imperial)
console.print(f" [green]wrote {where}[/green]")
except OSError as exc:
console.print(f" [red]could not write it: {exc}[/red]")
def _listen(console: Console, cfg: ScanConfig, options) -> None:
"""Run a listening session from the menu and come back afterwards."""
from . import aircraft as air
@ -1122,6 +1672,14 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
f" [cyan]4[/cyan] Saved settings and profiles\n"
f" [cyan]5[/cyan] Aircraft (ADS-B) "
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
f" [cyan]6[/cyan] Weather sensors "
f"[grey62]listen on 433 MHz, name what is out there[/grey62]\n"
f" [cyan]7[/cyan] APRS (144 MHz packet) "
f"[grey62]positions, weather and messages from amateurs"
f"[/grey62]\n"
f" [cyan]8[/cyan] FT8 "
f"[grey62]fifteen-second slots, whole bands at once, mostly "
f"under the noise[/grey62]\n"
f" [cyan]h[/cyan] Help\n"
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
f" [cyan]q[/cyan] Quit\n")
@ -1137,6 +1695,12 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
cfg = profiles_menu(console, cfg)
elif choice == "5":
aircraft_menu(console, cfg)
elif choice == "6":
weather_menu(console, cfg)
elif choice == "7":
aprs_menu(console, cfg)
elif choice == "8":
ft8_menu(console, cfg)
elif choice in ("h", "?", "help"):
help_screen(console)
elif choice in ("s", "start", "go"):
@ -1148,3 +1712,195 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
return cfg
elif choice in ("q", "quit", "exit"):
return None
_FT8_INTRO = (
"[bold]FT8[/bold] is fifteen seconds of everybody at once. Every station "
"on the band transmits in the same quarter-minute slots, on the same "
"dial frequency, fifty hertz wide each, stacked across three kilohertz "
"of audio — so one receiver parked on one frequency hears the whole "
"band's worth of stations at the same time.\n\n"
"Most of them arrive [bold]below the noise[/bold], and decode anyway: "
"half of what is sent is error-correcting code, which is what buys a "
"mode that works twenty decibels under what an operator can hear.\n\n"
"Two things it needs. The [bold]clock[/bold] has to be right to a second "
"or two, because the slots are quarter-minutes of UTC and every station "
"on earth agrees about which one it is. And almost all the activity is "
"on [bold]shortwave[/bold], which a plain receiver of this kind cannot "
"reach without an upconverter or direct sampling — so the default here "
"is the two-metre channel, which it can.\n\n"
"Nothing here transmits. It listens, decodes and writes down."
)
def ft8_menu(console: Console, cfg: ScanConfig) -> None:
"""Listen to FT8, without a command line."""
from . import ft8
options = ft8.load_options()
while True:
_rule(console, "FT8")
console.print(Panel(Text.from_markup(_FT8_INTRO),
border_style="blue", padding=(0, 1)))
_option_groups(console, options, ft8)
logs = ft8.logs_in(cfg.output_dir)
kept = "no logs yet" if not logs else \
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
console.print(
f"\n [cyan]l[/cyan] [bold green]Listen[/bold green]"
f" [grey62]{ft8.describe(options)}[/grey62]\n"
# The band is the setting that decides whether anything is heard
# at all, and whether the receiver can reach it, so it sits here
# rather than a level down among the gain and the sample rate.
f" [cyan]c[/cyan] Band / channel "
f"[bold]{ft8.band_text(options)}[/bold]\n"
f" [cyan]g[/cyan] Where you are "
f"[grey62]{options.grid.upper() or 'not set — no distances'}"
f"[/grey62]\n"
f" [cyan]r[/cyan] Read a log back [grey62]{kept} in "
f"{cfg.output_dir}[/grey62]\n"
f" [cyan]N[/cyan] open group N "
f"[grey62]or type part of an option's name to find it[/grey62]\n"
f" [cyan]s[/cyan] Save these as default "
f"[grey62]kept in {ft8.options_path()}[/grey62]\n"
f" [cyan]d[/cyan] Reset them\n"
f" [cyan]b[/cyan] Back\n")
answer = _ask(console, " choice", "l").strip().lower()
if answer in _BACK:
return
if answer in ("l", "listen", "p"):
_ft8_listen(console, cfg, options)
elif answer in ("c", "channel", "band"):
_ft8_band(console, options)
elif answer in ("g", "grid", "where"):
_ft8_grid(console, options)
elif answer in ("r", "read", "m"):
_ft8_read(console, cfg, options, logs)
elif answer == "s":
try:
where = ft8.save_options(options)
console.print(f" [green]saved to {where}[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
elif answer == "d":
if _confirm(" reset every FT8 option"):
options = ft8.Ft8Options()
console.print(" [green]reset[/green]")
elif answer.isdigit() and 1 <= int(answer) <= len(ft8.OPTION_GROUPS):
_option_group_menu(console, options,
ft8.OPTION_GROUPS[int(answer) - 1], ft8)
elif answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer, ft8)
elif answer:
found = _find_options(answer, ft8)
if not found:
console.print(f" [yellow]nothing matches {answer!r} — "
f"enter a group number, or l, c, g, r, s, d "
f"or b[/yellow]")
elif len(found) == 1:
_edit_option(console, options,
str(ft8.OPTIONS.index(found[0]) + 1), ft8)
else:
_option_list(console, options, found, f"matching {answer!r}",
ft8)
_pick_option(console, options, ft8)
def _ft8_listen(console: Console, cfg: ScanConfig, options) -> None:
from . import ft8
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
try:
ft8.listen(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
def _ft8_band(console: Console, options) -> None:
"""Choose the band, which sets the dial frequency with it.
Which band is also the question of whether the receiver can hear it at
all, so the list says so rather than leaving somebody to find out by
listening to silence for ten minutes.
"""
from . import ft8
console.print("\n [bold]Which band[/bold]\n")
for i, (name, hz, needs) in enumerate(ft8.BANDS, 1):
note = ("[yellow]needs an upconverter or direct sampling[/yellow]"
if needs == "shortwave"
else "[green]a plain receiver reaches this[/green]")
here = " [green]<- now[/green]" if options.band == name else ""
console.print(f" [cyan]{i:>2}[/cyan] {name:<5} "
f"{hz / 1e6:>10.3f} MHz {note}{here}")
console.print("\n [grey62]Almost all the activity is on shortwave, and "
"20m is the busiest band on earth. Two metres is quiet by "
"comparison and is what a plain dongle can reach.[/grey62]")
answer = _ask(console, "\n band (number, name, or blank to keep)",
"").strip().lower()
if not answer:
return
if answer.isdigit() and 1 <= int(answer) <= len(ft8.BANDS):
ft8.use_band(options, ft8.BANDS[int(answer) - 1][0])
elif any(answer == name for name, _hz, _n in ft8.BANDS):
ft8.use_band(options, answer)
else:
console.print(f" [yellow]no band called {answer!r}[/yellow]")
return
console.print(f" [green]{ft8.band_text(options)}[/green]")
def _ft8_grid(console: Console, options) -> None:
"""Set the grid square, which is what distances are measured from."""
from . import ft8
console.print("\n [grey62]Your Maidenhead grid square: four characters "
"like IO91 or FN31. Every station calling CQ says where it "
"is this way, so with yours filled in the report can say "
"how far each of them is and on what bearing — which on "
"shortwave is the whole interest of the thing.[/grey62]")
answer = _ask(console, "\n grid square (blank to clear)",
options.grid).strip()
if not answer:
options.grid = ""
console.print(" [green]cleared — no distances[/green]")
return
if ft8.grid_at(answer) is None:
console.print(f" [yellow]{answer!r} is not a grid square[/yellow]")
return
options.grid = answer.upper()
lat, lon = ft8.grid_at(options.grid)
console.print(f" [green]{options.grid} — {lat:.2f},{lon:.2f}[/green]")
def _ft8_read(console: Console, cfg: ScanConfig, options, logs) -> None:
"""Read an evening's decodes back off the disk."""
from . import ft8, ft8log
if not logs:
console.print(" [yellow]no FT8 logs yet — listen first[/yellow]")
return
console.print("\n [bold]Which log[/bold]\n")
for i, path in enumerate(logs[-20:], 1):
console.print(f" [cyan]{i:>2}[/cyan] {path.name}")
answer = _ask(console, "\n log (number, or blank to go back)",
"").strip()
if not answer.isdigit():
return
picked = logs[-20:][int(answer) - 1] if 1 <= int(answer) <= len(logs[-20:]) \
else None
if picked is None:
return
rows = ft8log.read_log(picked)
console.print(f"\n [grey62]{len(rows)} decodes in {picked.name}"
f"[/grey62]\n")
for row in rows[:40]:
console.print(f" {row['snr_db']:>4.0f} {row['offset']:>5.1f} "
f"{row['hertz']:>7.1f} ~ {row['text']}")
if len(rows) > 40:
console.print(f" [grey62]…and {len(rows) - 40} more[/grey62]")

View file

@ -22,8 +22,8 @@ from .flightlog import in_speed, speed_label
from .recorder import HitRecord
from .scanner import Detection, Scanner
__all__ = ["ScanDisplay", "AircraftDisplay", "KeyReader", "print_hit",
"print_band_table"]
__all__ = ["ScanDisplay", "AircraftDisplay", "WeatherDisplay",
"AprsDisplay", "KeyReader", "print_hit", "print_band_table"]
_SPARK = " ▁▂▃▄▅▆▇█"
@ -790,3 +790,425 @@ def print_band_table(console: Console, presets, title: str = "band plan") -> Non
if p.is_group else f"{fmt_hz(p.start)} - {fmt_hz(p.stop)}")
t.add_row(p.key, p.name, extent, p.mode, p.note)
console.print(t)
class WeatherDisplay:
"""One line per weather sensor, updated in place while listening.
A sensor appears when its first message arrives and stays until nothing
has been heard from it for ``hold`` seconds -- half an hour by default,
which is long compared with the sixteen seconds between messages and so
means a sensor that vanishes has really stopped rather than been missed.
The line shows the *latest value of every quantity*, not the latest
message. A 5-in-1 has more to say than fits in one message and sends two
kinds alternately, so its last message is either the wind and the rain or
the temperature and the humidity, never both; showing the newest of each
means the line is the whole sensor rather than half of it flickering.
Rows are numbered and the numbers do not move, because naming a sensor
means reading a row and then typing its number, and a list that reorders
itself between those two moments is a list that gets things named wrong.
"""
def __init__(self, console: Console, book=None, hold: float = 1800.0,
imperial: bool = False, frequency: float = 433.92e6):
self.console = console
self.book = book
self.hold = hold
self.imperial = imperial
self.frequency = frequency
self.messages = 0
self.started = time.time()
self.log_path = None
self.garden = None
# -- what the listener tells it ---------------------------------------
def update(self, garden, messages: int, log_path=None) -> None:
self.garden = garden
self.messages = messages
if log_path is not None:
self.log_path = log_path
def showing(self, now: float | None = None) -> list:
if self.garden is None:
return []
return self.garden.showing(self.hold, now)
# -- drawing ----------------------------------------------------------
def render(self, now: float | None = None, width: int | None = None):
now = time.time() if now is None else now
width = self.console.size.width if width is None else width
here = self.showing(now)
parts = [self._header(here, now)]
if here:
parts.append(self._table(here, now, width))
else:
parts.append(Panel(_one_line(
"[grey62]nothing heard yet — these are a few milliwatts at "
"433.92 MHz, and a quarter-wave whip is 17 cm[/grey62]"),
border_style="grey37", padding=(0, 1)))
return Group(*parts)
def _header(self, here, now: float) -> Panel:
elapsed = max(0.001, now - self.started)
nameless = sum(1 for s in here if not self._name(s))
where = f" [grey62]{self.log_path.name}[/grey62]" if self.log_path \
else ""
# The offer to name something is in the header rather than at the
# bottom because the bottom of this display moves as sensors arrive.
naming = f"[bold cyan]n[/bold cyan] [grey62]to name" \
f"{f' ({nameless} unnamed)' if nameless else ''}[/grey62]"
return Panel(_one_line(
f"[bold cyan]{self.frequency / 1e6:g} MHz[/bold cyan] "
f"[bold]{len(here)}[/bold] sensor{'s' if len(here) != 1 else ''} "
f"[bold]{self.messages}[/bold] message"
f"{'s' if self.messages != 1 else ''} "
f"[grey62]{_dur(elapsed)}[/grey62]{where} {naming} "
f"[grey62]control-C to stop[/grey62]"),
border_style="blue", padding=(0, 1))
def _name(self, station) -> str:
return self.book.name_for(station.key) if self.book is not None else ""
def _table(self, here, now: float, width: int) -> Table:
"""As many columns as the terminal has room for, widest first.
A narrow terminal keeps the number, the name and what the sensor
said, and drops the model and the reception statistics: the first
three are why anyone is looking, and the rest can be read in the
report afterwards.
"""
t = Table(box=None, header_style="bold", pad_edge=False, expand=False)
t.add_column("#", style="grey62", width=2, justify="right")
t.add_column("name", width=14, no_wrap=True)
t.add_column("id", width=5, style="grey62", no_wrap=True)
if width >= 100:
# The same identity in decimal, which is the form other tools
# for this band print and the form anybody's own list is in.
t.add_column("dec", width=6, style="grey62", no_wrap=True,
justify="right")
if width >= 92:
t.add_column("model", width=16, style="grey62", no_wrap=True)
t.add_column("readings", overflow="fold")
if width >= 68:
# Widest-first, but this one is kept on a narrow terminal: it is
# what somebody moving an aerial about is watching, and they are
# not doing it on a wide window.
t.add_column("signal", width=7, justify="right")
t.add_column("batt", width=4, justify="center")
if width >= 84:
t.add_column("msgs", width=5, justify="right", style="grey62")
t.add_column("ago", width=5, justify="right", style="grey62")
for i, station in enumerate(here, 1):
name = self._name(station)
row = [str(i),
Text(name, style="bold") if name
else Text("unnamed", style="yellow"),
station.sensor]
if width >= 100:
row.append(station.number)
if width >= 92:
row.append(station.model or "")
row.append(self._readings(station, now))
if width >= 68:
from .weather import signal_text
row.append(Text.from_markup(signal_text(station.snr)))
row.append(Text("low", style="bold red") if station.battery_low
else Text("ok", style="green"))
if width >= 84:
row.append(f"{station.messages:,}")
row.append(_dur(max(0.0, now - station.last)))
t.add_row(*row)
return t
def _readings(self, station, now: float) -> Text:
"""Everything the sensor is currently saying, newest values first.
A quantity that has not been reported for a while is dimmed rather
than dropped. The 5-in-1 alternates its two messages, so half of
what it says is always a message old and dropping that would make
the line flicker; but a quantity that has been stale for minutes
while the sensor is otherwise fine is worth seeing greyed out.
"""
from .acurite import format_measure
out = Text()
for name, measure in station.values.items():
if out.plain:
out.append(" ")
stale = now - station.times.get(name, now) > 90.0
out.append(f"{name} ", style="grey62")
out.append(format_measure(measure, self.imperial),
style="grey58" if stale else "bold white")
if station.unread and not station.values:
out.append("framed, not understood", style="grey62")
return out
class AprsDisplay:
"""One line per station on the APRS channel, updated in place.
Ordered by when each was last heard, newest at the top, which is the
opposite of the other two displays in this program and is deliberate.
A weather sensor speaks every sixteen seconds and a station's row should
stay where the eye left it; an APRS channel is a hundred stations
beaconing every few minutes, and what somebody watching wants to know is
what just came in.
"""
def __init__(self, console: Console, hold: float = 3600.0,
imperial: bool = False, frequency: float = 144.39e6,
home=None):
self.console = console
self.hold = hold
self.imperial = imperial
self.frequency = frequency
self.home = home
self.packets = 0
self.started = time.time()
self.log_path = None
self.net = None
def update(self, net, packets_seen: int, log_path=None) -> None:
self.net = net
self.packets = packets_seen
if log_path is not None:
self.log_path = log_path
def showing(self, now: float | None = None) -> list:
return [] if self.net is None else self.net.showing(self.hold, now)
def render(self, now: float | None = None, width: int | None = None):
now = time.time() if now is None else now
width = self.console.size.width if width is None else width
here = self.showing(now)
parts = [self._header(here, now)]
if here:
parts.append(self._table(here, now, width))
else:
parts.append(Panel(_one_line(
"[grey62]nothing heard yet — a station beacons every few "
"minutes, so give it a while, and check the region: on the "
"wrong channel there is silence rather than a bad signal"
"[/grey62]"), border_style="grey37", padding=(0, 1)))
return Group(*parts)
def _header(self, here, now: float) -> Panel:
elapsed = max(0.001, now - self.started)
heard = len(self.net) if self.net is not None else 0
messages = len(self.net.messages) if self.net is not None else 0
where = f" [grey62]{self.log_path.name}[/grey62]" if self.log_path \
else ""
return Panel(_one_line(
f"[bold cyan]{self.frequency / 1e6:g} MHz[/bold cyan] "
f"[bold]{len(here)}[/bold] station"
f"{'s' if len(here) != 1 else ''} "
f"[grey62]{heard} seen[/grey62] "
f"[bold]{self.packets}[/bold] packet"
f"{'s' if self.packets != 1 else ''} "
f"[grey62]{messages} message{'s' if messages != 1 else ''}"
f" {_dur(elapsed)}[/grey62]{where} "
f"[grey62]control-C to stop[/grey62]"),
border_style="blue", padding=(0, 1))
def _table(self, here, now: float, width: int) -> Table:
"""As many columns as the terminal has room for, widest first."""
from .acurite import Measure, compass, format_measure
from .aprs import signal_text
from .aprs import licensee, who_text
named = self.net is not None and getattr(self.net, "book", None) \
is not None
t = Table(box=None, header_style="bold", pad_edge=False, expand=False)
t.add_column("station", width=10, no_wrap=True)
# Wide terminals only. A name is the most interesting thing about a
# callsign and the least urgent: what somebody watching a channel
# needs is who is transmitting now, and the names are all in the
# report afterwards.
if named and width >= 110:
t.add_column("licensed to", width=22, style="grey62",
no_wrap=True)
if width >= 96:
t.add_column("what", width=15, style="grey62", no_wrap=True)
t.add_column("said", overflow="fold")
if self.home is not None and width >= 76:
t.add_column("away", width=11, justify="right", no_wrap=True)
if width >= 66:
t.add_column("signal", width=6, justify="right")
if width >= 86:
t.add_column("pkts", width=4, justify="right", style="grey62")
t.add_column("ago", width=5, justify="right", style="grey62")
for station in here:
row = [Text(station.call, style="bold")]
if named and width >= 110:
row.append("" if station.object_of
else who_text(licensee(self.net, station.call)))
if width >= 96:
row.append(station.symbol or station.kind)
row.append(self._said(station))
if self.home is not None and width >= 76:
away = station.away(self.home)
row.append("" if away is None else Text(
f"{format_measure(Measure('', away[0], 'km'), self.imperial)}"
f" {compass(away[1])}"))
if width >= 66:
row.append(Text.from_markup(signal_text(station.snr)))
if width >= 86:
row.append(f"{station.packets:,}")
row.append(_dur(max(0.0, now - station.last)))
t.add_row(*row)
return t
def _said(self, station) -> Text:
"""The most recent thing worth reading from this station."""
from .acurite import Measure, compass, format_measure
out = Text()
if station.position is not None:
out.append(station.position.describe(), style="white")
if station.moving:
out.append(f" {compass(station.course or 0.0)} ", style="grey62")
out.append(format_measure(Measure("", station.speed, "km/h"),
self.imperial), style="cyan")
if station.weather:
for name, measure in list(station.weather.items())[:3]:
out.append(f" {name} ", style="grey62")
out.append(format_measure(measure, self.imperial),
style="white")
for words in (station.status, station.comment):
if words:
out.append(" " + words[:40], style="grey70")
break
if not out.plain:
out.append(station.kind, style="grey62")
return out
class Ft8Display:
"""The last slot's decodes, above a table of who has been heard.
Two things at once because FT8 is two things at once. A slot is an
event -- forty stations transmitted, here is what they said -- and the
evening is an accumulation. A display that showed only the latest slot
would throw away the band; one that showed only the running table would
never show the thing that just happened.
"""
def __init__(self, console: Console, hold: float = 3600.0,
imperial: bool = False, grid: str = "", band: str = ""):
self.console = console
self.hold = hold
self.imperial = imperial
self.grid = grid
self.band = band
self.started = time.time()
self.heard = None
self.slot_at = 0.0
def update(self, heard, slot_at: float = 0.0) -> None:
self.heard = heard
if slot_at:
self.slot_at = slot_at
def showing(self, now: float | None = None) -> list:
if self.heard is None:
return []
now = time.time() if now is None else now
return [s for s in self.heard.band.all()
if now - s.last <= self.hold]
def render(self, now: float | None = None, width: int | None = None):
now = time.time() if now is None else now
parts = [self._header(now)]
latest = list(getattr(self.heard, "latest", []) or [])
if latest:
parts.append(self._slot(latest))
here = self.showing(now)
if here:
parts.append(self._table(here, now))
elif not latest:
parts.append(Panel(_one_line(
"[grey62]nothing decoded yet — the first decodes arrive "
"when the slot after next ends, up to thirty seconds away. "
"If nothing comes at all, check the clock: FT8 needs it "
"right to a second or two[/grey62]"),
border_style="grey37", padding=(0, 1)))
return Group(*parts)
def _header(self, now: float):
heard = self.heard
slots = getattr(heard, "slots", 0)
decodes = getattr(heard, "decodes", 0)
stations = len(getattr(heard, "band", None).stations) if heard else 0
elapsed = max(1e-9, now - self.started)
bits = [f"[bold]FT8[/bold] {self.band}",
f"{slots} slots",
f"{decodes} decodes",
f"{stations} stations"]
if slots:
bits.append(f"{decodes / max(1, slots):.1f} a slot")
bits.append(_dur(elapsed))
if self.grid:
bits.append(f"from {self.grid.upper()}")
return Text.from_markup(" ".join(f"[grey62]{b}[/grey62]"
if i else b
for i, b in enumerate(bits)))
def _slot(self, latest):
table = Table(box=None, header_style="bold", pad_edge=False,
title=f"last slot — {len(latest)} decoded",
title_justify="left", title_style="bold")
table.add_column("snr", justify="right")
table.add_column("dt", justify="right")
table.add_column("hz", justify="right")
table.add_column("message")
for d in latest[:24]:
table.add_row(f"{d.snr_db:.0f}", f"{d.offset:+.1f}",
f"{d.hertz:.0f}",
f"[bold]{d.text}[/bold]" if d.calling else d.text)
return table
def _table(self, here, now: float):
from .ft8 import grid_away
from .ft8 import licensee, who_text
here = sorted(here, key=lambda s: (-s.last, s.call))
band = getattr(self.heard, "band", None)
named = band is not None and getattr(band, "register", None) is not None
table = Table(box=None, header_style="bold", pad_edge=False,
title=f"heard so far — {len(here)} stations",
title_justify="left", title_style="bold")
table.add_column("station")
# Wide terminals only. What somebody watching a band needs is who
# is transmitting now; the names are all in the report afterwards.
if named and self.console.size.width >= 118:
table.add_column("licensed to", width=24, style="grey62",
no_wrap=True)
table.add_column("grid")
if self.grid:
table.add_column("away", justify="right")
table.add_column("n", justify="right")
table.add_column("best", justify="right")
table.add_column("hz", justify="right")
table.add_column("last", justify="right")
for s in here[:20]:
row = [s.call]
if named and self.console.size.width >= 118:
row.append(who_text(licensee(band, s.call)))
row.append(s.grid or "—")
if self.grid:
away = grid_away(self.grid, s.grid) if s.grid else None
if away is None:
row.append("—")
elif self.imperial:
row.append(f"{away[0] * 0.621371:.0f} mi")
else:
row.append(f"{away[0]:.0f} km")
row += [str(s.decodes), f"{s.best_snr:.0f}", f"{s.hertz:.0f}",
f"{now - s.last:.0f}s"]
table.add_row(*row)
return table

1258
bandsaunter/weather.py Normal file

File diff suppressed because it is too large Load diff

267
bandsaunter/weatherlog.py Normal file
View file

@ -0,0 +1,267 @@
"""Writing down the weather, and reading it back.
One line of JSON per message, written the moment it arrives. Flushed after
every one, for the reason every log in this program is: a listening session
ends when the operator gets bored and presses control-C, and a log that only
reached the disk on a clean shutdown would be empty exactly when it was most
wanted.
Each line holds the message in hexadecimal alongside whatever was made of
it, because the message is the evidence and the rest of the line is an
opinion about it. A future version of this program that reads a model this
one cannot will be able to go back through old logs and read them properly,
which is only possible if the bytes were kept.
There is also a way out to CSV, because weather is the one thing this
program records that people genuinely want to plot: a column per quantity, a
row per reading, the sensor's name in the second column, and nothing that
needs a program to open.
"""
from __future__ import annotations
import csv
import json
import time
from datetime import datetime
from pathlib import Path
from .acurite import Measure, Reading
__all__ = ["WeatherLog", "read_logs", "write_csv", "LOG_VERSION", "logs_in"]
LOG_VERSION = 1
class WeatherLog:
"""A JSON Lines record of every message heard, written as it arrives."""
def __init__(self, path, receiver: str = "", frequency: float = 0.0,
sample_rate: float = 0.0, started: float = 0.0):
self.path = Path(path)
self.messages = 0
self.started = started or time.time()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._file = self.path.open("a", encoding="utf8")
self._write({"log": "bandsaunter-weather", "version": LOG_VERSION,
"started": round(self.started, 3),
"started_local": datetime.fromtimestamp(
self.started).strftime("%Y-%m-%d %H:%M:%S"),
"frequency": frequency, "sample_rate": sample_rate,
"receiver": receiver})
def _write(self, body: dict) -> None:
self._file.write(json.dumps(body, separators=(",", ":"),
ensure_ascii=False) + "\n")
self._file.flush()
def append(self, reading: Reading, name: str = "") -> None:
"""Record one message: what arrived, and what was made of it."""
body: dict = {"t": round(reading.at or time.time(), 3),
"key": reading.key, "family": reading.family,
"id": reading.sensor, "model": reading.model,
"msg": reading.message, "copies": reading.copies,
"hex": _hex(reading.bits)}
if reading.snr:
# Decibels above the noise floor of the second it arrived in. A
# ratio, not a power: see Reading.decibels for why that is the
# only honest thing to record here.
body["snr"] = round(reading.snr, 1)
if reading.channel:
body["ch"] = reading.channel
if name:
# The name as it stood when the message arrived. Kept so that a
# log read back years later says where the sensor was, rather
# than where a sensor with the same identity is now.
body["name"] = name
if reading.battery_low is not None:
body["battery_low"] = bool(reading.battery_low)
if reading.measures:
body["m"] = {m.name: ([m.value, m.unit] if m.raw is None
else [m.value, m.unit, m.raw])
for m in reading.measures}
if reading.checks:
body["checks"] = list(reading.checks)
self.messages += 1
self._write(body)
def close(self) -> None:
try:
self._file.close()
except OSError:
pass
def __enter__(self) -> "WeatherLog":
return self
def __exit__(self, *exc) -> None:
self.close()
def _decimal(sensor: str) -> str:
"""An identity in decimal, which is the form other tools print."""
try:
return str(int(sensor, 16))
except ValueError:
return ""
def _hex(bits: str) -> str:
"""A message's bits as bytes, where they make whole ones."""
if not bits or len(bits) % 8:
return ""
return bytes(int(bits[i:i + 8], 2)
for i in range(0, len(bits), 8)).hex().upper()
def _bits(text: str) -> str:
try:
return "".join(format(byte, "08b") for byte in bytes.fromhex(text))
except ValueError:
return ""
# ---------------------------------------------------------------------------
# Reading it back
# ---------------------------------------------------------------------------
def logs_in(directory) -> list[Path]:
"""Every weather log in a directory, newest first."""
try:
found = list(Path(directory).expanduser().glob("weather_*.jsonl"))
except OSError:
return []
return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)
def read_logs(paths) -> list[Reading]:
"""Every reading in one or more logs, in the order they were heard.
A line that will not parse is skipped rather than fatal. A log is
appended to while the disk fills and the power goes off, so the last
line of one is quite often half a line, and losing an evening's weather
over it would be absurd.
"""
out: list[Reading] = []
for path in ([paths] if isinstance(paths, (str, Path)) else paths):
try:
text = Path(path).expanduser().read_text(encoding="utf8")
except OSError:
continue
for line in text.splitlines():
reading = _reading_from(line)
if reading is not None:
out.append(reading)
out.sort(key=lambda r: r.at)
return out
def _reading_from(line: str) -> Reading | None:
line = line.strip()
if not line:
return None
try:
body = json.loads(line)
except ValueError:
return None
if not isinstance(body, dict) or "key" not in body:
return None # the header line, or something else entirely
measures = []
for name, value in (body.get("m") or {}).items():
if not isinstance(value, list) or not value:
continue
measures.append(Measure(name=name, value=float(value[0]),
unit=str(value[1]) if len(value) > 1 else "",
raw=float(value[2]) if len(value) > 2 else None))
return Reading(model=str(body.get("model", "")),
family=str(body.get("family", "")),
sensor=str(body.get("id", "")),
channel=str(body.get("ch", "")),
battery_low=body.get("battery_low"),
message=int(body.get("msg", 0) or 0),
measures=tuple(measures),
bits=_bits(str(body.get("hex", ""))),
checks=tuple(body.get("checks") or ()),
at=float(body.get("t", 0.0) or 0.0),
copies=int(body.get("copies", 1) or 1),
snr=float(body.get("snr", 0.0) or 0.0))
# ---------------------------------------------------------------------------
# Out to a spreadsheet
# ---------------------------------------------------------------------------
def write_csv(path, readings, book=None, imperial: bool = False) -> Path:
"""A column per quantity and a row per reading.
The columns are the union of every quantity any sensor reported, so a
garden with a rain gauge in it has a rain column and the tower sensors
leave it empty. That is the shape a spreadsheet wants; the alternative,
a file per sensor, is the shape a program wants, and this is for people.
"""
path = Path(path).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
names: list[str] = []
for reading in readings:
for measure in reading.measures:
if measure.name not in names:
names.append(measure.name)
heads = ["time", "unix", "name", "key", "model", "sensor", "decimal",
"channel", "battery", "signal (dB)"] \
+ [_column(n, readings, imperial) for n in names]
with open(path, "w", encoding="utf8", newline="") as fh:
out = csv.writer(fh)
out.writerow(heads)
for reading in readings:
row = [datetime.fromtimestamp(reading.at).isoformat(
timespec="seconds") if reading.at else "",
f"{reading.at:.3f}" if reading.at else "",
book.name_for(reading.key) if book is not None else "",
reading.key, reading.model, reading.sensor,
_decimal(reading.sensor), reading.channel,
"" if reading.battery_low is None else
("low" if reading.battery_low else "ok"),
f"{reading.snr:.1f}" if reading.snr else ""]
values = {m.name: m for m in reading.measures}
for name in names:
measure = values.get(name)
# str() rather than a format: %g turns a rain counter of a
# million into 1e+06, which a spreadsheet reads as text.
row.append("" if measure is None
else str(_converted(measure, imperial)))
out.writerow(row)
return path
# The units a column is written in. Named in the heading rather than beside
# every number, because a column of "21.5 C" is text and a column of 21.5 is
# a temperature, and only one of those can be plotted.
_IMPERIAL = {"C": "F", "km/h": "mph", "mm": "in", "km": "mi"}
def _column(name: str, readings, imperial: bool) -> str:
unit = ""
for reading in readings:
for measure in reading.measures:
if measure.name == name and measure.unit:
unit = measure.unit
break
if unit:
break
if imperial:
unit = _IMPERIAL.get(unit, unit)
return f"{name} ({unit})" if unit else name
def _converted(measure: Measure, imperial: bool) -> float:
if not imperial:
return round(measure.value, 3)
if measure.unit == "C":
return round(measure.value * 9 / 5 + 32, 2)
if measure.unit == "km/h":
return round(measure.value / 1.609344, 2)
if measure.unit == "mm":
return round(measure.value / 25.4, 3)
if measure.unit == "km":
return round(measure.value / 1.609344, 2)
return round(measure.value, 3)

File diff suppressed because it is too large Load diff

View file

@ -78,7 +78,8 @@ Depends: python3 (>= 3.10), python3-numpy, python3-scipy, python3-rich,
python3-yaml, librtlsdr0
Recommends: bandsaunter-transcribe, espeak-ng
Suggests: rtl-sdr, python3-pyqt6, ffmpeg
Maintainer: bandsaunter
Maintainer: The Dust Council
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
Description: signal scanner and recorder for RTL-SDR receivers
Sweeps any set of frequency ranges, or presets from a built-in US band plan,

View file

@ -77,7 +77,8 @@ Priority: optional
Architecture: ${arch}
Depends: bandsaunter (= ${version}-${revision}), python3-numpy, python3-yaml
Recommends: bandsaunter-model-$(echo "$model" | tr '._' '--')
Maintainer: bandsaunter
Maintainer: The Dust Council
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
Installed-Size: $(du -ks "$pkg" | cut -f1)
Description: speech recogniser for bandsaunter
Transcribes recorded voice transmissions to text.
@ -131,7 +132,8 @@ Section: hamradio
Priority: optional
Architecture: all
Depends: bandsaunter-transcribe
Maintainer: bandsaunter
Maintainer: The Dust Council
Homepage: https://frostwarning.com/git/dustcouncil/bandsaunter
Installed-Size: $(du -ks "$modelpkg" | cut -f1)
Description: $model speech model for bandsaunter
The $model recognition model, installed locally so that transcription works

View file

@ -133,6 +133,12 @@ on it again. The frequency is written into your saved settings, the same list
maintains, and takes effect on the next scan \[em] a scan already running read
its settings when it started.
.TP
.B c
Open the register: every callsign and every aircraft this installation has
ever looked up, read back out of the two caches. See
.B THE REGISTER
below.
.TP
.B "? h"
The list of keys, and which audio player was found.
.TP
@ -270,6 +276,61 @@ is written into the map: holding it and not saying so would be worse than
either showing it or not asking for it.
.B \-\-no\-lookup
asks for none of it.
.SH THE REGISTER
.B c
opens it. Everything this installation has ever found out about a callsign or
an aircraft, read back out of the two caches the lookups write: the scanner's
identification, the Morse idents, APRS, FT8 and this browser all resolve
callsigns into one file, and the aircraft side keeps its own. Between them
they are a record of who and what has been heard from this aerial, and until
now the only way to read either was to open the JSON.
.PP
The list shows the callsign or registration, who or what it is, where, when it
was looked up, and whether there is anywhere to point a map at. Under it,
everything known about whichever one is highlighted, in two columns \[em] a
licence has a dozen fields and a screen is wider than it is tall.
.TP
.B "\[ua] \[da]"
Move. Page Up and Page Down go a screen at a time, Home and End to the ends.
.TP
.B tab
Switch between callsigns and aircraft.
.TP
.B /
Search. Every field rather than the name, because the interesting question is
usually not "which callsign" but "who was in Arizona" or "what Bombardiers
have gone over", and both of those live in the detail. Every word has to
match, so a search can be narrowed.
.TP
.B g
Open where this one is, in whatever browser the desktop uses. Only the
coordinates go into the link: a map does not need to be told whose licence it
is looking at in order to show a place, and the link is the one part of this
that leaves the machine. Where nothing is known about the position it says so
rather than guessing, and on a machine with no browser \[em] which is the
normal case for a receiver \[em] it prints the coordinates instead.
.TP
.B r
Read the caches again, for somebody who has just finished a scan in another
window.
.TP
.B "c esc"
Back to the recordings.
.B q
still quits.
.PP
Callsigns that no register could place are kept rather than dropped. "Asked
about, and in no register reachable from here" is a fact about a station, and a
list that quietly dropped them would make an evening of unlisted foreign
stations look like an evening when nothing was looked up. An aircraft is put
on the map at the airport its flight started from, labelled as such: this is a
register of airframes rather than a position report, and the origin is the
nearest true thing.
.PP
The addresses are licence records, public by law and already printed by
.BR bandsaunter (1)
in its own reports; this shows them the same way rather than more
prominently.
.SH THE MAP
A licence says where its holder is, so a list of callsigns is also a map. The
scanner writes one as it runs and
@ -410,6 +471,30 @@ during the scan, and the
.I _transcription.txt
beside the recording is what a later re\-run wrote. The file wins, being the
more recent of the two.
.SH THE TITLE SCREEN
Drawn on startup in braille, two dots wide and four tall to a character, which
is eight times the detail a block gives and is what makes room for capitals and
lowercase at the same height \[em] a five-row block font has no second height
to spend, so the name comes out shouted. Coloured in a gradient from deep blue
through cyan to a cool white across the width \[em] deliberately not the
waterfall ramp the rest of the program draws in, which carries on through green
and yellow to red because it stands in for a spectrum and has to mean
something. Under it, who conceived the program and what wrote it.
.PP
The font is twelve letters cut by hand at cap height seven and then doubled,
because a stroke two dots thick reads as a line where one dot thick reads as a
dotted line.
.PP
It never appears where it would be in the way. Everything here can be piped
into something else, and a banner in the middle of that is corruption rather
than decoration, so anything that is not a terminal gets nothing at all. It is
drawn after the arguments are parsed, so
.B \-\-help
and a bad argument say their piece without one over the top.
.B \-\-no\-splash
turns it off on a terminal too, and
.B BANDSAUNTER_NO_SPLASH=1
turns it off for good.
.SH FILES
.TP
.I ~/bandsaunter/
@ -493,6 +578,8 @@ or
.UR https://www.gnu.org/licenses/
.UE .
There is no warranty, to the extent permitted by law.
.SH HOMEPAGE
.I https://frostwarning.com/git/dustcouncil/bandsaunter
.SH BUGS
The list is read when the browser opens. Press
.B r

View file

@ -57,15 +57,43 @@ def settings_section() -> list[str]:
def aircraft_section() -> list[str]:
"""Every ADS-B option, from the same table the menu and flags come from.
Written out rather than described in prose, so that an option added to
the program cannot quietly fail to appear in its manual.
"""
"""Every ADS-B option, from the same table the menu and flags come from."""
from bandsaunter import aircraft as air
return options_section(air)
def weather_section() -> list[str]:
"""Every weather option, from the same table."""
from bandsaunter import weather as wx
return options_section(wx)
def aprs_section() -> list[str]:
"""Every APRS option, from the same table again."""
from bandsaunter import aprs as ap
return options_section(ap)
def ft8_section() -> list[str]:
"""Every FT8 option, from the same table again."""
from bandsaunter import ft8
return options_section(ft8)
def options_section(air) -> list[str]:
"""One section's options, written out from the table the program uses.
Written out rather than described in prose, so that an option added to
the program cannot quietly fail to appear in its manual. Both sections
describe their options in the same shape, so this does not need to know
which one it has been handed.
"""
out = []
defaults = air.AircraftOptions()
defaults = air.defaults()
for group in air.OPTION_GROUPS:
out.append(f'.SS {esc(group)}')
for o in air.in_group(group):
@ -180,6 +208,31 @@ animation. See
.B AIRCRAFT
below.
.TP
.B weather
Listen to the AcuRite weather sensors on 433.92 MHz, and name them as they
arrive. See
.B WEATHER SENSORS
below.
.TP
.B readings
Read a weather log back: the report, and a spreadsheet. See
.B WEATHER SENSORS
below.
.TP
.B sensors
List every weather sensor heard, and give them names.
.TP
.B aprs
Listen to the APRS channel on 144 MHz: positions, weather, messages, objects
and telemetry from amateur stations. See
.B APRS
below.
.TP
.B packets
Read an APRS log back: the report, a spreadsheet and a map. See
.B APRS
below.
.TP
.B analyze
Identify a signal in an already-recorded file, decode Morse from it, or write
out the picture it turns out to be.
@ -971,6 +1024,17 @@ and never fetched twice, every request identifies this program in its
User-Agent, and the attribution the tiles require is written onto the picture
\[em] a GIF travels without the readme that would otherwise carry it.
.PP
The finished map is cached as well, in
.IR ~/.cache/bandsaunter/ground .
The tiles always were, so a second evening on the same view has never touched
the network, but it still cost decoding forty PNGs and resampling a megapixel
and a half into this program's own projection every time a window opened, for
an answer that cannot have changed. On a hundred-and-fifty-mile view that is
0.61 seconds become 0.01. Both windows and both kinds of still picture share
it. A map with squares missing is not kept, since caching a hole would keep it
for a month. The whole cache is pruned to four hundred megabytes whenever a
map is written, least recently used first.
.PP
The zoom is chosen from how wide the picture is, not from the area alone, so a
map asked for at 1920 pixels fetches finer tiles than the same map asked for
at 960. Half again over the width is fetched deliberately and averaged down,
@ -984,6 +1048,48 @@ the window wants. A drawing is capped at a couple of hundred tiles, which at
3840 by 2160 is reached: there the zoom has stopped climbing and the map is
enlarged after all, and a smaller radius buys the detail back.
.PP
The window opens maximised \[em] this is a map, and the thing anybody wants
more of is map. Un-maximising gives back a usable window, the restored size
being set before it maximises rather than left to the toolkit to guess.
.B f
goes to true full screen and back to maximised, and is written along the top of
the screen because a window with no frame is one somebody has to know a key to
get out of. Maximised rather than full screen by default, because the title bar
is where the band and the frequency are written.
.PP
A window made bigger fetches a sharper map. The map underneath is fetched for
the size of the window at the time, and a map of the right piece of world goes
on being one however far it is then stretched, so nothing else notices. A
window opened at its default size and taken to the whole screen used to keep
the map it started with until an aircraft wandered far enough to move the view
out of the fetched box. It now compares map pixels per degree in hand against
what the view wants and asks for a better one when it is being blown up by more
than fifteen per cent \[em] per degree rather than in raw pixels, because the
fetched box is a quarter wider than the view, so a map with as many pixels as
the window is wide has only four fifths of them on the screen. The request is
keyed on the window's size, so once answered nothing more is asked, which is
what stops a window larger than the tile budget can cover from asking all
evening.
.PP
Resizing the window is the demanding case: a wider picture picks a sharper
zoom and a hundred tiles that have never been on this disk are asked for at
once, whereupon a busy server refuses some of them. A tile that does not
arrive leaves its square of the canvas black, and black is not neutral here
\[em] the brightness is inverted on the way in, so the darkest possible square
came out as the brightest thing on the picture, dragged the floor of the map's
own contrast down with it, and stayed there for the life of the view. Instead
the missing squares are asked for again at once, and only those, the rest
being on the disk by then; whatever is still missing is drawn as bare ground
and left out of the reckoning when the darkest and brightest of the map are
worked out; and the map is kept as provisional rather than as the last word,
asked for again half a minute later, four attempts in all, each retrying its
own misses once, so a square gets eight chances before one that will not come
is accepted as one that is not there.
.PP
The politeness pause between requests is paid only on a tile that had to be
fetched. Paid on every tile, as it had been, it put twenty-six seconds of
sleeping into redrawing a view whose tiles were all in hand.
.PP
.B \-\-no\-basemap
draws the tracks on their own,
.BI \-\-tiles " URL"
@ -1102,6 +1208,37 @@ Every option the ADS-B side takes, in the six groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree.
.AIRCRAFT_OPTIONS_HERE
.SS Pulsing and echoes
.B \-\-pulse
swells each aircraft 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 grown out of its own
shape, which is what a phosphor does when the beam sits in one place a little
too long. The halo is the aeroplane itself spread outward a pixel at a time
rather than a circle drawn round it, so the glow has the shape of the thing
casting it, and it goes only where the picture was still empty.
.PP
.B \-\-echo
sends a ring travelling outward from each aircraft, growing and dimming as it
goes \[em] 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. One ring at a time per aircraft.
.PP
.BI \-\-pulse\-rate " SECONDS" ,
.BI \-\-echo\-every " SECONDS"
and
.BI \-\-echo\-size " PIXELS"
set the rest. The two times are seconds of watching rather than of flying, so
a pulse looks the same whatever speed an evening is being run through. Each
aircraft is offset by its own address, so a sky full of them swells and rings
separately rather than beating as one, and an aeroplane keeps its own rhythm
from one drawing of the same log to the next. Only an aircraft still being
heard pulses: one that has gone quiet is fading, and a thing that is fading
and beating at once says two contradictory things about itself.
.PP
The window can blend and its swell is continuous. The animation cannot, a GIF
being indexed colour, so there the swell is the handful of steps a palette
allows \[em] which family of colours the aeroplane is drawn from, and how far
its halo reaches.
.SS Range rings
.B \-\-rings
puts faint discs at a quarter, a half and three quarters of the radius,
@ -1215,6 +1352,603 @@ sideband by which way the signal's energy leans, so
.B \-\-mode usb
is not needed. The frequency in the filename is the carrier \[em] the
frequency to dial into a radio.
.SH WEATHER SENSORS
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, on 433.92 MHz, to anyone who happens
to be listening.
.B bandsaunter weather
reads the box.
.PP
It is a mode 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 \[em] it would record the bursts as clicks in a WAV file and
decode nothing.
.SS What it reads
Five families, each with its own framing and its own check.
.TP
.B "Tower 592TXR / 06002RM"
Seven bytes: temperature and humidity.
.TP
.B "5-in-1 06014RM / VN1TXC"
Eight bytes, in two kinds sent alternately: wind speed with wind direction and
rainfall, or wind speed with temperature and humidity. It has more to say than
fits in one message, so the display keeps the newest value of each quantity
rather than the newest message.
.TP
.B "Lightning 6045M"
Nine bytes: temperature, humidity, the cumulative strike count, and how far
off the storm is. A bit set when the detector believes it is being interfered
with is shown too, because a strike count that climbs while it is set is not
lightning.
.TP
.B 609TXC
Five bytes: temperature and humidity.
.TP
.B 606TX
Four bytes: temperature, and nothing else at all.
.PP
Battery state comes from all of them. The Atlas, the 986 and 515 fridge
thermometers, the 00275rm room monitor and the 899 standalone rain gauge are
on the same band and are not decoded; a message from one whose framing happens
to match is reported as an unknown message type with its identity and nothing
else, rather than guessed at.
.PP
These formats are implemented from their published descriptions and are
checked against frames built from the same descriptions, which proves the
framing, the parity, the checksums and the arithmetic and proves nothing about
anything a description and an implementation of it both get wrong. The tower
sensor is additionally checked against messages recovered from real hardware,
byte for byte.
.SS Naming a sensor
A sensor broadcasts an identity, and that identity is a number that came out
of a hat in a factory \[em] or a different number out of the same hat the next
time the batteries were changed. It tells one sensor from another and is no
use at all for telling which is which.
.PP
It is shown in both bases. These identities are bit fields with the channel
packed above them, so this prints them in hexadecimal where that shape shows,
while rtl_433 and everything built on it prints them in decimal: 3935 and
14645 are the same sensor. Anyone arriving with a list of their own already
has it in decimal, so
.B bandsaunter sensors
puts the two side by side and either may be typed at
.BR \-\-name .
An identity that is a valid number in both bases is reported as ambiguous
rather than resolved by guesswork.
.PP
So press
.B n
while listening. The display comes down, the sensors are listed with numbers,
you pick one and type a name, and it goes back up. The receiver keeps running
throughout: a slow typist loses a few seconds of weather and nothing else.
That is the moment it is possible to do \[em] 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.
.PP
Names can also be given with
.BI \-\-name " ID=NAME"
on
.B "bandsaunter weather"
or
.BR "bandsaunter sensors" ,
before or after anything has been heard: a name given before the sensor has
ever been received waits under its identity alone, because nothing yet knows
which model it is, and moves across the moment the first message arrives. They
live in
.I sensors.yaml
beside the settings, are written the moment they are given rather than when
the program exits, and are written to a neighbouring file which is renamed
over the old one, so a machine losing power halfway through leaves either the
old names or the new ones and never half of each. Nothing is ever dropped for
being stale.
.SS Why nothing false gets through
433 MHz is a crowded band \[em] doorbells, car keys, tyre-pressure sensors,
garage doors \[em] and a decoder that looks at every bit offset of every burst
will find a message in noise if it is allowed to. Four things stop it.
.PP
The three newer models carry an eight-bit sum plus even parity in the top bit
of every payload byte, which is twelve to fourteen bits of check. Even and not
odd: that one bit of convention was wrong here and the cost was that nothing
decoded at all, every other check in the format passing and saying so.
.PP
The two older models carry one byte of check between them, which is one false
message in two hundred and fifty-six, so those two are only believed when the
same message arrives twice. It costs nothing: these sensors send everything
three times in a row, for exactly this reason.
.PP
Nothing outside what the hardware can report is accepted \[em] no temperature
beyond \-40 to 70 \[de]C, no humidity above 100 per cent, no wind the
anemometer cannot physically produce.
.PP
And a message must sit where a message sits. 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. Corroboration does not help either,
the copies of a message being identical. What gives that window away every
time is that it ends a whole byte before the burst does.
.SS Getting it off the air
The receiver is tuned straight at 433.92 MHz, at 250 kS/s, with the tuner's own
gain control doing its job and the RTL2832's digital AGC left off, which is
what the established tools for this band do.
.PP
The digital AGC is off because it pumps: it winds the gain up through the
silence between one burst and the next, lifting the noise towards the signal
and squeezing the difference this depends on. It matters here in a way it does
not for aircraft, where a frame is found by correlating a preamble over a few
microseconds rather than by comparing a burst with the quiet around it.
.PP
.B \-\-offset
tunes to one side of the sensors and shifts them back in software, which
avoids the spike every RTL-SDR puts at whatever it is tuned to. It is off by
default: the spike is a steady addition to the envelope and the burst rises
clear of it, while a filter narrow enough to reject the spike is narrow enough
to lose a transmitter that has drifted. At the default sample rate it does
nothing whatever it is set to, there being nothing after the mixer narrower
than the band.
.PP
Finding the bursts is done in two passes, and the reason is having more than
one sensor. The first pass asks only where anything is happening at all, and
asks it against the noise: the bottom fifth of a second, which is noise
however busy the rest was. Whatever clears that is grouped into regions, and
the second pass re-thresholds each region against its own high and low, so
every sensor is sliced at its own amplitude. One threshold per second, set
halfway between the noise and the loudest thing in it, is the obvious way to
write this and is wrong: a sensor on the windowsill and a sensor at the end of
the garden differ by forty decibels, so the far ones fall below it and vanish,
and vanish only while the near one is transmitting.
.PP
Slicing the envelope into bits never measures anything against a clock, and
assumes as little as it can about how a bit is drawn. Not which of the pulse
and the gap carries the bit; not whether the gap is the complement of the
pulse or a fixed spacer, since a 220 microsecond pulse against a 200
microsecond spacer is the longer of the two and reads as the wrong bit; and
not which of long and short means one; and not which end of a byte goes down
the air first. The same burst is read half a dozen ways and the checksums say
which reading it was, at most one of them being able to satisfy one. There is
a fingerprint for the last of those: reversing the bits of a byte does not
change how many are set, so parity survives it and a sum does not, and a
message read from the wrong end shows every parity holding and every checksum
failing. A transmitter running ten per cent fast is therefore read
correctly and never noticed, which matters: these are unlocked and drift with
the temperature, and an outdoor sensor in January is not the one that was on
the fence in July.
.SS How well each sensor is heard
Three columns say so, and they answer different halves of the question.
.TP
.B signal
How far the sensor's burst stood above the noise, in decibels, coloured red
below 14, amber below 22 and green above. A ratio of two amplitudes off the
same receiver in the same second and nothing more \[em] not a power at the
aerial, which an RTL-SDR cannot give, having no reference level and, on
automatic gain, no fixed gain either. What a ratio is good for is comparing
one sensor with another, watching one over an evening, and pointing an aerial.
Use a fixed
.B \-\-gain
if the figures are to be compared between one run and the next. It is on the
live display as well, and kept there on a narrow terminal, because watching a
number climb while moving a whip about is the most useful thing it does.
.TP
.B every
The average wait between messages.
.TP
.B heard
What share of what the sensor sent is arriving. 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 share getting
through; one over the other is the share, without needing to know the model or
how often it is supposed to speak.
.PP
The two are worth reading together and can disagree usefully. A strong signal
with a low share is interference or a collision rather than a range problem; a
weak signal at a hundred per cent is a sensor at the edge that is getting
through anyway.
.SS Afterwards
When the listening stops, two tables. The first is about reception and is the
one to look at when something is missing. The second is the first, last,
lowest and highest of everything each sensor reported.
.PP
There is no average, deliberately. These arrive every sixteen seconds when the
sensor is in range and not at all when it is not, and rain and cold both
shorten the range of a 433 MHz transmitter, so the mean of what was received
is the mean of a sample whose gaps are themselves the weather. A bearing gets
no lowest or highest either: north is 0 and also 360.
.PP
.B \-\-csv
writes a column per quantity and a row per reading, with the sensor's name in
the second column and the unit in the heading rather than beside every number.
.B "bandsaunter readings \-\-csv"
does the same to an old log, and takes
.BI \-\-sensor " NAME"
to narrow it to one sensor.
.PP
The log keeps the raw bytes of every message underneath whatever was made of
them, because the message is the evidence and the rest of the line is an
opinion about it. Readings are converted once, on the way in, to Celsius,
kilometres an hour, millimetres and kilometres \[em] different models report
in different units \[em] so
.B \-\-units imperial
changes only what is shown, and can be changed afterwards on an old log.
.SS Without a sensor
.B \-\-simulate
puts six sensors on a fence that does not exist, one of every model,
transmitting real messages with real checksums, keyed on and off as a real one
does, through the real filter, the real slicer and the real decoders. Nothing
touches the receiver.
.SS If nothing is heard
.B "bandsaunter weather \-\-diagnose"
is the answer to this, because "nothing was heard" is four different faults
wearing the same coat and they want four different answers. It prints each
second of band taken apart stage by stage: the noise level, the level a burst
has to clear, the loudest thing in the block, and then every burst found with
the lengths of its pulses and gaps and whatever was made of them.
.TP
.B "peak barely above noise, no bursts"
Nothing is arriving, which is an aerial. These are a few milliwatts; a
quarter-wave whip for 433.92 MHz is 17 cm of wire, which is the stock
telescopic aerial collapsed to about that, and indoors behind a wall with the
dongle in the back of a machine is usually the problem. Try
.B \-\-gain 40
if the automatic gain control is not finding them.
.TP
.B "peak well above noise, no bursts"
Something is there and did not group into a burst, usually a transmitter that
is on continuously rather than keyed. Not one of these.
.TP
.B "bursts whose pulse lengths are not two or three clean groups"
The receiver is hearing it and the slicing is wrong. A real message shows two
or three lengths with nothing in between; a smear means noise is being sliced
as signal, or two sensors are transmitting over each other.
.TP
.B "clean pulse lengths, nothing framed"
The radio is fine and the message is from a model this does not read. Under it
comes the closest thing to a message that was found, which of its checks held,
and the bytes themselves in hexadecimal. Which check fails says what kind of
fault it is: a sum that holds while a parity does not is a different thing
from neither holding. That line and the pulse lengths above it are between
them everything needed to add a format.
.TP
.B "framed, but needs the same message twice"
It was read correctly and arrived once. The two older models are believed only
on a second copy, so this wants a stronger signal.
.PP
When the listening stops it says which of those five it was, once, rather than
on every quiet second.
.PP
.BI \-\-save\-iq " FILE"
writes the raw samples alongside, for anything the diagnosis cannot settle. It
is 2 MB a second at the default rate, so bound it with
.BR \-\-seconds ;
sixty seconds is plenty, every sensor reporting at least twice in that. The
settings it was taken at are written beside it, a file of raw samples with no
record of its sample rate being unreadable by anything.
.PP
.BI \-\-from\-iq " FILE"
reads one back instead of the receiver, so a recording made where the aerial is
can be worked on anywhere. Everything downstream of the dongle is the real
thing, which is what tells a receiver problem and a decoder problem apart: a
capture that yields nothing on replay yields nothing for anybody, and one that
yields readings on replay and not on the air is a setting.
.SH WEATHER OPTIONS
Every option the weather side takes, in the four groups the menu shows them
in. Each is a flag here and a line in the menu, and both come from one table
in the program, so they cannot disagree.
.WEATHER_OPTIONS_HERE
.SH APRS
One channel, one frequency, everybody: 144.390 MHz across North America and a
different number in every other region, carrying position reports, weather,
messages, objects and telemetry from every amateur station within earshot, and
from every hilltop digipeater repeating them onward \[em] which is most of what
will actually be heard.
.PP
A mode of its own for the same reason the other two are: a scan stops on a
signal, records it and moves on, and this is a two-second transmission every
few minutes from a hundred stations sharing one frequency. A sweep catches
whichever one happened to key up while it was pointed there.
.PP
Unlike the others it is a conversation rather than a broadcast, so what is
shown is not only who is out there but what was said. And nothing here needs
naming: a station broadcasts a callsign issued by a government, which is
already the name.
.SS Which channel
The frequency is agreed between amateurs rather than allocated, so it differs
by region and there is no way to discover it from the air: on the wrong one
there is silence, not a bad signal. That is the one fault that looks exactly
like a dead aerial and is not, so it is the first thing to settle.
.B \-\-region
covers north-america (144.390), europe (144.800), australia (145.175), japan,
brazil and thailand, and
.B \-\-frequency
takes a number for anything else.
.PP
.BI \-\-find\-channel " [SECONDS]"
listens on each region's channel in turn \[em] twenty seconds each unless told
otherwise \[em] and prints what was on each, then says which to use. It
answers the one question about APRS that cannot be answered on any single
frequency, because the answer is a frequency. A quiet channel is not proof of
an empty one, a fixed station beaconing every half hour, so what it finds is
traffic and what it misses is only the absence of traffic while it listened;
it says as much when every channel comes back silent.
.PP
In the menus the channel is on the front page rather than a level down among
the gain and the sample rate, being the setting that decides whether anything
is heard at all.
.B c
lists the regions with their frequencies and also takes a number in megahertz
for a channel that is no region's;
.B f
runs the search and offers to adopt whichever channel had the most on it.
.SS What it reads
Positions, uncompressed and compressed into thirteen characters of base-91;
Mic-E, which every Kenwood and Yaesu mobile sends; weather, attached to a
position or without one; messages, acknowledgements, rejections and bulletins;
objects and items; status reports; telemetry; and third-party traffic relayed
in from another network, credited to whoever originally sent it. Riding in the
comment: course and speed, altitude, transmitter power and antenna height,
pre-computed range, direction-finding reports and the precision extension.
.PP
Mic-E deserves a note, being a quarter of everything on the channel and the
least readable thing in amateur radio. In 1995 the destination address of an
APRS frame carried nothing but the word "APRS", and somebody noticed that six
bytes is exactly enough for a latitude \[em] so a Mic-E packet puts the
latitude, the north/south bit, the east/west bit, a hundred degrees of
longitude and a three-bit status message into the callsign it is addressed to.
It is also why APRS fits in a two-second transmission.
.SS Refusing to guess
A packet whose format does not match what its first character promised comes
back as unparsed with its text kept, rather than as a position. Thirteen
characters of a malformed uncompressed position are perfectly good base-91, so
a decoder that tries one format and falls back to the other does not fail on a
bad packet \[em] it succeeds, as a confident and completely different place,
usually a thousand miles away. The specification makes the two unambiguous, a
leading digit always meaning uncompressed, so the rule is read rather than
guessed at.
.SS Getting it off the air
APRS is Bell 202: 1200 Hz for a mark and 2200 Hz for a space, twelve hundred a
second, inside an ordinary FM transmission. Two correlators, one at each tone,
and the difference between them \[em] a correlator rather than a frequency
discriminator, the tones being less than an octave apart and radio audio
distorted enough that instantaneous frequency wanders.
.PP
De-emphasis is turned off, which matters and is easy to miss: voice FM lifts
the high frequencies on transmit and drops them again on receive, and packet
radio takes its audio from the discriminator before that happens. Dropping the
highs would take four decibels off the 2200 Hz tone and leave the 1200 Hz one
alone, which is exactly the difference being measured.
.PP
The soft symbol is sampled once a bit, at an instant held in the middle of the
bit by a loop nudged at every zero crossing, and that loop carries its phase
from one block of audio to the next \[em] a packet is most of a second and a
block is about one, so frames straddling the boundary are most of them. Then
NRZI, where a zero is a change of tone and a one is no change, which makes the
whole thing immune to being wired up backwards; then HDLC framing with its bit
stuffing; then sixteen bits of CRC, and nothing without a correct one is
reported. That last is what makes it safe to leave running for hours with the
squelch open.
.SS A window, while it happens
.B \-\-window
opens the same map the aircraft use, with stations on it instead of
aeroplanes: a real map underneath, an information box beside each station, a
leader line to the mark it belongs to, range rings around the aerial and a
flag where it stands.
.PP
The one difference from the aircraft map is that nothing fades. An aeroplane
that stops transmitting has flown out of range, and drawing it an hour later
where it was would be drawing something that is certainly not there; a fixed
amateur station that stops transmitting is still exactly where it was,
beaconing every half hour, so the gaps are silence rather than absence. The
picture accumulates instead, and an evening of listening fills a map.
.PP
Marks are drawn by what they are \[em] something moving as a body with a
stalk pointing where it is going, and anything fixed as a diamond, which is
the one shape on the picture with no front \[em] and coloured off the same
altitude ramp the aircraft use, that being the one set of colours every theme
defines, so a digipeater stays distinguishable from a car on all five.
.PP
The box says what the station is, where it is in figures, how far off and in
which bearing, what it is doing if it is moving, its altitude, its weather, its
status, the digipeaters it came through, how many packets and how many arrived
directly, and how strongly. The mark shows where a station is and the figures
are what gets written down \[em] and where a station blanked its minutes, the
figures are the only place that shows. Boxes are placed where they cover nothing else and glide when their
station moves; where there is no room for one the mark is still drawn, and the
most recently heard get the boxes.
.PP
The same keys as the aircraft map:
.B d
for how much each box says,
.B t
trails,
.B g
the map underneath,
.B [
and
.B ]
its brightness,
.B +
and
.B \-
the range,
.B f
true full screen, and
.B q
to quit.
.PP
The window measures in whatever unit is being shown.
.B \-\-units " imperial"
puts statute miles round the rings, along the scale at the bottom and on
.B \-\-radius
itself;
.B \-\-units " metric"
puts kilometres on all three. The rings are drawn at the distance they are
labelled \[em] the outermost at
.B \-\-radius " 100"
in imperial stands seventy-five statute miles from the flag, measured on the
ground, rather than seventy-five kilometres with miles written beside it. A
ring is what a distance gets judged against by eye, so one labelled in a unit
it was not drawn in is a wrong answer given confidently.
.PP
.BI \-\-at " LAT,LON"
puts the red flag on the map, centres the range rings and gives every station a
distance and a bearing. Left unset, whatever the aircraft side was told is used
instead \[em] one aerial on one roof does not move because the receiver was
pointed at a different band \[em] and which was used is said out loud, an
inherited position being a convenience right up until somebody has moved and
changed only one of them.
.SS Afterwards
Three tables. Stations heard is about the band and the aerial: where each was,
how far off, how many packets, how many of those arrived directly rather than
through a digipeater, and how strongly. What they said is the weather, the
speeds and the status lines. What passed between them is the messages, in
order, which is the only part of APRS that is a conversation.
.PP
.BI \-\-at " LAT,LON"
turns on the distance and bearing columns.
.B \-\-direct\-only
leaves out anything relayed, which is a much shorter list and the honest
measure of what an aerial can reach.
.B \-\-csv
writes a row per packet and
.B \-\-kml
a pin per station with a line for anything that moved; both can be made later
from a log with
.BR "bandsaunter packets" ,
which also takes
.BI \-\-station " CALL"
to narrow either to one callsign.
.PP
The log keeps the whole AX.25 frame in hexadecimal under whatever was made of
it, because the list of APRS formats is still growing and a packet this
version cannot read should be on the disk in full for a version that can.
.SS If nothing is heard
Check the region first: it is the one fault that looks like a dead aerial and
is not. Then give it time \[em] a fixed station beacons every twenty or thirty
minutes and a mobile every minute or two, so five minutes of an ordinary
suburb might be three packets. A quarter-wave whip for 144 MHz is 49 cm, which
is longer than the aerial most dongles ship with.
.B \-\-packets
shows each frame as it arrives, which is what to watch while moving an aerial
about.
.SS Who each station is
An APRS callsign is issued by a government, so unlike a weather sensor it can
be looked up: the name on the licence, the town, and the licensed position,
which is a street address where a beacon only gives a grid square. The SSID
comes off first \[em] W1AW\-9 is the ninth radio W1AW runs, and no register
has heard of it \[em] and objects are not looked up at all, an object being a
marker placed on behalf of something with no licence of its own.
.PP
Nothing waits: the lookup runs on its own thread and the name appears in a
later frame, because a table that stopped for a network request would stop for
every new station on a busy channel. A station the register cannot know still
says where it is from, the country coming out of the callsign's own structure
with no network at all.
.PP
Answers are kept in
.I ~/.cache/bandsaunter/callsigns.json
for a month, and that file is shared with every other part of this program
that resolves a callsign, so the same net logged night after night is asked
about once.
.B \-\-no\-lookup
turns the network off and leaves the country and district, which cost nothing.
.SH APRS OPTIONS
Every option the APRS side takes, in the four groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree.
.APRS_OPTIONS_HERE
.SH FT8
Fifteen seconds of everybody at once. Every station on the band transmits in
the same quarter-minute slots, on the same dial frequency, fifty hertz wide
each, stacked across three kilohertz of audio \[em] so one receiver parked on
one frequency hears the whole band's worth of stations at the same time, and
hears most of them well below the noise.
.PP
Half of what is transmitted is error-correcting code, and that is the trick:
it is what buys a mode that decodes twenty-odd decibels under what an operator
can hear. A receiver that took the loudest tone of each symbol and hoped would
decode almost nothing, which is why the tone detector reports how confident it
is bit by bit rather than what it thinks it heard.
.SS What it needs
The clock has to be right to a second or two. The slots are quarter-minutes of
UTC and every station on earth agrees about which one it is. A receiver a
second out still decodes; one a slot out hears every transmission split across
two captures and decodes none of them. This is the one failure that looks
exactly like a dead band, so the display and the report both say so when
nothing arrives.
.PP
Almost all the activity is on shortwave, which a plain receiver of this kind
cannot reach. The default is therefore the two-metre channel at 144.174 MHz,
which it can. All thirteen channels are in the list and the shortwave ones
work through an upconverter or a receiver in direct sampling mode; the menu
says which is which rather than leaving somebody to find out by listening to
silence.
.SS What comes out
.BI \-\-grid " SQUARE"
is what turns decodes into geography: every station calling CQ says where it
is, so with your own square filled in each gets a distance and a bearing and
the furthest heard is named. Three tables afterwards \[em] stations heard,
calling CQ, and who was working whom.
.PP
.B \-\-adif
writes the log again in the form every amateur logging program imports,
marked as heard rather than worked. Nothing here transmits, so nothing here is
a contact, and an ADIF that let a logging program treat these as worked would
put claims into somebody's log that they cannot make.
.SS Who each station is
Every FT8 exchange is two callsigns and a callsign is issued by a government,
so both are looked up \[em] the station being answered may never transmit
within earshot and is still one this receiver knows about. The licensed
callsign is found inside the heard one: ET3RFG/R is ET3RFG operating away from
home, DL/G4ABC is G4ABC as a guest, and a hashed <...> is not asked about
because there is nothing to ask. Those rules are shared with APRS rather than
written twice, getting them wrong being silent: a register asked about W1AW\-9
returns nothing, which looks exactly like a station that is not licensed.
.PP
Nothing waits on the network \[em] a slot has to be decoded in well under
fifteen seconds or the next one is missed \[em] and answers are kept in
.I ~/.cache/bandsaunter/callsigns.json
for a month, in the same file every other part of this program uses. The
databases are United States registers, so the DX that makes this mode worth
listening to comes back unlisted, and the country beside it comes out of the
callsign's own structure with no network at all.
.SS How well it works
Checked against eleven off-air recordings with published decodes, which is the
only honest way to test a decoder: an encoder tested against its own decoder
agrees with it about anything they are both wrong about. Ninety-seven of a
hundred and fifty messages, with no false decodes; timing within a hundredth
of a second, frequency within a hertz, signal reports within half a decibel on
average. The third not decoded are the weakest in each slot: a mature decoder
subtracts what it has decoded and looks again in the remainder, which is not
built here.
.SH FT8 OPTIONS
Every option the FT8 side takes, in the five groups the menu shows them in.
Each is a flag here and a line in the menu, and both come from one table in
the program, so they cannot disagree.
.FT8_OPTIONS_HERE
.SH THE TITLE SCREEN
Drawn on startup in braille, two dots wide and four tall to a character, which
is eight times the detail a block gives and is what makes room for capitals and
lowercase at the same height \[em] a five-row block font has no second height
to spend, so the name comes out shouted. Coloured in a gradient from deep blue
through cyan to a cool white across the width \[em] deliberately not the
waterfall ramp the rest of the program draws in, which carries on through green
and yellow to red because it stands in for a spectrum and has to mean
something. Under it, who conceived the program and what wrote it.
.PP
The font is twelve letters cut by hand at cap height seven and then doubled,
because a stroke two dots thick reads as a line where one dot thick reads as a
dotted line.
.PP
It never appears where it would be in the way. Everything here can be piped
into something else, and a banner in the middle of that is corruption rather
than decoration, so anything that is not a terminal gets nothing at all. It is
drawn after the arguments are parsed, so
.B \-\-help
and a bad argument say their piece without one over the top.
.B \-\-no\-splash
turns it off on a terminal too, and
.B BANDSAUNTER_NO_SPLASH=1
turns it off for good.
.SH FILES
.TP
.I ~/.config/bandsaunter/config.yaml
@ -1223,6 +1957,16 @@ The settings every run starts from.
.I ~/.config/bandsaunter/aircraft.yaml
The aircraft options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/weather.yaml
The weather options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/sensors.yaml
What each weather sensor is called. The only file here holding anything a
person typed; safe to edit by hand.
.TP
.I ~/.config/bandsaunter/aprs.yaml
The APRS options, as saved from the menus.
.TP
.I ~/.config/bandsaunter/*.yaml
Named profiles.
.TP
@ -1231,6 +1975,18 @@ Every ADS-B frame heard in one listening session, with a
.I .txt
report and any picture drawn from it beside it.
.TP
.IR weather_ * .jsonl
Every weather sensor message heard in one listening session, with a
.I .csv
of the readings beside it where one was asked for.
.TP
.IR aprs_ * .jsonl
Every APRS packet heard in one listening session, with a
.I .csv
and a
.I .kml
beside it where they were asked for.
.TP
.I ~/bandsaunter/
Where recordings, transcripts and logs are written, unless
.B \-\-output
@ -1325,6 +2081,18 @@ terms, in
or at
.UR https://www.gnu.org/licenses/
.UE .
.PP
One file is not original work.
.I ft8tables.py
holds the two fixed tables that define the FT8 error-correcting code, taken
from ft8_lib (https://github.com/kgoba/ft8_lib), MIT licensed, copyright 2018
K\[u0101]rlis Goba, which took them in turn from WSJT\-X. They are reproduced
under the MIT terms and that file carries the attribution they ask for. They
are there because they cannot be derived: everything else about FT8 here is
worked out from first principles, but those two tables are the code itself,
chosen once by its designers and published. No decoding logic was taken.
.SH HOMEPAGE
.I https://frostwarning.com/git/dustcouncil/bandsaunter
.SH BUGS
The DVB-T television driver claims these dongles on sight. If the receiver
cannot be opened, that is almost always why: the package blacklists the
@ -1342,6 +2110,10 @@ def main() -> int:
text = "\n".join(out)
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
"\n".join(aircraft_section()))
text = text.replace(".WEATHER_OPTIONS_HERE",
"\n".join(weather_section()))
text = text.replace(".APRS_OPTIONS_HERE", "\n".join(aprs_section()))
text = text.replace(".FT8_OPTIONS_HERE", "\n".join(ft8_section()))
text = text.replace("\n\n", "\n") # troff dislikes blank lines
target = Path(sys.argv[1] if len(sys.argv) > 1
else Path(__file__).parent / "bandsaunter.1")

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-browse-man.py -- do not edit by hand.
.TH SAUNTERBROWSE 1 "2026-09-04" "bandsaunter 2026-09-04_08" "User Commands"
.TH SAUNTERBROWSE 1 "2026-09-24" "bandsaunter 2026-09-24_01" "User Commands"
.SH NAME
saunterbrowse \- read and listen to what a bandsaunter scan collected
.SH SYNOPSIS
@ -107,6 +107,12 @@ on it again. The frequency is written into your saved settings, the same list
maintains, and takes effect on the next scan \[em] a scan already running read
its settings when it started.
.TP
.B c
Open the register: every callsign and every aircraft this installation has
ever looked up, read back out of the two caches. See
.B THE REGISTER
below.
.TP
.B "? h"
The list of keys, and which audio player was found.
.TP
@ -244,6 +250,61 @@ is written into the map: holding it and not saying so would be worse than
either showing it or not asking for it.
.B \-\-no\-lookup
asks for none of it.
.SH THE REGISTER
.B c
opens it. Everything this installation has ever found out about a callsign or
an aircraft, read back out of the two caches the lookups write: the scanner's
identification, the Morse idents, APRS, FT8 and this browser all resolve
callsigns into one file, and the aircraft side keeps its own. Between them
they are a record of who and what has been heard from this aerial, and until
now the only way to read either was to open the JSON.
.PP
The list shows the callsign or registration, who or what it is, where, when it
was looked up, and whether there is anywhere to point a map at. Under it,
everything known about whichever one is highlighted, in two columns \[em] a
licence has a dozen fields and a screen is wider than it is tall.
.TP
.B "\[ua] \[da]"
Move. Page Up and Page Down go a screen at a time, Home and End to the ends.
.TP
.B tab
Switch between callsigns and aircraft.
.TP
.B /
Search. Every field rather than the name, because the interesting question is
usually not "which callsign" but "who was in Arizona" or "what Bombardiers
have gone over", and both of those live in the detail. Every word has to
match, so a search can be narrowed.
.TP
.B g
Open where this one is, in whatever browser the desktop uses. Only the
coordinates go into the link: a map does not need to be told whose licence it
is looking at in order to show a place, and the link is the one part of this
that leaves the machine. Where nothing is known about the position it says so
rather than guessing, and on a machine with no browser \[em] which is the
normal case for a receiver \[em] it prints the coordinates instead.
.TP
.B r
Read the caches again, for somebody who has just finished a scan in another
window.
.TP
.B "c esc"
Back to the recordings.
.B q
still quits.
.PP
Callsigns that no register could place are kept rather than dropped. "Asked
about, and in no register reachable from here" is a fact about a station, and a
list that quietly dropped them would make an evening of unlisted foreign
stations look like an evening when nothing was looked up. An aircraft is put
on the map at the airport its flight started from, labelled as such: this is a
register of airframes rather than a position report, and the origin is the
nearest true thing.
.PP
The addresses are licence records, public by law and already printed by
.BR bandsaunter (1)
in its own reports; this shows them the same way rather than more
prominently.
.SH THE MAP
A licence says where its holder is, so a list of callsigns is also a map. The
scanner writes one as it runs and
@ -398,6 +459,30 @@ during the scan, and the
.I _transcription.txt
beside the recording is what a later re\-run wrote. The file wins, being the
more recent of the two.
.SH THE TITLE SCREEN
Drawn on startup in braille, two dots wide and four tall to a character, which
is eight times the detail a block gives and is what makes room for capitals and
lowercase at the same height \[em] a five-row block font has no second height
to spend, so the name comes out shouted. Coloured in a gradient from deep blue
through cyan to a cool white across the width \[em] deliberately not the
waterfall ramp the rest of the program draws in, which carries on through green
and yellow to red because it stands in for a spectrum and has to mean
something. Under it, who conceived the program and what wrote it.
.PP
The font is twelve letters cut by hand at cap height seven and then doubled,
because a stroke two dots thick reads as a line where one dot thick reads as a
dotted line.
.PP
It never appears where it would be in the way. Everything here can be piped
into something else, and a banner in the middle of that is corruption rather
than decoration, so anything that is not a terminal gets nothing at all. It is
drawn after the arguments are parsed, so
.B \-\-help
and a bad argument say their piece without one over the top.
.B \-\-no\-splash
turns it off on a terminal too, and
.B BANDSAUNTER_NO_SPLASH=1
turns it off for good.
.SH FILES
.TP
.I ~/bandsaunter/
@ -481,6 +566,8 @@ or
.UR https://www.gnu.org/licenses/
.UE .
There is no warranty, to the extent permitted by law.
.SH HOMEPAGE
.I https://frostwarning.com/git/dustcouncil/bandsaunter
.SH BUGS
The list is read when the browser opens. Press
.B r

View file

@ -21,6 +21,10 @@ dependencies = [
"PyYAML>=5.4",
]
[project.urls]
Homepage = "https://frostwarning.com/git/dustcouncil/bandsaunter"
Source = "https://frostwarning.com/git/dustcouncil/bandsaunter"
[project.optional-dependencies]
# The realtime aircraft window. Optional on purpose: without it the passive
# capture, the terminal board and the drawn maps all work unchanged.

1177
tests/test_acurite.py Normal file

File diff suppressed because it is too large Load diff

1415
tests/test_aprs.py Normal file

File diff suppressed because it is too large Load diff

260
tests/test_ax25.py Normal file
View file

@ -0,0 +1,260 @@
"""AX.25: the frames APRS rides in, and getting them off the air.
Every frame here is built by the encoder that sits beside the decoder, keyed
out as real Bell 202 audio, and read back. That proves the framing, the bit
stuffing, the NRZI, the checksum and the clock recovery; it does not prove
anything a description and an implementation of it might both get wrong, and
the module says so.
The other half is about what must not be read. This runs for hours with the
squelch open, so a frame either satisfies sixteen bits of CRC or it never
existed.
"""
import numpy as np
import pytest
from bandsaunter import ax25
RATE = 22_050.0
def heard(frames, rate=RATE, chunk=None, noise=0.02, amplitude=0.5):
"""Frames keyed out and read back, optionally a block at a time."""
if isinstance(frames, (bytes, bytearray)):
frames = [frames]
audio = ax25.modulate(frames, rate, amplitude=amplitude, noise=noise)
receiver = ax25.Receiver(rate)
if chunk is None:
return receiver.feed(audio, when=1_000.0)
out = []
for i in range(0, audio.size, chunk):
out += receiver.feed(audio[i:i + chunk], when=1_000.0)
return out
# ---------------------------------------------------------------------------
# What a frame is made of
# ---------------------------------------------------------------------------
def test_a_frame_comes_back_with_everything_it_was_sent_with():
raw = ax25.frame_bytes("W1AW-5", "APRS", "=4123.45N/07203.12W-hi",
path=("WIDE1-1*", "WIDE2-1"))
frame = ax25.frame_from(raw)
assert frame is not None
assert frame.source.plain == "W1AW-5" and frame.source.ssid == 5
assert frame.destination.plain == "APRS"
assert [str(h) for h in frame.path] == ["WIDE1-1*", "WIDE2-1"]
assert frame.text() == "=4123.45N/07203.12W-hi"
assert frame.route() == "W1AW-5>APRS,WIDE1-1*,WIDE2-1"
def test_a_station_with_no_ssid_is_written_without_one():
frame = ax25.frame_from(ax25.frame_bytes("W1AW", "APRS", "x"))
assert frame.source.plain == "W1AW"
assert str(frame.source) == "W1AW"
@pytest.mark.parametrize("ssid", range(16))
def test_every_ssid_survives(ssid):
call = f"KU0W-{ssid}" if ssid else "KU0W"
frame = ax25.frame_from(ax25.frame_bytes(call, "APRS", "x"))
assert frame.source.ssid == ssid
assert frame.source.plain == call
def test_a_digipeater_that_has_repeated_a_frame_says_so():
"""The H bit, which is how the path records where a frame has been.
The hops with it set are where the frame went; the rest are where it was
asked to go and has not been yet.
"""
frame = ax25.frame_from(ax25.frame_bytes(
"W1AW", "APRS", "x", path=("WIDE1-1*", "WIDE2-1")))
assert [str(h) for h in frame.heard_through] == ["WIDE1-1*"]
assert frame.path[0].repeated and not frame.path[1].repeated
def test_the_frame_type_apris_uses_is_recognised_and_others_are_named():
ui = ax25.frame_from(ax25.frame_bytes("W1AW", "APRS", "x"))
assert ui.unnumbered_information and ui.kind == "unnumbered information"
other = ax25.frame_from(ax25.frame_bytes("W1AW", "APRS", "x",
control=0x2F, pid=0xF0))
assert not other.unnumbered_information
assert other.kind == "set async balanced"
def test_a_frame_with_a_broken_check_is_refused():
raw = bytearray(ax25.frame_bytes("W1AW", "APRS", "hello"))
for i in range(len(raw)):
broken = bytearray(raw)
broken[i] ^= 0x01
assert ax25.frame_from(bytes(broken)) is None, f"byte {i} got through"
def test_a_frame_whose_addresses_are_not_callsigns_is_refused():
"""Sixteen bits of CRC is strong and this band is busy.
A frame whose addresses are unprintable passed the check by accident,
and there is no reason to put it on a display.
"""
raw = bytearray(ax25.frame_bytes("W1AW", "APRS", "x"))
raw[0] = 0x02 # an unprintable callsign
body = bytes(raw[:-2])
check = ax25.fcs(body)
assert ax25.frame_from(body + bytes([check & 0xFF, check >> 8])) is None
def test_a_frame_too_short_to_be_one_is_refused():
assert ax25.frame_from(b"") is None
assert ax25.frame_from(b"\x00" * 10) is None
@pytest.mark.parametrize("info", ["", "x", "!" * 256, "\x00\xff binary"])
def test_an_information_field_of_any_shape_survives(info):
frame = ax25.frame_from(ax25.frame_bytes("W1AW", "APRS", info))
assert frame is not None and frame.text() == info
# ---------------------------------------------------------------------------
# HDLC
# ---------------------------------------------------------------------------
def test_a_zero_is_stuffed_after_five_ones_and_taken_back_out():
assert ax25.stuff("11111" + "1") == "111110" + "1"
assert ax25.unstuff(ax25.stuff("1" * 40)) == "1" * 40
assert ax25.stuff("0" * 40) == "0" * 40
def test_stuffing_is_what_stops_a_flag_appearing_inside_a_frame():
assert ax25.FLAG not in ax25.stuff("0" + ax25.FLAG + "0")
def test_nrzi_is_the_same_data_read_from_either_polarity():
"""A zero is a change of tone and a one is no change, so inverting the
whole stream -- swapping mark for space, or wiring a discriminator up
backwards -- decodes to exactly the same bits. Nothing here ever has to
guess at polarity, and that is why."""
bits = "110100111000101"
sent = ax25.nrzi(bits)
upside_down = "".join("1" if b == "0" else "0" for b in sent)
assert ax25.un_nrzi("1" + sent) == bits
assert ax25.un_nrzi("0" + upside_down) == bits
def test_a_stream_is_consumed_up_to_the_last_flag_and_no_further():
"""What a caller reading a continuous signal may forget.
Trimming to before a frame that has already been reported hands it back
on the next block, and every packet is counted twice for ever.
"""
raw = ax25.frame_bytes("W1AW", "APRS", "hello")
bits = ax25.bits_of(raw, flags=2)
frames, used = ax25.hdlc_frames(bits)
assert frames == [raw]
assert used > 0
again, _ = ax25.hdlc_frames(bits[used:])
assert again == []
def test_a_frame_still_arriving_is_kept_rather_than_thrown_away():
raw = ax25.frame_bytes("W1AW", "APRS", "hello")
bits = ax25.bits_of(raw, flags=2)
half = bits[:len(bits) // 2]
frames, used = ax25.hdlc_frames(half)
assert frames == []
rest, _ = ax25.hdlc_frames(half[used:] + bits[len(bits) // 2:])
assert rest == [raw]
# ---------------------------------------------------------------------------
# Off the air
# ---------------------------------------------------------------------------
def test_a_packet_survives_being_keyed_out_and_read_back():
raw = ax25.frame_bytes("W1AW-5", "APRS", "=4123.45N/07203.12W-hi",
path=("WIDE1-1*",))
got = heard(raw)
assert [f.raw for f in got] == [raw]
assert got[0].at == 1_000.0
@pytest.mark.parametrize("chunk", [220, 1_102, 2_205, 11_025, 44_100])
def test_a_packet_is_read_once_however_the_audio_is_cut_into_blocks(chunk):
"""A packet is most of a second and a block is about one, so a frame
straddling the boundary is not an edge case -- it is most of them."""
raw = ax25.frame_bytes("W1AW-5", "APRS", "=4123.45N/07203.12W-hi")
got = heard(raw, chunk=chunk)
assert [f.raw for f in got] == [raw], f"{len(got)} frames at {chunk}"
def test_several_packets_back_to_back_all_come_through():
raws = [ax25.frame_bytes("W1AW", "APRS", f">beacon {i}") for i in range(4)]
got = heard(raws, chunk=2_205)
assert [f.text() for f in got] == [f">beacon {i}" for i in range(4)]
@pytest.mark.parametrize("rate", [11_025.0, 22_050.0, 24_000.0, 48_000.0])
def test_it_works_at_the_audio_rates_a_demodulator_might_hand_over(rate):
raw = ax25.frame_bytes("W1AW", "APRS", "=4123.45N/07203.12W-")
assert [f.raw for f in heard(raw, rate=rate, chunk=int(rate // 10))] == [raw]
def test_a_weak_packet_is_still_read():
raw = ax25.frame_bytes("W1AW", "APRS", "=4123.45N/07203.12W-")
got = heard(raw, noise=0.2, amplitude=0.5) # about 8 dB
assert [f.raw for f in got] == [raw]
def test_nothing_is_read_out_of_noise():
rng = np.random.default_rng(1)
for _ in range(80):
receiver = ax25.Receiver(RATE)
assert receiver.feed(rng.normal(0.0, 0.3, int(RATE))) == []
def test_a_receiver_can_be_reset_and_carries_nothing_over():
raw = ax25.frame_bytes("W1AW", "APRS", "hello")
audio = ax25.modulate(raw, RATE, noise=0.02)
receiver = ax25.Receiver(RATE)
receiver.feed(audio[:audio.size // 2])
receiver.reset()
assert receiver.feed(audio[audio.size // 2:]) == []
def test_the_receiver_counts_what_it_has_seen():
raw = ax25.frame_bytes("W1AW", "APRS", "hello")
receiver = ax25.Receiver(RATE)
receiver.feed(ax25.modulate(raw, RATE, noise=0.02))
assert receiver.frames == 1
assert receiver.bytes_seen >= len(raw)
# ---------------------------------------------------------------------------
# Where APRS is
# ---------------------------------------------------------------------------
def test_every_region_has_a_channel_and_they_are_all_different():
frequencies = [hz for _key, hz, _where in ax25.APRS_CHANNELS]
assert len(set(frequencies)) == len(frequencies)
assert all(144e6 < hz < 146e6 for hz in frequencies)
assert ax25.APRS_CHANNELS[0][1] == ax25.APRS_HZ == 144_390_000.0
def test_a_block_holding_more_frames_than_one_pass_takes_repeats_none():
"""The framer returns at most so many at a time, and says how far it got.
If it stopped at the limit without saying it had consumed the frames it
just returned, the caller would keep them and hand them back on the next
pass -- every packet in a busy second counted twice.
"""
raws = [ax25.frame_bytes("W1AW", "APRS", f">beacon {i}") for i in range(9)]
bits = "".join(ax25.bits_of(raw, flags=2) for raw in raws)
seen, at = [], 0
for _ in range(6):
frames, used = ax25.hdlc_frames(bits[at:], most=3)
if not frames:
break
seen += frames
at += used
assert seen == raws, f"{len(seen)} frames out of {len(raws)}"

View file

@ -5,6 +5,7 @@ in through the same door the real ones come through, and the PNGs are built
here from the specification rather than by the decoder they are testing.
"""
import json
import os
import struct
import time
import zlib
@ -215,8 +216,9 @@ def counting_fetcher(answers=None, fail_on=()):
def test_the_tiles_are_stitched_in_the_right_places():
fetch = counting_fetcher()
raster, ox, oy = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=fetch,
pause=0)
raster, ox, oy, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
fetch=fetch, pause=0)
assert covered.all()
assert raster is not None
assert raster.shape[2] == 3
assert raster.shape[0] % bm.TILE_PIXELS == 0
@ -233,28 +235,33 @@ def test_a_tile_that_does_not_arrive_leaves_a_hole_rather_than_an_error():
seen = counting_fetcher()
bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=seen, pause=0)
missing = seen.asked[:1]
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
fetch=counting_fetcher(fail_on=missing), pause=0)
raster, _, _, covered = bm.mosaic(
47.0, -122.8, 48.5, -121.0, 9,
fetch=counting_fetcher(fail_on=missing), pause=0, retry_pause=0)
assert raster is not None
assert not raster[:bm.TILE_PIXELS, :bm.TILE_PIXELS].any() # the hole
# And the hole is reported rather than left to be mistaken for map.
assert not covered[0, 0] and covered.sum() == covered.size - 1
def test_nothing_at_all_gives_no_map():
def nothing(z, x, y, **kw):
return None
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=nothing,
pause=0)
raster, _, _, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
fetch=nothing, pause=0, retry_pause=0)
assert raster is None
assert not covered.any()
def test_a_corrupt_tile_is_skipped():
def rubbish(z, x, y, **kw):
return b"not a png"
raster, _, _ = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9, fetch=rubbish,
pause=0)
raster, _, _, covered = bm.mosaic(47.0, -122.8, 48.5, -121.0, 9,
fetch=rubbish, pause=0, retry_pause=0)
assert raster is None
assert not covered.any()
# ---------------------------------------------------------------------------
@ -269,14 +276,203 @@ def gradient_tile(z, x, y, **kw):
def test_the_ground_comes_back_as_levels_the_map_can_paint():
levels = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150,
shades=32, fetch=gradient_tile, pause=0)
levels, _settled = bm.ground_under(47.0, -122.8, 48.5, -121.0, 200, 150,
shades=32, fetch=gradient_tile,
pause=0)
assert levels is not None
assert levels.shape == (150, 200)
assert levels.dtype == np.uint8
assert levels.max() <= 31
def _patchy(fail_on, note=None):
"""A tile server that refuses some squares. What a busy one does."""
def fetch(z, x, y, **kw):
if note is not None:
note.append((z, x, y))
if (x, y) in fail_on:
return None
# Something to see, and different in each tile, so that a hole is
# distinguishable from map rather than from a flat field.
return solid(40 if (x + y) % 3 == 0 else 230)
return fetch
BOX = (47.0, -123.0, 48.0, -122.0)
def _hole_at():
"""The grid position of one tile inside BOX, worked out here rather
than asked of the code whose arithmetic is under test elsewhere."""
zoom = bm.choose_zoom(*BOX, width=400)
x0, y0 = bm.tile_of(BOX[2], BOX[1], zoom)
return zoom, (int(x0) + 1, int(y0) + 1)
def _hole_mask(hole, zoom, width=400, height=400, grow=0):
"""Which output pixels the missing tile covers.
Worked out from the tile's own corners rather than from the picture, so
that a test of what is drawn in the gap is not asking the code that drew
it where the gap is. ``grow`` widens it, for the band along the edge
where a cell is averaged over part of a tile and part of nothing.
"""
south, west, north, east = tile_bounds(zoom, hole[0], hole[1])
top = (BOX[2] - min(north, BOX[2])) / (BOX[2] - BOX[0]) * height
bottom = (BOX[2] - max(south, BOX[0])) / (BOX[2] - BOX[0]) * height
left = (max(west, BOX[1]) - BOX[1]) / (BOX[3] - BOX[1]) * width
right = (min(east, BOX[3]) - BOX[1]) / (BOX[3] - BOX[1]) * width
mask = np.zeros((height, width), dtype=bool)
mask[max(0, int(top) - grow):int(bottom) + 1 + grow,
max(0, int(left) - grow):int(right) + 1 + grow] = True
return mask
def test_a_missing_tile_is_a_gap_rather_than_the_brightest_thing_on_the_map():
"""The canvas starts black and the brightness is inverted further down,
so a square that never arrived used to come out as the brightest thing
on the picture: a glowing rectangle where the map should be."""
zoom, hole = _hole_at()
# remember=False throughout: these two are the same view with different
# tiles under it, which cannot happen on the air and would otherwise be
# answered from the first one's cache entry.
whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0,
zoom=zoom, remember=False)
holed, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}),
pause=0, retry_pause=0, zoom=zoom,
remember=False)
assert whole is not None and holed is not None
gap = _hole_mask(hole, zoom)
assert gap.sum() > 1000, "the hole is not where this test thinks it is"
# There was map there, and now there is nothing -- rather than the
# brightest shade the picture has.
assert whole[gap].max() > 0
assert holed[_hole_mask(hole, zoom, grow=-1)].max() == 0
# And what leaks in is a hairline round the edge, where a cell is
# averaged over part of a tile and part of nothing, rather than a
# fraction of the hole: the averaging weighs the real pixels only.
assert int((holed[gap] > 0).sum()) < gap.sum() // 100
assert holed.max() == whole.max(), "the map lost its brightest shade"
def test_a_missing_tile_does_not_dim_the_rest_of_the_map():
"""Black is darker than any real tile, so counting a hole when working
out the darkest and brightest of what was fetched drags the floor down,
and every other pixel is drawn dimmer to make room for a square that is
not there."""
zoom, hole = _hole_at()
whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0,
zoom=zoom, remember=False)
holed, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}),
pause=0, retry_pause=0, zoom=zoom,
remember=False)
# Outside the gap and the averaged band along its edge, the map is the
# map: the hole took nothing else with it.
elsewhere = ~_hole_mask(hole, zoom, grow=2)
assert elsewhere.sum() > holed.size // 2
assert bool((holed[elsewhere] == whole[elsewhere]).all())
def test_a_map_with_squares_missing_says_it_is_not_the_whole_answer():
"""Most of a map is worth drawing and is not worth keeping: the caller
has to be able to tell the two cases apart to know whether to ask
again."""
_zoom, hole = _hole_at()
whole, settled = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()),
pause=0, remember=False)
assert whole is not None and settled
holed, settled = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}),
pause=0, retry_pause=0, remember=False)
assert holed is not None and not settled
def test_a_tile_that_fails_once_is_asked_for_again():
"""The usual reason for a hole is that a hundred tiles were asked for
in the preceding second, which is a server asking to be slowed down
rather than a tile that is not there."""
_zoom, hole = _hole_at()
refused = {"left": 1}
def flaky(z, x, y, **kw):
if (x, y) == hole and refused["left"]:
refused["left"] -= 1
return None
return solid(40 if (x + y) % 3 == 0 else 230)
whole, _ = bm.ground_under(*BOX, 400, 400, fetch=_patchy(()), pause=0,
remember=False)
healed, settled = bm.ground_under(*BOX, 400, 400, fetch=flaky, pause=0,
retry_pause=0, remember=False)
assert refused["left"] == 0, "the tile was never asked for a second time"
assert settled, "a recovered map is the whole answer"
assert bool((healed == whole).all())
def test_only_the_tiles_that_failed_are_asked_for_again():
"""A retry that fetched the lot again would treat a busy server by
asking it for everything twice."""
_zoom, hole = _hole_at()
asked = []
bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}, note=asked),
pause=0, retry_pause=0)
twice = [where for where in set(asked) if asked.count(where) > 1]
assert len(twice) == 1 and twice[0][1:] == hole
def test_retrying_can_be_turned_off():
_zoom, hole = _hole_at()
asked = []
bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}, note=asked),
pause=0, retries=0)
assert len(asked) == len(set(asked))
def test_only_a_tile_that_had_to_be_fetched_costs_politeness(monkeypatch):
"""The pause is an apology to a volunteer-funded server, and a file
that was already on the disk was never asked of it. Paying it anyway
is how a view whose tiles are all in hand takes half a minute to
redraw -- and how asking again for three missing squares costs the
wait for two hundred that are not missing."""
slept = []
monkeypatch.setattr(bm.time, "sleep", lambda s: slept.append(s))
zoom, _hole = _hole_at()
_r, _x, _y, covered = bm.mosaic(*BOX, zoom, fetch=_patchy(()),
pause=0.05,
cached=lambda z, x, y, **kw: False)
assert len(slept) == int(covered.sum()) > 1
slept.clear()
bm.mosaic(*BOX, zoom, fetch=_patchy(()), pause=0.05,
cached=lambda z, x, y, **kw: True)
assert slept == []
def test_asking_again_for_what_is_missing_does_not_wait_for_what_is_not(
monkeypatch):
"""Which is what makes a second ask affordable at all: the tiles that
arrived the first time are on the disk, so the only thing paid for is
the handful that did not."""
slept = []
monkeypatch.setattr(bm.time, "sleep", lambda s: slept.append(s))
zoom, hole = _hole_at()
# Everything arrived but the one square, so everything but that square
# is now on the disk.
bm.mosaic(*BOX, zoom, fetch=_patchy({hole}), pause=0.05, retries=0,
cached=lambda z, x, y, **kw: (x, y) != hole)
assert len(slept) <= 1, slept
def test_a_tile_is_on_the_disk_once_it_has_been_fetched(tmp_path):
zoom, hole = _hole_at()
assert not bm.on_disk(zoom, hole[0], hole[1], cache=tmp_path)
where = tmp_path / str(zoom) / str(hole[0])
where.mkdir(parents=True)
(where / f"{hole[1]}.png").write_bytes(solid(200))
assert bm.on_disk(zoom, hole[0], hole[1], cache=tmp_path)
def tile_bounds(zoom: int, x: int, y: int):
"""The corners of one tile, from the inverse of the standard formula.
@ -298,7 +494,8 @@ def test_the_map_is_inverted_so_that_ink_shows_on_a_dark_picture():
"""A printed map is dark ink on white paper; this picture is the other
way round, so the dark parts of a tile are the bright parts here."""
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
levels, _settled = bm.ground_under(south, west, north, east, 64, 64,
shades=32,
fetch=gradient_tile, zoom=9, pause=0)
# The tile is black at the top and white at the bottom, so the picture
# has to be bright at the top and dark at the bottom.
@ -313,7 +510,8 @@ def test_north_is_at_the_top():
return make_png(px)
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
levels, _settled = bm.ground_under(south, west, north, east, 40, 40,
shades=32,
fetch=half_and_half, zoom=9, pause=0)
assert levels[0].mean() < levels[-1].mean() # white inverts to dark
@ -325,7 +523,8 @@ def test_east_is_to_the_right():
return make_png(px)
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 40, 40, shades=32,
levels, _settled = bm.ground_under(south, west, north, east, 40, 40,
shades=32,
fetch=half_and_half, zoom=9, pause=0)
assert levels[:, 0].mean() < levels[:, -1].mean()
@ -338,7 +537,8 @@ def test_one_tile_covers_its_own_box_exactly():
return make_png(px)
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 128, 128, shades=32,
levels, _settled = bm.ground_under(south, west, north, east, 128, 128,
shades=32,
fetch=corner_marks, zoom=9, pause=0)
dark = levels < levels.max() / 2 # the white corner, inverted
assert dark[:4, :4].all()
@ -349,13 +549,18 @@ def test_no_tiles_means_no_ground_rather_than_an_exception():
def nothing(z, x, y, **kw):
return None
assert bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50,
fetch=nothing, pause=0) is None
levels, settled = bm.ground_under(47.0, -122.8, 48.5, -121.0, 50, 50,
fetch=nothing, pause=0, retry_pause=0)
assert levels is None
# Settled on purpose: a machine with no network must not spend the
# night asking for tiles it is never going to be given.
assert settled
def test_a_box_that_makes_no_sense_is_refused_quietly():
assert bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50,
fetch=gradient_tile, pause=0) is None
levels, settled = bm.ground_under(48.0, -122.0, 47.0, -123.0, 50, 50,
fetch=gradient_tile, pause=0)
assert levels is None and settled
# ---------------------------------------------------------------------------
@ -655,7 +860,8 @@ def test_a_cell_smaller_than_a_source_pixel_takes_that_pixel():
def test_a_gradient_still_comes_out_as_a_gradient():
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 64, 64, shades=32,
levels, _settled = bm.ground_under(south, west, north, east, 64, 64,
shades=32,
fetch=gradient_tile, zoom=9, pause=0)
rows = levels.mean(axis=1)
assert (np.diff(rows) <= 0.51).all(), "the gradient came out lumpy"
@ -664,16 +870,172 @@ def test_a_gradient_still_comes_out_as_a_gradient():
def test_averaging_is_the_same_shape_as_the_picture_asked_for():
south, west, north, east = tile_bounds(9, 81, 178)
for width, height in ((40, 40), (137, 91), (300, 200), (17, 5)):
levels = bm.ground_under(south, west, north, east, width, height,
shades=32, fetch=gradient_tile, zoom=9,
pause=0)
levels, _settled = bm.ground_under(south, west, north, east,
width, height, shades=32,
fetch=gradient_tile, zoom=9,
pause=0)
assert levels.shape == (height, width)
def test_a_map_asked_for_at_more_detail_than_the_tiles_hold_still_works():
"""Zoomed in past the tiles, a cell covers less than one source pixel."""
south, west, north, east = tile_bounds(9, 81, 178)
levels = bm.ground_under(south, west, north, east, 2000, 2000, shades=32,
levels, _settled = bm.ground_under(south, west, north, east, 2000, 2000,
shades=32,
fetch=gradient_tile, zoom=9, pause=0)
assert levels.shape == (2000, 2000)
assert levels[0].mean() != levels[-1].mean()
# ---------------------------------------------------------------------------
# Keeping the finished map
# ---------------------------------------------------------------------------
def test_a_map_already_built_is_not_built_again(monkeypatch):
"""The tiles were always cached; the work done on them was not. Opening
the same window twice used to decode forty PNGs and resample a
megapixel and a half into this program's own projection, both times,
for an answer that cannot have changed."""
box = (47.0, -123.0, 48.0, -122.0)
asked = []
def counting(z, x, y, **kw):
asked.append((z, x, y))
return gradient_tile(z, x, y, **kw)
first, settled = bm.ground_under(*box, 300, 300, shades=32,
fetch=counting, pause=0)
assert settled and first is not None
built = len(asked)
assert built > 1
asked.clear()
again, settled = bm.ground_under(*box, 300, 300, shades=32,
fetch=counting, pause=0)
assert settled
assert asked == [], "it went back to the tiles for a map it already had"
assert bool((again == first).all()), "the kept map is a different map"
def test_a_map_with_squares_missing_is_not_kept():
"""Caching a hole would keep it for a month, and the whole point of
calling a partial map provisional is that it gets asked for again."""
zoom, hole = _hole_at()
holed, settled = bm.ground_under(*BOX, 400, 400, fetch=_patchy({hole}),
pause=0, retry_pause=0, zoom=zoom)
assert holed is not None and not settled
assert list(bm.ground_cache_dir().glob("*.npz")) == []
def test_a_different_picture_is_a_different_map():
"""Same piece of world, bigger window: reusing the smaller one would be
exactly the upscale this program went to some trouble to avoid."""
box = (47.0, -123.0, 48.0, -122.0)
small, _ = bm.ground_under(*box, 200, 200, shades=32,
fetch=gradient_tile, pause=0)
large, _ = bm.ground_under(*box, 400, 400, shades=32,
fetch=gradient_tile, pause=0)
assert small.shape == (200, 200) and large.shape == (400, 400)
elsewhere, _ = bm.ground_under(48.0, -123.0, 49.0, -122.0, 200, 200,
shades=32, fetch=gradient_tile, pause=0)
assert len(list(bm.ground_cache_dir().glob("*.npz"))) == 3
def test_a_view_that_drifted_a_few_metres_is_the_same_view():
"""Rounded to about a hundred metres, which is finer than a tile and
far finer than anything visible: a map rebuilt because the middle
moved by a pixel would defeat the point of keeping it."""
a, _ = bm.ground_under(47.0, -123.0, 48.0, -122.0, 200, 200, shades=32,
fetch=gradient_tile, pause=0)
asked = []
def counting(z, x, y, **kw):
asked.append((z, x, y))
return gradient_tile(z, x, y, **kw)
b, _ = bm.ground_under(47.00002, -123.00002, 48.00002, -121.99998,
200, 200, shades=32, fetch=counting, pause=0)
assert asked == []
assert bool((a == b).all())
def test_keeping_the_map_can_be_turned_off():
box = (47.0, -123.0, 48.0, -122.0)
bm.ground_under(*box, 250, 250, shades=32, fetch=gradient_tile,
pause=0, remember=False)
assert list(bm.ground_cache_dir().glob("*.npz")) == []
def test_a_truncated_cache_file_is_a_miss_rather_than_a_crash():
"""Which is what a cache looks like after a power cut."""
box = (47.0, -123.0, 48.0, -122.0)
bm.ground_under(*box, 220, 220, shades=32, fetch=gradient_tile, pause=0)
kept = list(bm.ground_cache_dir().glob("*.npz"))
assert len(kept) == 1
kept[0].write_bytes(b"PK\x03\x04 truncated")
levels, settled = bm.ground_under(*box, 220, 220, shades=32,
fetch=gradient_tile, pause=0)
assert levels is not None and settled and levels.shape == (220, 220)
def test_a_map_kept_too_long_ago_is_built_again(monkeypatch):
box = (47.0, -123.0, 48.0, -122.0)
bm.ground_under(*box, 210, 210, shades=32, fetch=gradient_tile, pause=0)
later = time.time() + bm.GROUND_CACHE_DAYS * 86_400 + 1
monkeypatch.setattr(bm.time, "time", lambda: later)
asked = []
def counting(z, x, y, **kw):
asked.append(1)
return gradient_tile(z, x, y, **kw)
bm.ground_under(*box, 210, 210, shades=32, fetch=counting, pause=0)
assert asked, "a month-old map was believed"
def test_forgetting_the_maps_keeps_the_tiles():
"""The maps are quick to rebuild from tiles and the tiles are not quick
to fetch again, so the cheap thing goes first."""
bm.ground_under(47.0, -123.0, 48.0, -122.0, 230, 230, shades=32,
fetch=gradient_tile, pause=0)
before = bm.cache_size()
assert before["ground"]["files"] == 1
assert bm.forget_ground() == 1
after = bm.cache_size()
assert after["ground"]["files"] == 0
assert after["tiles"]["files"] == before["tiles"]["files"]
def test_the_cache_says_how_big_it_is(tmp_path, monkeypatch):
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
empty = bm.cache_size()
assert empty["total"]["bytes"] == 0 and empty["total"]["files"] == 0
root = tmp_path / "bandsaunter" / "tiles" / "9" / "81"
root.mkdir(parents=True)
(root / "178.png").write_bytes(b"x" * 1000)
got = bm.cache_size()
assert got["tiles"] == {"bytes": 1000, "files": 1}
assert got["total"]["bytes"] == 1000
def test_pruning_throws_away_the_least_recently_used(tmp_path, monkeypatch):
"""Least recently *used*, not oldest: a tile fetched a year ago and
looked at last night is the receiver's own neighbourhood, and throwing
that away to keep last week's holiday is the wrong way round."""
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
where = tmp_path / "bandsaunter" / "tiles" / "9" / "81"
where.mkdir(parents=True)
now = time.time()
for i, age in enumerate((100.0, 0.0, 50.0)): # middle one is freshest
path = where / f"{i}.png"
path.write_bytes(b"x" * 400_000)
os.utime(path, (now - age * 86_400, now - age * 86_400))
assert bm.cache_size()["tiles"]["files"] == 3
gone = bm.prune_cache(limit_mb=0.5)
assert gone == 2
left = list(where.glob("*.png"))
assert [p.name for p in left] == ["1.png"], "it kept the wrong one"
def test_pruning_a_cache_that_already_fits_does_nothing():
assert bm.prune_cache(limit_mb=10_000) == 0

View file

@ -1947,3 +1947,189 @@ def test_the_card_fades_with_the_label_it_is_behind():
return int((base.astype(int) - img.astype(int)).clip(0).sum())
assert darkness(1.0) > darkness(0.5) > 0
# ---------------------------------------------------------------------------
# Pulsing aircraft, and the rings that leave them
# ---------------------------------------------------------------------------
def test_a_pulse_swells_and_falls_rather_than_flashing():
"""A raised cosine, so the bright end is a swell and not a flash: what
a phosphor does when the beam lingers, not what a warning light does."""
over = [fm.pulse_at(t / 20.0 * 2.0, 2.0) for t in range(21)]
assert over[0] == pytest.approx(0.0, abs=1e-9)
assert over[10] == pytest.approx(1.0, abs=1e-9)
assert over[20] == pytest.approx(0.0, abs=1e-9)
assert over[:11] == sorted(over[:11]), "it does not rise smoothly"
assert over[10:] == sorted(over[10:], reverse=True), "nor fall smoothly"
assert all(0.0 <= v <= 1.0 for v in over)
def test_a_pulse_repeats_at_the_rate_it_was_given():
for rate in (1.0, 2.2, 7.5):
assert fm.pulse_at(0.0, rate) == pytest.approx(fm.pulse_at(rate, rate))
assert fm.pulse_at(0.3, rate) == pytest.approx(
fm.pulse_at(0.3 + 3 * rate, rate))
def test_a_rate_of_nothing_is_no_pulse_at_all():
assert fm.pulse_at(1.7, 0.0) == 1.0
def test_each_aircraft_keeps_its_own_place_in_the_cycle():
"""A sky full of them beating as one reads as a display flashing rather
than as a lot of separate things transmitting."""
seen = {fm.phase_of(f"A0{n:04X}") for n in range(200)}
assert len(seen) > 150, "too many aircraft share a phase"
assert all(0.0 <= p < 1.0 for p in seen)
# Stable: the same address is the same phase, every time.
assert fm.phase_of("AC4C44") == fm.phase_of("AC4C44")
assert fm.phase_of("") == 0.0
def test_an_echo_leaves_and_travels_out_and_is_replaced():
"""One at a time: a new one leaves as the last reaches its reach."""
ages = [fm.echo_age(t / 10.0, 3.0) for t in range(31)]
assert ages[0] == pytest.approx(0.0)
assert ages[:30] == sorted(ages[:30]), "it does not travel outward"
assert ages[30] == pytest.approx(0.0), "it does not start again"
assert all(0.0 <= a < 3.0 for a in ages)
assert fm.echo_age(1.0, 0.0) == 0.0
def test_a_ring_is_a_circle_and_not_a_scattering_of_dots():
img = np.full((80, 80), fm.BG, dtype=np.uint8)
fm._ring(img, 40, 40, 20, fm.INK)
ys, xs = np.where(img == fm.INK)
assert len(xs) > 90, "too few pixels for a circle of that size"
away = np.hypot(xs - 40, ys - 40)
assert away.min() >= 19 and away.max() <= 21, (away.min(), away.max())
# No gaps: every angle round the circle has a pixel near it.
import math as _math
for step in range(0, 360, 10):
angle = _math.radians(step)
want = (40 + 20 * _math.cos(angle), 40 + 20 * _math.sin(angle))
assert np.hypot(xs - want[0], ys - want[1]).min() < 1.6, step
def test_a_ring_of_nothing_draws_nothing():
img = np.full((40, 40), fm.BG, dtype=np.uint8)
fm._ring(img, 20, 20, 0, fm.INK)
assert not (img == fm.INK).any()
def test_the_burn_has_the_shape_of_the_aircraft_casting_it():
"""Grown from the aeroplane rather than drawn as a circle round it: a
ring of dots is what a halo is not."""
img = np.full((60, 60), fm.BG, dtype=np.uint8)
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
step = fm.altitude_step(30_000)
halo = (img == fm.TRAIL + step) | (img == fm.OLD + step)
core = img >= fm.HOT
assert core.any(), "no aircraft at the top of its pulse"
assert halo.sum() > 20, "no halo round it"
# The halo hugs the shape: every halo pixel is within a few of a core one.
hy, hx = np.where(halo)
cy, cx = np.where(core)
for x, y in zip(hx[:60], hy[:60]):
assert np.hypot(cx - x, cy - y).min() <= 3.5, (x, y)
def test_a_burn_never_paints_over_anything_already_drawn():
"""A halo is what light does to the dark around a thing. Painting it
over a neighbouring aeroplane would be light doing something light does
not do -- so with nothing but other drawn things around, there is
nowhere for it to go and it goes nowhere."""
img = np.full((60, 60), fm.AIRPORT, dtype=np.uint8)
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
step = fm.altitude_step(30_000)
assert not ((img == fm.TRAIL + step) | (img == fm.OLD + step)).any(), \
"the halo was painted over the aerodromes"
assert (img >= fm.HOT).any(), "the aircraft itself was not drawn"
def test_a_burn_does_go_on_the_ground_and_the_empty_background():
"""The other half of it: there is somewhere for the light to go."""
step = fm.altitude_step(30_000)
for under in (fm.BG, fm.GRID, fm.GROUND + 4):
img = np.full((60, 60), under, dtype=np.uint8)
fm._pulse(img, 30, 30, 0.0, 30_000, 1.0)
assert ((img == fm.TRAIL + step) | (img == fm.OLD + step)).any(), under
def test_the_dim_end_of_a_pulse_is_dimmer_than_the_bright_end():
def drawn(level):
img = np.full((60, 60), fm.BG, dtype=np.uint8)
fm._pulse(img, 30, 30, 0.0, 30_000, level)
return int(fm.PALETTE[img].astype(int).sum())
assert drawn(1.0) > drawn(0.55) > drawn(0.3) > drawn(0.0)
def _beat_frame(beat, **over):
track = straight()
view = fm.fit([track], width=600, box=(50.0, -3.0, 52.0, 3.0))
base = fm.background(view, unit="knots")
settings = dict(unit="knots", labels=False, beat=beat)
settings.update(over)
return base, fm.render_frame(base, view, [track], track.fixes[0].at,
**settings)
def test_a_frame_with_no_pulse_and_no_echo_draws_neither():
"""Which is what the pictures looked like before, and still can."""
base, img = _beat_frame(1.1)
assert not (img >= fm.HOT).any(), "an aircraft burned with no pulse asked"
plain = _beat_frame(0.0)[1]
assert np.array_equal(img, plain), "something moved with both turned off"
def _at_phase(rate, want):
"""The beat at which straight()'s aircraft is `want` of the way round.
Each aeroplane is offset by its own address, so "half a cycle in" is a
different moment for each of them and a test cannot assume it is at the
half-way mark of the clock.
"""
return rate * ((want - fm.phase_of(straight().icao)) % 1.0)
def test_a_pulsing_frame_burns_at_the_top_of_the_swell():
_base, peak = _beat_frame(_at_phase(2.2, 0.5), pulse=2.2)
_base, trough = _beat_frame(_at_phase(2.2, 0.0), pulse=2.2)
assert (peak >= fm.HOT).any(), "nothing burned at the top of the pulse"
assert not (trough >= fm.HOT).any(), "it burned at the bottom too"
assert fm.PALETTE[peak].astype(int).sum() > \
fm.PALETTE[trough].astype(int).sum()
def test_an_echoing_frame_sends_a_ring_out_and_a_bigger_one_later():
def reach(beat):
_base, img = _beat_frame(beat, echo=3.0, echo_reach=60)
step = fm.altitude_step(35_000)
ring = ((img == fm.TRAIL + step) | (img == fm.OLD + step)
| (img == fm.FAINT + step))
ys, xs = np.where(ring)
return 0 if not len(xs) else int(np.hypot(xs - xs.mean(),
ys - ys.mean()).max())
early, late = reach(_at_phase(3.0, 0.15)), reach(_at_phase(3.0, 0.8))
assert early > 0, "no ring at all"
assert late > early, f"the ring did not grow: {early} then {late}"
def test_an_aircraft_that_has_gone_quiet_does_not_pulse():
"""It is fading, and a thing that is fading and beating at once says
two contradictory things about itself."""
track = straight()
view = fm.fit([track], width=600, box=(50.0, -3.0, 52.0, 3.0))
base = fm.background(view, unit="knots")
gone = track.fixes[-1].at + 320.0 # past stale, inside the fade
peak = _at_phase(2.2, 0.5) # where it would burn
img = fm.render_frame(base, view, [track], gone, unit="knots",
labels=False, stale=300.0, fade=60.0, beat=peak,
pulse=2.2, echo=3.0)
assert not (img >= fm.HOT).any(), "a fading aircraft burned"
# And it is still on the picture, fading, rather than simply gone.
assert (img != base).any(), "the aircraft was not drawn at all"

1071
tests/test_ft8.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -243,3 +243,79 @@ def test_the_flags_it_tells_you_to_use_are_real():
for line in ("scan -b 2m --simulate", "adsb --simulate --seconds 30",
"devices --test", "transcribe --engines"):
parser.parse_args(line.split()) # raises SystemExit if not
# ---------------------------------------------------------------------------
# Where it lives
# ---------------------------------------------------------------------------
REPOSITORY = "https://frostwarning.com/git/dustcouncil/bandsaunter"
def test_the_install_instructions_say_where_to_clone_from():
"""This said "git clone <the repository>" for a long time.
Nothing noticed, because nothing reads the install instructions except
somebody installing -- who then cannot. A placeholder in the one
command a new person types first is the worst place for one, so it is
checked here rather than hoped about.
"""
body = INSTALL.read_text()
clones = re.findall(r"^git clone\s+(\S+)", body, re.MULTILINE)
assert clones, "the install instructions do not say how to get the source"
for where in clones:
assert where.startswith(("http://", "https://", "git@", "ssh://")), \
f"not a repository anybody can clone: {where!r}"
assert "<" not in where and ">" not in where, \
f"still a placeholder: {where!r}"
@pytest.mark.parametrize("path", ["INSTALL.md", "README.md",
"pyproject.toml"])
def test_the_files_a_new_person_reads_have_no_placeholders_left(path):
"""Angle brackets around a word are how a placeholder is written, and
every one of them is a sentence somebody cannot act on."""
body = (ROOT / path).read_text()
# Inside a fenced block a placeholder is an instruction to substitute;
# outside one it is prose. Both are checked, and both are wrong when the
# thing to substitute is the repository.
for bad in ("<the repository>", "<repository>", "<repo>", "<URL>",
"<url>"):
assert bad not in body, f"{path} still says {bad}"
def test_the_packaging_metadata_names_the_repository():
import tomllib
body = tomllib.load(open(ROOT / "pyproject.toml", "rb"))
urls = body["project"].get("urls") or {}
assert urls, "pyproject names no homepage at all"
assert any(REPOSITORY in v for v in urls.values())
@pytest.mark.parametrize("page", ["bandsaunter.1", "saunterbrowse.1"])
def test_both_manuals_say_where_the_source_is(page):
body = (ROOT / "packaging" / page).read_text()
assert REPOSITORY in body, f"{page} does not say where to find the source"
def test_the_built_package_says_where_it_came_from():
"""Debian policy asks for a Homepage field, and an installed package
that cannot say where it came from is one nobody can update."""
for script in ("build-deb.sh", "build-repo.sh"):
body = (ROOT / "packaging" / script).read_text()
assert f"Homepage: {REPOSITORY}" in body, script
assert "Maintainer: bandsaunter" not in body, \
f"{script} still names the package as its own maintainer"
def test_the_tile_server_is_told_where_to_look_this_program_up():
"""Not decoration: the usage policy of the service the default tiles
come from asks for a User-Agent that identifies the application and
gives somewhere to look it up, so that an operator with a question
about the traffic has somebody to ask."""
from bandsaunter.basemap import USER_AGENT
assert "bandsaunter/" in USER_AGENT
assert REPOSITORY in USER_AGENT
assert bandsaunter.__version__ in USER_AGENT

View file

@ -363,6 +363,123 @@ def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
assert sky.wanted_ground() is None
def test_a_map_with_squares_missing_is_asked_for_again():
"""A resize picks a sharper zoom and asks for a hundred tiles that have
never been on this disk at once, and a busy server refuses some of
them. Most of a map gets drawn; it does not get kept."""
from bandsaunter.livemap import GROUND_RETRY_S
sky = a_sky()
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
settled=False)
assert sky.wanted_ground() is None, "not before the server has a rest"
assert not sky.ground_settled()
sky.reask_ground()
assert sky.wanted_ground() is None, "still too soon"
sky._ground_at -= GROUND_RETRY_S + 1
sky.reask_ground()
# Asked for again, and for the map it already asked for rather than for
# whatever the view has drifted to since.
assert sky.wanted_ground() == ("a", (0, 0, 1, 1), (10, 10))
def test_a_whole_map_is_never_asked_for_again():
sky = a_sky()
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1))
assert sky.ground_settled()
sky._ground_at -= 10_000.0
sky.reask_ground()
assert sky.wanted_ground() is None
def test_a_square_that_is_missing_for_good_is_not_asked_for_all_night():
"""A tile can be absent because there is no such tile. Asking for it
every half minute until morning is the same discourtesy more slowly."""
from bandsaunter.livemap import GROUND_RETRY_S, GROUND_TRIES
sky = a_sky()
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
asks = 0
for _ in range(GROUND_TRIES + 5):
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
settled=False)
sky._ground_at -= GROUND_RETRY_S + 1
sky.reask_ground()
if sky.wanted_ground() is not None:
asks += 1
sky._wanted = None
assert asks == GROUND_TRIES - 1, asks
def test_moving_the_view_starts_the_asking_over():
"""The count is against one view's tiles. A window that was resized
twice has not used up its patience on the second view."""
from bandsaunter.livemap import GROUND_RETRY_S, GROUND_TRIES
sky = a_sky()
for _ in range(GROUND_TRIES + 2):
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
settled=False)
sky.want_ground("b", (0, 0, 2, 2), (20, 20))
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "b", (0, 0, 2, 2),
settled=False)
sky._wanted = None
sky._ground_at -= GROUND_RETRY_S + 1
sky.reask_ground()
assert sky.wanted_ground() is not None
@qt
def test_drawing_most_of_a_map_draws_it_and_asks_for_the_rest(app):
"""Both halves: the window does not go bare while it waits, and it does
not settle for the map it was given."""
from bandsaunter.livemap import GROUND_RETRY_S, SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
view._draw_ground(_NoPainter(), view.projection()) # asks
wanted = sky.wanted_ground()
assert wanted is not None
key, box, _size = wanted
sky.set_ground(np.full((760, 1050), 12, dtype=np.uint8), key, box,
settled=False)
drawn = []
class _Painter(_NoPainter):
def drawImage(self, *a):
drawn.append(a)
sky._ground_at -= GROUND_RETRY_S + 1
view._draw_ground(_Painter(), view.projection())
assert drawn, "most of a map is still worth drawing"
assert sky.wanted_ground() is not None, "and worth finishing"
@qt
def test_drawing_a_whole_map_asks_for_nothing(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
view._draw_ground(_NoPainter(), view.projection())
key, box, _size = sky.wanted_ground()
sky.set_ground(np.full((760, 1050), 12, dtype=np.uint8), key, box)
sky._wanted = None
class _Painter(_NoPainter):
def drawImage(self, *a):
pass
sky._ground_at -= 10_000.0
view._draw_ground(_Painter(), view.projection())
assert sky.wanted_ground() is None
# ---------------------------------------------------------------------------
# The map staying still
# ---------------------------------------------------------------------------
@ -1919,3 +2036,632 @@ def test_nothing_in_the_window_is_drawn_over_the_flag(app):
assert is_flag(x, y), "the foot of the pole was painted over"
assert all(is_flag(x, y - up) for up in range(HOME_POLE)), \
"part of the pole was painted over"
# ---------------------------------------------------------------------------
# Pulsing aircraft, and the rings that leave them
# ---------------------------------------------------------------------------
def test_a_sky_draws_neither_effect_unless_it_is_asked_to():
plain = a_sky()
assert plain.pulse == 0.0 and plain.echo == 0.0
lively = Sky(pulse=2.2, echo=3.0, echo_reach=46)
assert lively.pulse == 2.2 and lively.echo == 3.0
def test_the_effect_settings_are_kept_inside_their_range():
quiet = Sky(pulse=-1.0, echo=-2.0, echo_reach=-5.0)
assert quiet.pulse == 0.0 and quiet.echo == 0.0 and quiet.echo_reach == 0.0
def _at_own_peak(sky, icao, want=0.5):
"""Wind the clock so this aircraft is `want` of the way round its pulse.
Each is offset by its own address, so a test cannot assume that half a
cycle on the clock is half a cycle for any particular aeroplane.
"""
from bandsaunter.flightmap import phase_of
rate = sky.pulse or sky.echo
sky.started = time.time() - rate * ((want - phase_of(icao)) % 1.0)
@qt
def test_an_aircraft_burns_at_the_top_of_its_pulse(app):
"""Brighter, and wearing a halo grown out of its own shape: what a
phosphor does when the beam sits in one place a little too long."""
from bandsaunter.livemap import SkyView
def drawn(want):
sky = a_sky(a_blip(), pulse=2.2)
_at_own_peak(sky, "A76154", want)
view = SkyView(sky)
view.show_ground = False
view.detail = 0 # the symbol alone
picture = _rendered(view)
return int(picture[:, :, :3].astype(int).sum())
assert drawn(0.5) > drawn(0.0), "it did not brighten at the top"
@qt
def test_a_pulse_turned_off_leaves_the_aircraft_alone(app):
"""Asked of the level rather than of the pixels: the header carries a
running clock, so two whole frames are never identical anyway."""
from bandsaunter.livemap import SkyView
off = SkyView(a_sky(a_blip()))
assert off._pulse_level("A76154") is None
on = SkyView(a_sky(a_blip(), pulse=2.2))
for want in (0.0, 0.25, 0.5, 0.75):
_at_own_peak(on.sky, "A76154", want)
level = on._pulse_level("A76154")
assert level is not None and 0.0 <= level <= 1.0
_at_own_peak(on.sky, "A76154", 0.5)
assert on._pulse_level("A76154") > 0.98, "it does not reach the top"
_at_own_peak(on.sky, "A76154", 0.0)
assert on._pulse_level("A76154") < 0.02, "nor the bottom"
@qt
def test_a_ring_leaves_an_aircraft_and_grows(app):
from bandsaunter.livemap import SkyView
def spread(want):
"""How far the ring has got from its aircraft.
Measured against the same view with the echo turned off, and only
within a box round the aeroplane: the header carries a running
clock and the box a running age, and neither is a ring.
"""
sky = a_sky(a_blip(), echo=3.0, echo_reach=60)
_at_own_peak(sky, "A76154", want)
view = SkyView(sky)
view.show_ground = False
view.detail = 0
with_it = _rendered(view)
sky.echo = 0.0
without = _rendered(view)
x, y = view.projection().xy(a_blip().latitude, a_blip().longitude)
top, left = max(0, y - 90), max(0, x - 90)
near = slice(top, y + 90), slice(left, x + 90)
differs = (with_it[near] != without[near]).any(axis=2)
ys, xs = np.where(differs)
if not len(xs):
return 0
return int(np.hypot(xs + left - x, ys + top - y).max())
early, late = spread(0.15), spread(0.8)
assert early > 0, "no ring at all"
assert late > early, f"the ring did not grow: {early} then {late}"
@qt
def test_the_window_keeps_asking_for_frames_while_anything_is_alive(app):
"""A pulse and an echo are never finished, so the window has to keep
repainting the way it does while a box is on its way somewhere."""
from bandsaunter.livemap import SkyView
lively = SkyView(a_sky(a_blip(), pulse=2.2))
lively.show_ground = False
_rendered(lively)
assert lively._glide_timer.isActive(), "it stopped asking for frames"
still = SkyView(a_sky(a_blip()))
still.show_ground = False
_rendered(still)
assert not still._glide_timer.isActive(), \
"it is repainting with nothing to animate"
# ---------------------------------------------------------------------------
# Marks that are not aeroplanes
# ---------------------------------------------------------------------------
#
# The window draws an APRS station as well as an aircraft. Everything it
# knows how to do -- fetch a map, place a box where it covers nothing, glide
# it when its owner moves, draw rings and put a flag where the aerial is --
# has nothing to do with aviation, so the three things that do are hooks
# rather than a second window.
def test_an_aircraft_still_fades_when_it_stops_transmitting():
"""The guard on the other side of the hook: nothing about aircraft moved."""
sky = Sky(hold=10.0, fade=10.0)
old = Blip(icao="A1", latitude=47.0, longitude=-122.0,
last_seen=now() - 15.0)
sky.update([old], 1, 1)
assert 0.0 < sky.strength(old) < 1.0
gone = Blip(icao="A2", latitude=47.0, longitude=-122.0,
last_seen=now() - 400.0)
sky.update([gone], 1, 1)
assert [b.icao for b in sky.flying()] == ["A1"]
def test_a_station_never_fades_and_never_leaves_the_picture():
"""A fixed amateur station that stops transmitting is still where it was.
It beacons every half hour, so the gaps are silence rather than absence,
and an evening's listening should fill a map rather than empty one.
"""
sky = Sky(hold=10.0, fade=10.0, fades=False)
ancient = Blip(icao="W1AW", latitude=47.0, longitude=-122.0,
last_seen=now() - 86_400.0)
sky.update([ancient], 1, 1)
assert sky.strength(ancient) == 1.0
assert [b.icao for b in sky.flying()] == ["W1AW"]
def test_an_accumulating_map_puts_the_most_recent_first():
"""Boxes are laid out in this order and a map that has been filling all
evening has more marks on it than it has room for boxes, so the ones
worth reading are the ones that just spoke."""
sky = Sky(fades=False)
sky.update([Blip(icao="OLD", latitude=47.0, longitude=-122.0,
last_seen=now() - 3_600.0),
Blip(icao="NEW", latitude=47.1, longitude=-122.1,
last_seen=now() - 5.0)], 2, 2)
assert [b.icao for b in sky.flying()] == ["NEW", "OLD"]
def test_a_mark_may_be_given_its_own_colour_instead_of_an_altitude():
"""There is nothing about a weather station that an altitude ramp says."""
from bandsaunter.flightmap import RAMP
assert Blip(icao="X").colour_index is None # an aeroplane
assert Blip(icao="X", colour_index=RAMP + 4).colour_index == RAMP + 4
def test_a_mark_may_be_given_its_own_box_contents():
rows = (("symbol", "weather station", ""), ("away", "5 km NNE", ""))
assert Blip(icao="X", details=rows).lines("kph") == list(rows)
def test_an_aircraft_with_no_details_still_builds_its_box_from_the_registers():
lines = Blip(icao="A835AF", callsign="UAL1", altitude_ft=35_000).lines("knots")
assert lines and any("UAL1" in str(row) or "35" in str(row)
for row in lines)
@qt
def test_every_shape_draws_something(app):
"""Three shapes: a triangle that points, a body with a stalk that also
points, and a diamond that does not, because a place has no front."""
from bandsaunter.livemap import _build
for shape in ("aircraft", "vehicle", "station"):
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape=shape, last_seen=now())], 1, 1)
view = _build()["SkyView"](sky)
painted = _painted(_rendered(view))
assert painted > 500, f"{shape} drew almost nothing"
@qt
def test_a_station_map_draws_the_stations_and_their_boxes(app):
from bandsaunter import aprs, packets
from bandsaunter.livemap import _build
net = aprs.Net()
when = now()
for source, info, at in [
("W1AW-1", "=4736.37N/12219.93W#Seattle wide digi", when - 74),
("KB1XYZ", "@092345z4735.40N/12216.20W_220/004g011t058h62b10132",
when - 22),
("N0ABC-7", "=4737.00N/12221.00Wb out for a ride", when - 3_600)]:
packet = packets.parse_info(info)
packet.source, packet.at, packet.snr = source, at, 28.0
net.add(packet)
home = (47.55, -122.30)
sky = Sky(unit="kph", home=home, radius_nm=40 / 1.852, fades=False,
rings=True, channel="144.39 MHz", subject="on the map",
counted="packets")
sky.started = when - 600
sky.update([aprs.blip_for(s, home) for s in net.all() if s.position],
148, len(net))
view = _build()["SkyView"](sky)
assert _painted(_rendered(view)) > 5_000
# Including the one heard an hour ago, which has not faded.
assert {b.icao for b in sky.flying()} == {"W1AW-1", "KB1XYZ", "N0ABC-7"}
@qt
def test_the_strip_along_the_top_says_what_it_is_looking_at(app):
"""It said 1090 MHz and "overhead" and "frames" whatever it was drawing."""
from bandsaunter.livemap import _build
sky = Sky(fades=False, channel="144.39 MHz", subject="on the map",
counted="packets", home=(47.55, -122.30))
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
last_seen=now())], 9, 1)
view = _build()["SkyView"](sky)
assert _painted(_rendered(view)) > 500
assert sky.channel == "144.39 MHz" and sky.counted == "packets"
def _has_colour(picture, rgb_wanted) -> bool:
"""Whether a given colour was actually painted.
Exactly, not nearly: a mark is filled at full alpha, so its own colour
appears unblended in the middle of it however the edges are softened.
"""
want = np.array(rgb_wanted, dtype=np.uint8)
# Rendered as BGRA, so the first three channels are blue, green, red.
body = picture[:, :, :3][:, :, ::-1]
return bool((body == want).all(axis=2).any())
@qt
def test_a_given_colour_is_the_one_that_reaches_the_pixels(app):
"""Not merely stored on the mark and then ignored in favour of an
altitude the mark has not got."""
from bandsaunter.flightmap import PALETTE, RAMP
from bandsaunter.livemap import _build
chosen = RAMP + 25
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
shape="station", colour_index=chosen,
last_seen=now())], 1, 1)
picture = _rendered(_build()["SkyView"](sky))
assert _has_colour(picture, tuple(int(v) for v in PALETTE[chosen]))
@qt
def test_two_sorts_of_station_are_not_painted_the_same_colour(app):
from bandsaunter.flightmap import PALETTE, RAMP
from bandsaunter.livemap import _build
seen = []
for index in (RAMP + 3, RAMP + 28):
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape="station", colour_index=index,
last_seen=now())], 1, 1)
seen.append(_rendered(_build()["SkyView"](sky)))
for picture, index in zip(seen, (RAMP + 3, RAMP + 28)):
assert _has_colour(picture, tuple(int(v) for v in PALETTE[index]))
def _around_the_mark(shape, track=0.0, span=15):
"""Just the pixels the symbol itself occupies.
Cropped, and cropped for a reason: the strip along the top carries a
running clock, so two renders taken a millisecond apart differ by a few
hundred pixels whatever is on the map. Comparing whole frames would
pass whatever the symbol did.
"""
from bandsaunter.livemap import _build
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape=shape, colour_index=20, track_deg=track,
last_seen=now())], 1, 1)
view = _build()["SkyView"](sky)
picture = _rendered(view, 400, 300)
where = view.projection()
x, y = where.xy(47.55, -122.30)
return picture[y - span:y + span, x - span:x + span, :3]
@qt
def test_a_place_is_not_drawn_with_the_same_outline_as_an_aeroplane(app):
"""A diamond has no front, which is the point of using one for a house
that beacons twice an hour: a triangle would have it pointing north for
no reason at all."""
plane = _around_the_mark("aircraft")
place = _around_the_mark("station")
assert plane.shape == place.shape and plane.size > 0
differing = int((plane != place).any(axis=2).sum())
assert differing > 20, f"only {differing} pixels differ between the two"
@qt
def test_something_moving_is_drawn_differently_again(app):
moving = _around_the_mark("vehicle", track=70.0)
place = _around_the_mark("station", track=70.0)
differing = int((moving != place).any(axis=2).sum())
assert differing > 20, f"only {differing} pixels differ between the two"
@qt
def test_a_symbol_that_points_turns_with_its_heading(app):
"""Which is what tells a mark that is going somewhere from one that is
not, and is the whole reason a moving station is not a diamond."""
north = _around_the_mark("vehicle", track=0.0)
east = _around_the_mark("vehicle", track=90.0)
assert int((north != east).any(axis=2).sum()) > 20
@qt
def test_an_empty_picture_says_what_it_is_waiting_for(app):
"""What has to arrive before a mark can be placed is a fact about the
signal, not about the window: an aeroplane needs two position frames of
opposite parity, an amateur station needs to have mentioned where it is,
and the window had the first of those written into it."""
from bandsaunter.livemap import _build
plane = Sky()
assert "1090 MHz" in plane.waiting and "position frame" in plane.waiting
station = Sky(channel="144.39 MHz", fades=False,
waiting="listening on 144.39 MHz\n\nnothing placed yet")
assert "1090" not in station.waiting
# And with nowhere to centre on, that is what gets drawn.
assert station.centre() is None
view = _build()["SkyView"](station)
assert _painted(_rendered(view)) > 200
@qt
def test_the_waiting_words_follow_the_channel_when_none_are_given(app):
assert "144.39 MHz" in Sky(channel="144.39 MHz").waiting
@qt
def test_the_empty_picture_draws_the_words_it_was_given(app):
"""Holding the sentence is not the same as painting it. The complaint
that started this was about what the window said while it waited, and a
window that stores one sentence and draws another is that fault exactly
-- so the test has to read the pixels, not the attribute."""
from bandsaunter.livemap import _build
def below_the_header(sky):
# Cropped past the header, whose clock ticks: two pictures taken a
# moment apart differ up there for reasons that have nothing to do
# with the words in the middle.
return _rendered(_build()["SkyView"](sky))[60:, :, :]
plane = below_the_header(Sky())
assert not (plane != below_the_header(Sky())).any(), "not steady"
station = below_the_header(Sky(
channel="144.39 MHz", fades=False,
waiting="listening on 144.39 MHz\n\nnothing placed yet — a station "
"appears\nonce it has said where it is"))
assert int((plane != station).any(axis=2).sum()) > 200
# ---------------------------------------------------------------------------
# Keeping up with the window
# ---------------------------------------------------------------------------
class _Quiet(_NoPainter):
"""A painter that lets the ground be drawn, once there is any.
_NoPainter refuses, on purpose, so that a test of "it asked and drew
nothing" cannot pass by drawing something. These tests are about what
happens once there *is* a map.
"""
def drawImage(self, *a):
pass
def _grown(sky, view, painter, width, height):
"""Resize, paint the ground, and say what was asked for."""
view.resize(width, height)
view._draw_ground(painter, view.projection())
return sky.wanted_ground()
@qt
def test_a_window_made_bigger_fetches_a_sharper_map(app):
"""A map fetched for a smaller window is still a map of the right piece
of world, so nothing else notices it is being stretched. Opening at the
default size and then going to the whole screen used to keep the first
map until an aircraft wandered far enough to move the view out of the
fetched box -- which on a quiet band is a long time to look at a blurred
coastline.
"""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
asked = _grown(sky, view, painter, 900, 650)
assert asked is not None
key, box, size = asked
first = size[0]
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
# Twice as wide: the map in hand now covers each of its pixels with two.
asked = _grown(sky, view, painter, 1800, 1300)
assert asked is not None, "it kept stretching the map it had"
assert asked[2][0] > first * 1.5, asked[2]
@qt
def test_a_window_nudged_a_few_pixels_does_not_refetch(app):
"""Dragging an edge must not send somebody's evening to a tile server a
hundred times."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
key, box, size = _grown(sky, view, painter, 1200, 850)
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
for extra in (1, 4, 20, 60):
sky._wanted = None
view.resize(1200 + extra, 850)
view._draw_ground(painter, view.projection())
assert sky.wanted_ground() is None, f"refetched over {extra} pixels"
@qt
def test_it_stops_asking_once_the_sharper_map_has_arrived(app):
"""The check is "am I being stretched", and a window larger than the
tile budget can cover is stretched by design -- so this had better be
answered by the request rather than by the result, or a 4K window asks
for a map for the rest of the evening."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
key, box, size = _grown(sky, view, painter, 3840, 2160)
# Answered with a map of the size asked for, which is what the fetching
# does however far the zoom had to be capped to get it.
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
for _ in range(5):
sky._wanted = None
view._draw_ground(painter, view.projection())
assert sky.wanted_ground() is None, "it is still asking"
@qt
def test_a_window_made_smaller_does_not_refetch(app):
"""Shrinking leaves the map finer than it needs to be, which costs
nothing and looks perfect."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
key, box, size = _grown(sky, view, painter, 1800, 1300)
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
sky._wanted = None
view.resize(900, 650)
view._draw_ground(painter, view.projection())
assert sky.wanted_ground() is None
@qt
def test_the_stretch_is_measured_in_map_pixels_per_degree(app):
"""Not in window pixels: the fetched box is wider than the view, so the
two numbers are not the same and comparing the wrong pair would either
never refetch or always."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(1000, 700)
projection = view.projection()
box = view.ground_box(projection)
across = box[3] - box[1]
shown = projection.east - projection.west
assert across > shown, "the fetched box is not wider than the view"
# A map at exactly the density the view wants is not stretched; one at
# half that is.
want = projection.width / shown
exact = np.zeros((10, int(round(want * across))), dtype=np.uint8)
assert not SkyView._stretched(exact, box, projection)
half = np.zeros((10, max(1, exact.shape[1] // 2)), dtype=np.uint8)
assert SkyView._stretched(half, box, projection)
# The case that tells the two ways of measuring apart, and the realistic
# one: a map with exactly as many pixels as the window is wide, spread
# over a box a quarter wider than the view. Only four fifths of those
# pixels land on the screen, so it *is* being stretched -- and counting
# raw pixels against raw pixels would call it fine.
same = np.zeros((10, projection.width), dtype=np.uint8)
assert SkyView._stretched(same, box, projection), (
"the stretch is being measured in window pixels rather than in map "
"pixels per degree")
@qt
def test_nothing_is_stretched_when_there_is_nothing_to_stretch(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
projection = view.projection()
assert not SkyView._stretched(None, (0.0, 0.0, 1.0, 1.0), projection)
assert not SkyView._stretched(np.zeros((4, 4), dtype=np.uint8), None,
projection)
# A box with no width at all cannot be divided by.
assert not SkyView._stretched(np.zeros((4, 4), dtype=np.uint8),
(1.0, 2.0, 1.0, 2.0), projection)
@qt
def test_the_window_opens_maximised(app):
"""This is a map, and the thing somebody wants more of is map. It used
to open at eleven hundred by eight hundred whatever the screen was."""
from bandsaunter.livemap import _build
window = _build()["Window"](a_sky(a_blip()), "test")
assert window.isMaximized()
assert not window.isFullScreen()
@qt
def test_un_maximising_gives_back_a_usable_window(app):
"""Set before maximising, so that there is a size to come back to: a
window maximised from nothing restores to whatever the toolkit guessed."""
from bandsaunter.livemap import _build
window = _build()["Window"](a_sky(a_blip()), "test")
restored = window.normalGeometry()
assert restored.width() >= 800 and restored.height() >= 600
@qt
def test_f_goes_to_true_full_screen_and_back_to_maximised(app):
"""Back to maximised rather than to the size it was built at: leaving
full screen should not shrink the map to a quarter of the screen."""
from bandsaunter.livemap import _build, _qt
_name, core, gui, _widgets, _signal = _qt()
window = _build()["Window"](a_sky(a_blip()), "test")
def press(letter):
window.keyPressEvent(gui.QKeyEvent(
core.QEvent.Type.KeyPress, 0,
core.Qt.KeyboardModifier.NoModifier, letter))
press("f")
assert window.isFullScreen() and not window.isMaximized()
press("f")
assert window.isMaximized() and not window.isFullScreen()
@qt
def test_the_keys_along_the_top_say_how_to_go_full_screen(app):
"""A window with no frame is one somebody has to know a key to get out
of, so the key is on the screen rather than only in the manual."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(1400, 700)
said = []
class _Spy:
"""Swallows everything a painter is asked to do, and keeps the text."""
def __getattr__(self, name):
return lambda *a, **k: None
def drawText(self, *a):
said.append(a[-1])
view._draw_header(_Spy(), 1)
assert any("f full" in line for line in said), said
@qt
def test_opening_maximised_fetches_a_map_for_the_size_it_opened_at(app):
"""The two halves of this belong together: a window that opens big is no
use if the map it fetches is for a window that opened small."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
# The small size a window used to open at, answered.
key, box, size = _grown(sky, view, painter, 1100, 800)
small = size[0]
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
# Now the size a maximised window on an ordinary screen has.
sky._wanted = None
asked = _grown(sky, view, painter, 1920, 1080)
assert asked is not None, "it opened big and kept the small map"
assert asked[2][0] > small

View file

@ -42,10 +42,13 @@ def test_every_setting_explains_itself_in_plain_words(page):
def test_the_commands_and_the_keys_are_documented(page):
for word in ("scan", "bands", "config", "transcribe", "devices",
"profiles", "analyze"):
"profiles", "analyze", "weather", "readings", "sensors",
"aprs", "packets"):
assert f".B {word}\n" in page, f"command {word} undocumented"
for section in ("SYNOPSIS", "DESCRIPTION", "COMMANDS", "OPTIONS",
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES"):
"SETTINGS", "FILES", "ENVIRONMENT", "EXAMPLES",
"AIRCRAFT OPTIONS", "WEATHER SENSORS", "WEATHER OPTIONS",
"APRS", "APRS OPTIONS"):
assert f".SH {section}" in page
@ -147,3 +150,33 @@ def test_the_manual_lists_every_aircraft_option(page):
assert any(flag in page for flag in flags), \
f"{option.key} ({', '.join(flags)}) is not in the manual"
assert option.key in page, f"{option.key} is not named in the manual"
def test_the_manual_lists_every_weather_option(page):
"""The same, for the other section, from the other table."""
from bandsaunter import weather as wx
for option in wx.OPTIONS:
flags = tuple(option.flags) + tuple(option.off_flags)
assert any(flag in page for flag in flags), \
f"{option.key} ({', '.join(flags)}) is not in the manual"
assert option.key in page, f"{option.key} is not named in the manual"
def test_the_manual_lists_every_aprs_option(page):
"""The same again, for the third section, from the third table."""
from bandsaunter import aprs as ap
for option in ap.OPTIONS:
flags = tuple(option.flags) + tuple(option.off_flags)
assert any(flag in page for flag in flags), \
f"{option.key} ({', '.join(flags)}) is not in the manual"
assert option.key in page, f"{option.key} is not named in the manual"
def test_the_manual_says_where_the_sensor_names_are_kept(page):
"""They are the only thing this program stores that somebody typed."""
assert "sensors.yaml" in page
assert "weather.yaml" in page
assert "weather_" in page
assert "aprs.yaml" in page and "aprs_" in page

365
tests/test_packets.py Normal file
View file

@ -0,0 +1,365 @@
"""The APRS information field: about twenty formats, and what must not happen.
Every format here has a writer beside its reader, so a position goes in and
the same position comes out. That proves the arithmetic and the framing and
proves nothing about anything the specification and this both get wrong --
which is worth saying, because the last section of this program shipped
unable to decode anything at all for exactly that reason.
The other half is about refusing to guess. A packet whose format does not
match what its first character promised comes back as unparsed, with its text
intact, rather than as a confident position a thousand miles from where the
station is.
"""
import math
import pytest
from bandsaunter import packets as p
# Every quadrant, the prime meridian, the equator, and the longitudes either
# side of the boundaries Mic-E re-maps to keep its bytes printable.
PLACES = [
(49.0583, -72.0292), (-33.8688, 151.2093), (51.5074, -0.1278),
(35.6762, 139.6503), (5.0, -5.0), (0.0, 0.0), (-45.0, -179.5),
(64.1466, -21.9426), (-1.2921, 36.8219), (47.55, -122.30),
]
# ---------------------------------------------------------------------------
# Positions
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("latitude,longitude", PLACES)
def test_an_uncompressed_position_comes_back_where_it_was_sent(latitude,
longitude):
info = p.position_report(latitude, longitude, "/>", "hello")
packet = p.parse_info(info)
assert packet.kind == "position"
assert packet.position.latitude == pytest.approx(latitude, abs=0.0002)
assert packet.position.longitude == pytest.approx(longitude, abs=0.0002)
assert packet.position.symbol == "car"
assert packet.comment == "hello"
@pytest.mark.parametrize("latitude,longitude", PLACES)
def test_a_compressed_position_comes_back_where_it_was_sent(latitude,
longitude):
packet = p.parse_info(p.compressed_report(latitude, longitude, "/>"))
assert packet.kind == "position"
assert packet.position.compressed
assert packet.position.latitude == pytest.approx(latitude, abs=0.0002)
assert packet.position.longitude == pytest.approx(longitude, abs=0.0002)
@pytest.mark.parametrize("latitude,longitude", PLACES)
def test_a_mic_e_position_comes_back_where_it_was_sent(latitude, longitude):
"""The awkward one: half of it lives in the destination callsign, and
the longitude is re-mapped in three ranges to keep the bytes printable."""
destination, info = p.mic_e_report(latitude, longitude, course=251,
speed=37.0, symbol="/j",
status="returning")
packet = p.parse_info(info, destination)
assert packet.kind == "position"
assert packet.position.latitude == pytest.approx(latitude, abs=0.0002)
assert packet.position.longitude == pytest.approx(longitude, abs=0.0002)
assert packet.course == 251
assert packet.speed == pytest.approx(37.0, abs=1.0)
assert packet.status == "returning"
assert packet.position.symbol == "jeep"
def test_the_compressed_position_from_the_specification_reads_correctly():
"""The example in the specification itself: 49 30.00 N, 72 45.00 W, with
a pre-computed radio range of 20.13 miles."""
packet = p.parse_info("!/5L!!<*e7>{?!")
assert packet.position.latitude == pytest.approx(49.5, abs=0.0001)
assert packet.position.longitude == pytest.approx(-72.75, abs=0.0001)
assert packet.range == pytest.approx(20.13 * 1.609344, rel=0.01)
@pytest.mark.parametrize("blanked,within_km", [(0, 0.0), (1, 0.34), (2, 3.4),
(3, 34.0), (4, 340.0)])
def test_a_station_that_blanks_its_minutes_is_not_drawn_as_a_point(blanked,
within_km):
"""Blanking the minute digits is a deliberate act by the operator.
Drawing a fuzzy position as a sharp one is a lie they specifically asked
not to be told, so the count is kept and turned into a distance.
"""
info = p.position_report(49.0583, -72.0292, "/-", ambiguity=blanked)
packet = p.parse_info(info)
assert packet.position.ambiguity == blanked
assert packet.position.uncertainty_km == within_km
@pytest.mark.parametrize("info", [
"=4760.37N/07201.75W-", # sixty minutes is not a minute
"=4903.50X/07201.75W-", # not a hemisphere
"=49 3.50N/07201.75W-", # a gap in the middle, not ambiguity
"=9903.50N/07201.75W-", # past the pole
"=4903.50N/19201.75W-", # past the antimeridian
])
def test_a_position_that_cannot_be_one_is_not_reported_as_a_position(info):
"""And in particular is not quietly re-read as a compressed position.
Thirteen characters of a malformed uncompressed position are perfectly
good base-91, so falling back does not fail -- it succeeds, as a
confident and completely different place.
"""
packet = p.parse_info(info)
assert packet.kind == "unparsed"
assert packet.position is None
assert packet.info == info # the text is kept regardless
def test_a_leading_digit_always_means_an_uncompressed_position():
"""Which is why the compressed format writes a numeric overlay as a
letter: so the two can never be confused by anything that reads the rule."""
overlaid = p.parse_info("!a5L!!<*e7> sT")
assert overlaid.position.table == "0" # the overlay, as a digit
assert overlaid.position.compressed
@pytest.mark.parametrize("timestamp", ["092345z", "092345/", "234500h"])
def test_a_position_with_a_timestamp_keeps_both(timestamp):
info = p.position_report(49.0583, -72.0292, "/-", timestamp=timestamp)
packet = p.parse_info(info, now=1_600_000_000.0)
assert packet.position is not None
assert packet.reported > 0.0
def test_a_timestamp_with_no_year_lands_on_the_nearest_side_of_today():
"""None of the four formats carries a year, so a packet stamped the
thirty-first heard on the first is from yesterday, not four weeks on."""
import calendar
now = calendar.timegm((2026, 3, 1, 0, 30, 0, 0, 0, 0))
when, _rest = p.timestamp_from("282345z", now)
assert now - when < 3 * 24 * 3600 # late February, not next year
assert when < now
# ---------------------------------------------------------------------------
# What rides in the comment
# ---------------------------------------------------------------------------
def test_a_course_and_speed_are_read_in_the_units_they_are_sent_in():
"""Knots here, which is not the same as the miles an hour a weather
report uses for wind, and the two differ by fifteen per cent."""
packet = p.parse_info(p.position_report(49.0, -72.0, "/>", course=88,
speed=66.672))
assert packet.course == 88
assert packet.speed == pytest.approx(66.672, abs=1.0)
def test_an_antenna_description_is_read_rather_than_shown_as_letters():
extra, rest = p.extensions_from("PHG5132Hi")
assert extra["power"] == 25 # watts, from a single digit
assert extra["height"] == pytest.approx(10.0 * 2 ** 1 * 0.3048, rel=0.01)
assert extra["gain"] == 3 and extra["beam"] == "E"
assert rest == "Hi"
def test_a_pre_computed_range_is_read():
extra, rest = p.extensions_from("RNG0050here")
assert extra["range"] == pytest.approx(50 * 1.609344, rel=0.01)
assert rest == "here"
def test_a_comment_that_is_only_a_comment_is_left_alone():
extra, rest = p.extensions_from("Hello there")
assert extra == {} and rest == "Hello there"
def test_an_altitude_is_pulled_out_of_wherever_in_the_comment_it_sits():
extra, rest = p.comment_from("climbing /A=012345 steadily")
assert extra["altitude"] == pytest.approx(12345 * 0.3048, rel=0.001)
assert "A=" not in rest and "climbing" in rest and "steadily" in rest
def test_an_altitude_is_shown_in_metres_and_not_rounded_to_a_kilometre():
"""Four hundred metres and the ground both read as "0 km", which is the
sort of rounding that makes a number worse than no number."""
assert p.height_text(376.0) == "376 m"
assert p.height_text(376.0, imperial=True) == "1,234 ft"
# ---------------------------------------------------------------------------
# Weather
# ---------------------------------------------------------------------------
def test_a_weather_report_with_a_position_reads_both():
info = "@092345z4903.50N/07201.75W_220/004g005t077r000p000P000h50b09900"
packet = p.parse_info(info)
assert packet.kind == "weather"
assert packet.position is not None
wx = packet.weather
assert wx["temperature"].value == pytest.approx(25.0, abs=0.1)
assert wx["humidity"].value == 50
assert wx["pressure"].value == pytest.approx(990.0, abs=0.1)
assert wx["wind from"].value == 220
assert wx["wind"].value == pytest.approx(4 * 1.852, abs=0.1)
def test_a_positionless_weather_report_reads_the_numbers():
packet = p.parse_info("_10090556c220s004g005t077r000p000P000h50b09900")
assert packet.kind == "weather" and packet.position is None
assert packet.weather["wind"].value == pytest.approx(4 * 1.609344, abs=0.1)
def test_the_letter_s_means_wind_speed_or_snow_depending_on_the_form():
"""The same letter, and the two differ by a factor of forty.
In a positionless report the wind arrives as "c" then "s"; in a position
report it arrives in the course-and-speed field, so a later "s" is
snowfall. Guessing is not an option.
"""
positionless = p.parse_info("_10090556c220s004g005t077")
assert "wind" in positionless.weather and "snow" not in positionless.weather
with_position = p.parse_info(
"@092345z4903.50N/07201.75W_220/004g005t077s010")
assert with_position.weather["snow"].value == pytest.approx(254.0, abs=1)
assert with_position.weather["wind"].value == pytest.approx(4 * 1.852,
abs=0.1)
def test_a_humidity_of_zero_means_a_hundred_per_cent():
packet = p.parse_info("_10090556c220s004g005t077h00")
assert packet.weather["humidity"].value == 100
def test_a_weather_report_comes_back_as_it_was_written():
info = p.weather_report(49.0583, -72.0292, timestamp="092345z",
wind_from=220, wind=7.4, gust=18.0,
temperature=25.0, humidity=50, pressure=990.0)
packet = p.parse_info(info)
assert packet.weather["temperature"].value == pytest.approx(25.0, abs=0.3)
assert packet.weather["humidity"].value == 50
assert packet.weather["pressure"].value == pytest.approx(990.0, abs=0.1)
# ---------------------------------------------------------------------------
# Messages, objects, items, status, telemetry
# ---------------------------------------------------------------------------
def test_a_message_reads_with_its_addressee_and_its_number():
packet = p.parse_info(p.message_text("KU0W", "on my way", number="42"))
assert packet.kind == "message"
assert packet.message.to == "KU0W"
assert packet.message.text == "on my way"
assert packet.message.number == "42"
@pytest.mark.parametrize("body,field,value", [
("ack42", "acknowledges", "42"),
("rej42", "rejects", "42"),
])
def test_an_acknowledgement_is_not_read_as_a_message_saying_ack(body, field,
value):
packet = p.parse_info(":W1AW :" + body)
assert getattr(packet.message, field) == value
assert packet.message.text == ""
def test_a_bulletin_is_addressed_to_everybody_and_says_so():
packet = p.parse_info(":BLN1 :Net tonight at eight")
assert packet.message.is_bulletin and packet.message.bulletin == "1"
assert "Net tonight" in packet.message.describe()
def test_an_object_carries_a_name_that_is_not_the_station_that_placed_it():
info = p.object_report("LEADER", 49.0583, -72.0292, "/>",
timestamp="092345z")
packet = p.parse_info(info)
assert packet.kind == "object"
assert packet.name == "LEADER"
assert packet.station == "LEADER" # the object, not the sender
assert packet.position.latitude == pytest.approx(49.0583, abs=0.0002)
assert packet.live
def test_an_object_can_be_killed():
info = p.object_report("LEADER", 49.0583, -72.0292, live=False)
assert p.parse_info(info).live is False
def test_an_item_is_an_object_without_a_timestamp():
packet = p.parse_info(p.item_report("AID", 49.0583, -72.0292))
assert packet.kind == "item" and packet.name == "AID"
assert packet.position is not None
def test_a_status_report_is_kept_as_what_the_operator_typed():
packet = p.parse_info(">Monitoring 146.52")
assert packet.kind == "status" and packet.status == "Monitoring 146.52"
def test_telemetry_reads_as_five_channels_and_eight_bits():
packet = p.parse_info(p.telemetry_report("005", [199, 0, 255, 73, 123],
"01101001"))
assert packet.kind == "telemetry"
assert packet.telemetry.analogue == (199.0, 0.0, 255.0, 73.0, 123.0)
assert packet.telemetry.digital == "01101001"
def test_a_relayed_packet_is_credited_to_whoever_originally_sent_it():
inner = p.position_report(49.0583, -72.0292, "/>")
packet = p.parse_info("}W1AW-5>APRS,TCPIP*:" + inner)
assert packet.source == "W1AW-5"
assert packet.position is not None
assert "relayed" in packet.comment
# ---------------------------------------------------------------------------
# Refusing to guess
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("info", [
"", "x", "!", "=short", ":notamessage", ";tooshort", "T#", ">",
"@notatimestamp", "}", "_", "`", "'",
])
def test_a_packet_that_cannot_be_read_keeps_its_text_and_says_so(info):
packet = p.parse_info(info)
assert packet.position is None
assert packet.info == info
assert packet.kind in p.KINDS
def test_nothing_is_read_out_of_random_text():
import random
rng = random.Random(4)
alphabet = "".join(chr(c) for c in range(32, 127))
placed = 0
for _ in range(4000):
info = "".join(rng.choice(alphabet) for _ in range(rng.randint(5, 60)))
packet = p.parse_info(info)
if packet.position is not None:
placed += 1
# Some random text really is a valid compressed position -- thirteen
# printable characters is all one takes -- so the bar is a rate. What
# reaches this off the air has also had to satisfy a sixteen-bit CRC.
assert placed <= 120, f"{placed} of 4000 random strings became a place"
# ---------------------------------------------------------------------------
# Symbols
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("table,code,name", [
("/", ">", "car"), ("/", "_", "weather station"), ("/", "#", "digipeater"),
("/", "O", "balloon"), ("\\", "s", "boat"), ("/", "[", "person"),
])
def test_a_station_is_described_by_what_it_draws_itself_as(table, code, name):
assert p.symbol_name(table, code) == name
def test_an_overlaid_symbol_says_which_character_is_over_it():
assert "(T)" in p.symbol_name("T", "#")
def test_a_symbol_nobody_named_is_reported_by_its_characters():
assert p.symbol_name("/", "\x01") == "symbol /\x01"

412
tests/test_register.py Normal file
View file

@ -0,0 +1,412 @@
"""The register: everything ever looked up, read back and made browsable.
Nothing here touches a network or a receiver. Both caches are written by
the tests and read by the code, which is the whole reason the reading lives
in a module of its own.
"""
import json
import tempfile
import time
from pathlib import Path
import pytest
from rich.console import Console
from bandsaunter import register as reg
from bandsaunter.browse import Browser
def write_callsigns(path: Path, entries) -> Path:
body = {}
for one in entries:
row = {"call": one["call"], "status": one.get("status", "found"),
"fetched_at": one.get("fetched_at", 1_700_000_000.0),
"version": 4}
row.update({k: v for k, v in one.items() if k != "call"})
body[one["call"]] = row
path.write_text(json.dumps(body))
return path
def write_aircraft(path: Path, planes, routes=None) -> Path:
path.write_text(json.dumps({"aircraft": {p["icao"]: p for p in planes},
"routes": routes or {},
"airports": {}}))
return path
SOME = [
{"call": "AB7IC", "name": "Thomas G Britton", "location": "Tucson, AZ",
"street": "8410 E Brookwood Dr", "postcode": "85750-2468",
"country": "United States", "district": "district 7",
"grid": "DM42og", "oper_class": "EXTRA", "licence_type": "PERSON",
"expires": "12/21/2033", "latitude": 32.2750431,
"longitude": -110.8131236, "source": "callook.info"},
{"call": "VK4BLE", "country": "Australia", "status": "unlisted"},
{"call": "WQVF960", "country": "United States", "status": "service"},
]
# ---------------------------------------------------------------------------
# Reading the caches
# ---------------------------------------------------------------------------
def test_a_callsign_comes_back_with_everything_that_was_known(tmp_path):
where = write_callsigns(tmp_path / "c.json", SOME)
entries = reg.load_callsigns(where)
got = {e.title: e for e in entries}
one = got["AB7IC"]
assert one.what == "Thomas G Britton"
assert one.where == "Tucson, AZ"
assert one.position == pytest.approx((32.2750431, -110.8131236))
shown = dict(one.rows)
for label in ("licensed to", "class", "licence", "street", "town",
"postcode", "country", "district", "grid", "expires",
"position", "status", "answered by", "looked up"):
assert label in shown, label
assert shown["street"] == "8410 E Brookwood Dr"
assert shown["answered by"] == "callook.info"
def test_a_callsign_nobody_could_place_is_kept_rather_than_dropped(tmp_path):
""""Asked about and in no register" is a fact about a station. A list
that quietly dropped them would make an evening of unlisted foreign
stations look like an evening when nothing was looked up."""
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
assert {e.title for e in entries} == {"AB7IC", "VK4BLE", "WQVF960"}
vk = next(e for e in entries if e.title == "VK4BLE")
assert vk.position is None and not vk.mappable
assert dict(vk.rows)["status"] == \
"asked about, in no register reachable from here"
def test_a_status_says_what_it_means_rather_than_what_it_is_called(tmp_path):
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
service = next(e for e in entries if e.title == "WQVF960")
assert "not an amateur callsign" in dict(service.rows)["status"]
def test_a_position_taken_from_the_grid_says_so(tmp_path):
"""A grid square is a few kilometres across and a licensed address is a
street, so which one a coordinate came from matters."""
where = write_callsigns(tmp_path / "c.json", [
{"call": "G4ABC", "grid": "IO91", "latitude": 51.5,
"longitude": -1.0, "from_grid": True}])
one = reg.load_callsigns(where)[0]
assert "from the grid square" in dict(one.rows)["position"]
def test_an_aircraft_comes_back_with_its_route(tmp_path):
"""The route is filed against the flight number and the airframe against
the address, so the two halves have to be joined."""
where = write_aircraft(
tmp_path / "f.json",
[{"icao": "4CA2D3", "registration": "EI-DYM", "callsign": "RYR1234",
"manufacturer": "Boeing", "model": "737-8AS", "type_code": "B738",
"operator": "Ryanair", "country": "Ireland", "status": "found",
"fetched_at": 1_700_000_000.0}],
{"RYR1234": {"origin": "Dublin", "origin_code": "EIDW",
"origin_lat": 53.4213, "origin_lon": -6.2701,
"destination": "Stansted", "destination_code": "EGSS",
"route_source": "a schedule service"}})
one = reg.load_aircraft(where)[0]
assert one.title == "EI-DYM"
assert one.what == "Boeing 737-8AS"
assert one.where == "EIDW → EGSS"
shown = dict(one.rows)
assert shown["from"] == "Dublin (EIDW)"
assert shown["to"] == "Stansted (EGSS)"
assert shown["route from"] == "a schedule service"
assert shown["ICAO address"] == "4CA2D3"
assert one.position == pytest.approx((53.4213, -6.2701))
def test_an_aircraft_with_no_registration_is_shown_by_its_address(tmp_path):
where = write_aircraft(tmp_path / "f.json",
[{"icao": "ABCDEF", "status": "unlisted"}])
assert reg.load_aircraft(where)[0].title == "ABCDEF"
def test_a_cache_that_is_not_there_is_no_entries_rather_than_an_error(tmp_path):
assert reg.load_callsigns(tmp_path / "nope.json") == []
assert reg.load_aircraft(tmp_path / "nope.json") == []
def test_a_cache_full_of_rubbish_is_no_entries_rather_than_an_error(tmp_path):
bad = tmp_path / "c.json"
bad.write_text("{ not json at all")
assert reg.load_callsigns(bad) == []
bad.write_text("[1, 2, 3]")
assert reg.load_callsigns(bad) == []
def test_the_two_caches_are_the_ones_everything_else_writes():
from bandsaunter.callsign import _cache_path as calls
from bandsaunter.flights import _cache_path as planes
where = reg.cache_paths()
assert where["callsigns"] == calls()
assert where["aircraft"] == planes()
# ---------------------------------------------------------------------------
# Maps
# ---------------------------------------------------------------------------
def test_a_map_link_points_at_the_coordinates_and_says_nothing_else(tmp_path):
"""The one part of this that leaves the machine, so it carries as little
as it can: a map does not need to be told whose licence it is looking
at in order to show a place."""
one = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))[0]
url = one.maps()
assert url.startswith("https://www.google.com/maps/")
assert "32.275043" in url and "-110.813124" in url
assert "AB7IC" not in url
assert "Britton" not in url and "Brookwood" not in url
def test_nothing_to_point_at_is_no_link_rather_than_a_wrong_one(tmp_path):
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
vk = next(e for e in entries if e.title == "VK4BLE")
assert vk.maps() == "" and reg.maps_url(None) == ""
# ---------------------------------------------------------------------------
# Searching
# ---------------------------------------------------------------------------
def test_searching_looks_at_every_field_and_not_only_the_name(tmp_path):
"""The interesting question is usually not "which callsign" but "who was
in Arizona", and that lives in the detail."""
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
assert [e.title for e in reg.search(entries, "tucson")] == ["AB7IC"]
assert [e.title for e in reg.search(entries, "brookwood")] == ["AB7IC"]
assert [e.title for e in reg.search(entries, "australia")] == ["VK4BLE"]
assert [e.title for e in reg.search(entries, "EXTRA")] == ["AB7IC"]
def test_every_word_has_to_match_so_a_search_can_be_narrowed(tmp_path):
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
assert [e.title for e in reg.search(entries, "united states")] != []
assert reg.search(entries, "tucson australia") == []
def test_an_empty_search_is_everything(tmp_path):
entries = reg.load_callsigns(write_callsigns(tmp_path / "c.json", SOME))
assert len(reg.search(entries, "")) == len(entries)
assert len(reg.search(entries, " ")) == len(entries)
# ---------------------------------------------------------------------------
# Browsing it
# ---------------------------------------------------------------------------
@pytest.fixture
def browser(tmp_path, monkeypatch):
"""A browser whose register reads caches the test wrote."""
calls = write_callsigns(tmp_path / "callsigns.json", SOME)
planes = write_aircraft(tmp_path / "flights.json", [
{"icao": "4CA2D3", "registration": "EI-DYM", "manufacturer": "Boeing",
"model": "737-8AS", "status": "found", "fetched_at": 1.7e9}])
monkeypatch.setattr(reg, "cache_paths",
lambda: {"callsigns": calls, "aircraft": planes})
console = Console(width=116, height=30, force_terminal=False)
return Browser(tmp_path, console=console)
def _screen(browser) -> str:
with browser.console.capture() as caught:
browser.console.print(browser.render())
return caught.get()
def test_c_opens_the_register_and_closes_it_again(browser):
assert not browser.showing_register
browser.handle("c")
assert browser.showing_register
assert "62" not in browser.message # it read the test's caches
assert "3 callsigns" in browser.message
browser.handle("c")
assert not browser.showing_register
browser.handle("c")
browser.handle("escape")
assert not browser.showing_register
def test_the_register_shows_what_is_in_it(browser):
browser.handle("c")
shown = _screen(browser)
assert "the register" in shown
assert "AB7IC" in shown and "Thomas G Britton" in shown
assert "Tucson, AZ" in shown
# A date, because when something was looked up is part of the record.
assert "2023-" in shown or "2026-" in shown or "2020-" in shown
def test_the_detail_shows_every_field_for_the_current_one(browser):
browser.handle("c")
browser.reg_index = 0
shown = _screen(browser)
for bit in ("8410 E Brookwood Dr", "85750-2468", "DM42og", "EXTRA",
"12/21/2033", "callook.info"):
assert bit in shown, bit
def test_tab_switches_between_callsigns_and_aircraft(browser):
browser.handle("c")
assert browser.reg_kind == "callsigns"
browser.handle("tab")
assert browser.reg_kind == "aircraft"
shown = _screen(browser)
assert "EI-DYM" in shown and "Boeing 737-8AS" in shown
browser.handle("tab")
assert browser.reg_kind == "callsigns"
def test_moving_about_stays_inside_the_list(browser):
browser.handle("c")
for _ in range(20):
browser.handle("down")
_screen(browser) # the panel clamps as it draws
assert browser.reg_index == 2 # three entries
for _ in range(20):
browser.handle("up")
assert browser.reg_index == 0
browser.handle("end")
_screen(browser)
assert browser.reg_index == 2
browser.handle("home")
assert browser.reg_index == 0
def test_searching_in_the_register_narrows_the_list(browser):
browser.handle("c")
browser.handle("/")
for ch in "tucson":
browser.handle(ch)
assert browser.reg_query == "tucson"
browser.handle("enter")
shown = _screen(browser)
assert "AB7IC" in shown and "VK4BLE" not in shown
assert "matching" in shown
browser.handle("/")
browser.handle("escape")
assert browser.reg_query == ""
def test_backspace_takes_a_letter_off_a_search(browser):
browser.handle("c")
browser.handle("/")
for ch in "tucsonx":
browser.handle(ch)
browser.handle("backspace")
assert browser.reg_query == "tucson"
def test_g_opens_the_place_in_a_browser(browser, monkeypatch):
import webbrowser
asked = []
monkeypatch.setattr(webbrowser, "open",
lambda url: asked.append(url) or True)
browser.handle("c")
browser.reg_index = 0
browser.handle("g")
assert len(asked) == 1
assert "32.275043" in asked[0] and "maps" in asked[0]
assert "opened AB7IC" in browser.message
def test_g_on_something_with_no_position_says_so_rather_than_guessing(
browser, monkeypatch):
import webbrowser
asked = []
monkeypatch.setattr(webbrowser, "open",
lambda url: asked.append(url) or True)
browser.handle("c")
browser.handle("/")
for ch in "australia":
browser.handle(ch)
browser.handle("enter")
browser.handle("g")
assert asked == []
assert "where this one is" in browser.message
def test_a_machine_with_no_browser_prints_the_place_instead(browser,
monkeypatch):
"""A headless box is the normal case for a receiver, and the coordinates
are still the answer somebody wanted."""
import webbrowser
monkeypatch.setattr(webbrowser, "open", lambda url: False)
browser.handle("c")
browser.reg_index = 0
browser.handle("g")
assert "no browser to open" in browser.message
assert "32.275043" in browser.message
def test_a_browser_that_will_not_start_is_reported_rather_than_raising(
browser, monkeypatch):
import webbrowser
def boom(url):
raise RuntimeError("no display")
monkeypatch.setattr(webbrowser, "open", boom)
browser.handle("c")
browser.reg_index = 0
assert browser.handle("g") is True
assert "could not open a browser" in browser.message
def test_q_still_quits_from_the_register(browser):
browser.handle("c")
assert browser.handle("q") is False
def test_the_register_is_read_once_rather_than_on_every_frame(browser,
monkeypatch):
reads = []
real = reg.load_all
monkeypatch.setattr(reg, "load_all",
lambda *a, **k: reads.append(1) or real(*a, **k))
browser.handle("c")
for _ in range(5):
_screen(browser)
browser.handle("down")
assert len(reads) == 1
# And r asks again, for somebody who has just finished a scan.
browser.handle("r")
assert len(reads) == 2
assert "read the caches again" in browser.message
def test_the_keys_page_says_the_register_is_there(browser):
browser.handle("?")
shown = _screen(browser)
assert "every callsign and aircraft ever looked up" in shown
# The register's own keys are named on the register's own screen, the
# keys page being exactly as tall as an eighty-by-twenty-four terminal.
browser.handle("?") # close the keys page
browser.handle("c")
browser.message = "" # the footer shows counts first
assert "g map" in _screen(browser)
assert "tab switch" in _screen(browser)
def test_an_empty_register_says_so_rather_than_drawing_a_blank(tmp_path,
monkeypatch):
monkeypatch.setattr(reg, "cache_paths",
lambda: {"callsigns": tmp_path / "none.json",
"aircraft": tmp_path / "none.json"})
console = Console(width=100, height=24, force_terminal=False)
browser = Browser(tmp_path, console=console)
browser.handle("c")
shown = _screen(browser)
assert "nothing here" in shown
assert browser.reg_current is None
# And g on nothing at all does not fall over.
assert browser.handle("g") is True

View file

@ -240,8 +240,10 @@ def test_random_bits_behind_a_real_preamble_are_refused():
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("sensor,celsius,humidity,channel", [
# A, B and C are the three positions of the switch on the back of the
# sensor; there is no D, whatever the letters on a scanner display say.
(0x1234, 21.5, 48, "A"), (0x0001, -20.0, 5, "C"), (0x3FFF, 45.3, 100, "B"),
(0x2AAA, 0.0, 50, "D"), (0x0555, -39.9, 1, "A"),
(0x2AAA, 0.0, 50, "C"), (0x0555, -39.9, 1, "A"),
])
def test_a_sensor_reading_comes_back_as_it_was_sent(sensor, celsius,
humidity, channel):
@ -262,7 +264,7 @@ def test_a_flat_battery_is_reported_and_a_good_one_is_not():
def test_a_message_is_found_wherever_in_the_burst_it_starts():
bits = "10110" + acurite_frame(0x0ABC, 12.3, 77, "B") + "0011"
bits = "1011" + acurite_frame(0x0ABC, 12.3, 77, "B") + "0011"
assert decode_acurite(bits).identifier == "0ABC"

239
tests/test_splash.py Normal file
View file

@ -0,0 +1,239 @@
"""The title screen.
Most of what matters here is when it does *not* appear. Everything in this
program can be piped into something else, and a banner in the middle of a
table somebody is parsing is corruption rather than decoration.
"""
from rich.console import Console
from bandsaunter import splash
def _plain(console: Console, drawing) -> str:
with console.capture() as caught:
drawing()
return caught.get()
# ---------------------------------------------------------------------------
# The drawing
# ---------------------------------------------------------------------------
def test_both_names_come_out_as_four_rows_of_braille():
for word in (splash.SCANNER_NAME, splash.BROWSER_NAME):
rows = splash.banner(word)
assert len(rows) == splash.ROWS
assert all(all(0x2800 <= ord(c) <= 0x28FF for c in row)
for row in rows), "something in there is not braille"
assert any(c != "\u2800" for row in rows for c in row)
def test_the_names_are_written_the_way_they_are_spelled():
"""Not shouted. Braille gives four times the vertical detail a block
does, which is what makes room for two heights of letter at once."""
assert splash.SCANNER_NAME == "BandSaunter"
assert splash.BROWSER_NAME == "SaunterBrowse"
assert set("BS") <= set(splash.CAPS)
assert "a" in splash.LOWER and "d" in splash.ASCENDERS
def test_a_capital_stands_taller_than_the_letter_beside_it():
"""The point of the whole exercise: if both were drawn the same height
the name would read as BANDSAUNTER, which is not its name."""
def top_of(word):
drawn = splash.pixels(word)
return next(i for i, row in enumerate(drawn) if row.strip())
assert top_of("B") < top_of("a"), "the capital does not rise"
assert top_of("d") == top_of("B"), "the ascender does not reach"
assert top_of("t") < top_of("a"), "the t does not rise at all"
assert top_of("o") == top_of("a"), "two x-height letters disagree"
def test_every_letter_sits_on_the_same_baseline():
def bottom_of(word):
drawn = splash.pixels(word)
return max(i for i, row in enumerate(drawn) if row.strip())
heights = {ch: bottom_of(ch) for ch in "BSandutersow"}
assert len(set(heights.values())) == 1, heights
def test_both_names_fit_an_eighty_column_terminal():
"""Which is the narrowest terminal anybody still has, and the width a
banner has to be cut to rather than wrapped at."""
for word in (splash.SCANNER_NAME, splash.BROWSER_NAME):
assert max(len(r) for r in splash.banner(word)) <= 78, word
def test_every_letter_the_two_names_need_has_a_glyph():
"""A missing one would silently shorten the name rather than fail."""
for word in (splash.SCANNER_NAME, splash.BROWSER_NAME):
for letter in word:
assert letter in splash.FONT, letter
def test_a_stroke_is_two_dots_thick_in_both_directions():
"""One dot thick reads as a dotted line rather than a line, which is
the one thing that makes drawing in braille difficult. Doubling the
small font is what avoids it."""
doubled = splash._double(("#.",))
assert doubled == ["##..", "##.."]
def test_a_letter_with_no_glyph_is_left_out_rather_than_drawn_as_a_hole():
assert splash.banner("Band!!") == splash.banner("Band")
assert splash.banner("!!!") == []
assert splash.banner("") == []
# And an uppercase name is no longer the same as a lowercase one, which
# is the whole change: the font has two heights now.
assert splash.banner("Band") != splash.banner("BAND")
def test_the_gradient_is_cold_the_whole_way_along():
"""Digital blues, cyans and white, and nothing warm anywhere.
Checked as an invariant rather than by naming the five stops: blue is
never the weakest channel and red is never the strongest, which is what
"blue through cyan to white" *means* and what any warm colour slipping
back in would break. This is deliberately not the waterfall ramp the
rest of the program draws in -- that one has to run to red because it
stands in for a spectrum.
"""
for step in range(0, 101):
style = splash._colour(step / 100)
red, green, blue = (int(v) for v in style[4:-1].split(","))
assert blue >= green >= red, (step, style)
assert blue >= 120, f"too dark to read at {step}: {style}"
def test_the_gradient_ends_pale_and_starts_deep():
"""A gradient that did not travel would be a colour."""
start = [int(v) for v in splash._colour(0.0)[4:-1].split(",")]
end = [int(v) for v in splash._colour(1.0)[4:-1].split(",")]
assert sum(end) > sum(start) * 2, "it barely brightens"
assert start[2] >= 120 and end[1] >= 200
def test_the_gradient_runs_from_one_end_of_the_ramp_to_the_other():
first, last = splash._colour(0.0), splash._colour(1.0)
assert first != last
assert first.startswith("rgb(") and last.startswith("rgb(")
# And it is clamped rather than extrapolated off either end.
assert splash._colour(-5.0) == first
assert splash._colour(5.0) == last
def test_the_lines_under_it_are_drawn_in_the_order_they_were_given():
console = Console(width=100, force_terminal=False)
shown = _plain(console, lambda: splash.show(
console, splash.SCANNER_NAME,
("Conceived by: The Dust Council", "100% AI Coded by Claude Code."),
force=True))
assert "Conceived by: The Dust Council" in shown
assert "100% AI Coded by Claude Code." in shown
assert shown.index("Conceived") < shown.index("100%")
# ---------------------------------------------------------------------------
# When it stays out of the way
# ---------------------------------------------------------------------------
def test_nothing_is_drawn_when_the_output_is_not_a_terminal():
"""The one that matters. A table piped into something else must come
out as a table."""
console = Console(width=100, force_terminal=False)
shown = _plain(console, lambda: splash.show(console, splash.SCANNER_NAME,
("a", "b")))
assert shown == ""
assert splash.wanted(console) is False
def test_no_splash_turns_it_off_on_a_terminal_too():
console = Console(width=100, force_terminal=True)
assert splash.wanted(console) is True
assert splash.wanted(console, no_splash=True) is False
shown = _plain(console, lambda: splash.show(console, splash.SCANNER_NAME,
("a",), no_splash=True))
assert shown == ""
def test_the_environment_can_turn_it_off_for_good(monkeypatch):
"""For anybody who would rather it did not, without having to remember a
flag on every invocation."""
console = Console(width=100, force_terminal=True)
monkeypatch.setenv("BANDSAUNTER_NO_SPLASH", "1")
assert splash.wanted(console) is False
shown = _plain(console, lambda: splash.show(console, splash.SCANNER_NAME,
("a",)))
assert shown == ""
def test_it_says_whether_it_drew_anything():
console = Console(width=100, force_terminal=False)
assert splash.show(console, splash.SCANNER_NAME, ("a",)) is False
assert splash.show(console, splash.SCANNER_NAME, ("a",), force=True) is True
assert splash.show(console, "!!!", ("a",), force=True) is False
def test_nothing_sleeps_when_nothing_is_drawn(monkeypatch):
"""The pause is for a program about to take the screen over. Paying it
with no banner drawn would be a tenth of a second added to every piped
command for nothing."""
import time
slept = []
monkeypatch.setattr(time, "sleep", lambda s: slept.append(s))
console = Console(width=100, force_terminal=False)
splash.show(console, splash.BROWSER_NAME, ("a",), pause=5.0)
assert slept == []
splash.show(console, splash.BROWSER_NAME, ("a",), pause=0.25, force=True)
assert slept == [0.25]
# ---------------------------------------------------------------------------
# Where it is wired in
# ---------------------------------------------------------------------------
def test_both_programs_can_be_told_not_to_draw_one():
from bandsaunter.browse import build_parser as browse_parser
from bandsaunter.cli import build_parser as cli_parser
assert cli_parser().parse_args(["--no-splash", "devices"]).no_splash
assert not cli_parser().parse_args(["devices"]).no_splash
assert browse_parser().parse_args(["--no-splash"]).no_splash
assert not browse_parser().parse_args([]).no_splash
def test_the_scanner_draws_its_own_name(monkeypatch):
drawn = []
monkeypatch.setattr(splash, "show",
lambda console, word, lines=(), **kw:
drawn.append((word, tuple(lines))) or False)
from bandsaunter import cli
cli.main(["devices"])
assert drawn and drawn[0][0] == splash.SCANNER_NAME
lines = drawn[0][1]
assert lines[0] == "Conceived by: The Dust Council"
assert lines[1] == "100% AI Coded by Claude Code."
def test_the_browser_draws_its_own_name_and_waits_a_moment(monkeypatch,
tmp_path):
drawn = []
def fake(console, word, lines=(), **kw):
drawn.append((word, tuple(lines), kw.get("pause", 0.0)))
return False
monkeypatch.setattr(splash, "show", fake)
from bandsaunter import browse
# Straight back out again: the banner is drawn before anything else.
browse.main([str(tmp_path), "--callsigns"])
assert drawn and drawn[0][0] == splash.BROWSER_NAME
assert drawn[0][1][0] == "Conceived by: The Dust Council"
assert drawn[0][1][1] == "100% AI Coded by Claude Code."
# The browser runs on the alternate screen, so the banner needs a beat.
assert drawn[0][2] > 0

1790
tests/test_weather.py Normal file

File diff suppressed because it is too large Load diff