Cover every option in the help, the manual and the readme, and say what to install

An audit rather than a feature, prompted by wanting this fit to hand to
somebody else.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-06 14:12:33 -07:00
parent 6d2436cde1
commit 8ed01f991f
16 changed files with 1012 additions and 46 deletions

3
.gitignore vendored
View file

@ -16,3 +16,6 @@ recordings/
# local environments
.venv/
venv/
# a personal helper, not part of the program
resume.sh

View file

@ -59,6 +59,13 @@ model into a small apt repository under `dist/repo`, so that `apt install
bandsaunter` on every other machine brings transcription with it. See
**Install → From your own apt repository** in the README.
It needs two more tools than `build-deb.sh` does, neither of which is on a
minimal system:
```bash
sudo apt install dpkg-dev apt-utils # dpkg-scanpackages and apt-ftparchive
```
---
## B. Anywhere else: a virtual environment
@ -80,8 +87,10 @@ python3 -m venv .venv
pip install . # a few wheels; under a minute
```
That installs four wheels — numpy, scipy, rich, PyYAML — and puts two commands
on the path, `bandsaunter` and `saunterbrowse`. Nothing is built from source.
That installs seven wheels — numpy, scipy, rich and PyYAML, plus the three
Rich brings with it (Pygments, markdown-it-py, mdurl) — and puts two commands
on the path, `bandsaunter` and `saunterbrowse`. Nothing is built from source,
and it takes under a minute.
Use `pip install -e .` instead if you intend to change the code.
@ -123,21 +132,72 @@ if not.
---
## Optional dependencies
## Every dependency, in one table
Everything below is genuinely optional. The program starts, scans, records,
identifies, decodes Morse and draws waterfalls with none of it, and says
plainly when a feature is unavailable rather than failing.
**Required.** Four Python packages and one system library. The `.deb` and
`pip install` both pull the Python ones in; the system library is the only
thing either way that has to come from your distribution.
| | Debian/Ubuntu/Mint | Fedora | Arch | what needs it |
|---|---|---|---|---|
| Python 3.10+ | `python3` | `python3` | `python` | everything |
| NumPy | `python3-numpy` | `python3-numpy` | `python-numpy` | every signal path, every picture |
| SciPy | `python3-scipy` | `python3-scipy` | `python-scipy` | filtering, demodulation, classifying |
| Rich | `python3-rich` | `python3-rich` | `python-rich` | the menus and the live display |
| PyYAML | `python3-yaml` | `python3-pyyaml` | `python-yaml` | the settings file |
| **librtlsdr** | `librtlsdr0` | `rtl-sdr` | `rtl-sdr` | **talking to the dongle** |
**`librtlsdr` is the one manual dependency that matters.** It is a C library,
not a Python package, so `pip` cannot install it and no virtual environment
brings it with it. bandsaunter opens it with `ctypes` at runtime, trying
`librtlsdr.so.2`, `.so.0`, `.so`, `librtlsdr.dylib`, `rtlsdr.dll` and
`librtlsdr.dll` in that order.
Without it everything that does not touch the hardware still works — reading
logs, drawing maps, decoding recordings, the simulated sky — and anything that
does says so plainly instead of failing:
```sh
sudo apt install librtlsdr0 # Debian, Ubuntu, Mint
sudo dnf install rtl-sdr # Fedora
sudo pacman -S rtl-sdr # Arch
brew install librtlsdr # macOS
```
**Optional.** Everything below is genuinely optional. The program starts,
scans, records, identifies, decodes Morse, draws waterfalls and maps with none
of it, and says plainly when a feature is unavailable rather than failing.
| Install | Gives you | Without it |
|---|---|---|
| `sudo apt install espeak-ng` | clearer spoken timestamps on combined recordings, rendered about three times faster | a built-in formant synthesiser does the same job, less clearly |
| `sudo apt install python3-pyqt6` | the realtime aircraft window (`bandsaunter adsb --window`) | the terminal board still shows every aircraft, and the maps are still drawn afterwards |
| `sudo apt install ffmpeg` | `bandsaunter flights --out sky.mp4` writes a video | a GIF is written instead, and it says so |
| `sudo apt install rtl-sdr` | `rtl_test`, `rtl_sdr` and friends, for diagnosing hardware | nothing missing from bandsaunter itself |
| `sudo apt install espeak-ng` | clearer spoken timestamps on combined recordings, rendered about three times faster | a built-in formant synthesiser does the same job, less clearly |
| `sudo apt install rtl-sdr` | `rtl_test`, `rtl_sdr` and friends, for diagnosing the hardware | nothing missing from bandsaunter itself |
| `pip install faster-whisper` | speech transcription of recorded voice — see [the step-by-step below](#speech-transcription-step-by-step) (~250 MB installed, plus a 148 MB model) | transcription is off; scans report that no recogniser is installed |
| `pip install vosk` | a smaller, weaker recogniser (~10 MB plus a 40 MB model) | as above |
| `sudo apt install python3-pyqt6` | the realtime aircraft window (`bandsaunter adsb --window`) | the terminal board still shows every aircraft, and the maps are still drawn afterwards |
| `pip install openai-whisper` | the reference Whisper, slower and heavier than faster-whisper | as above |
| `pip install pocketsphinx` | a tiny recogniser, poor on radio audio | as above |
| whisper.cpp (`whisper-cli` on the PATH) | transcription with no Python dependencies at all | as above |
| `pip install pyte` | the terminal-resize tests | those tests skip |
| `pip install pytest` | running the test suite | you cannot run the tests |
`bandsaunter transcribe --list` says which recognisers it can actually see,
and which one it would use.
**Nothing is downloaded behind your back.** The things that reach a network do
so only when you ask, and each is cached on disk afterwards:
| What | Where from | When | Kept in |
|---|---|---|---|
| aircraft and route lookups | `api.adsbdb.com`, `hexdb.io` | while listening, unless `--no-lookup` | `~/.cache/bandsaunter/flights.json`, a month |
| amateur callsign lookups | `callook.info`, `api.hamdb.org` | when a scan hears a callsign, unless turned off | `~/.cache/bandsaunter/callsigns.json`, a month |
| map tiles | `tile.openstreetmap.org`, or `--tiles URL` | when a map is drawn, unless `--no-basemap` | `~/.cache/bandsaunter/tiles`, for ever |
| aerodrome positions | `overpass-api.de` | when a map is drawn, unless `--no-airports` | `~/.cache/bandsaunter/airports`, a month |
| flight schedules | the four paid services below | only if you have set a key | a month |
Every request identifies itself as `bandsaunter` and carries nothing but the
question — a callsign, a 24-bit address, or a box of the world. No identity,
no position, no key.
**The aircraft window needs Qt**, and any of four bindings will do — PyQt6,
PyQt5, PySide6 or PySide2 — because distributions disagree about which they

101
README.md
View file

@ -111,9 +111,16 @@ be built.
| **required** | Rich | `python3-rich` | `python3-rich` | `python-rich` | menus and the live display |
| **required** | PyYAML | `python3-yaml` | `python3-pyyaml` | `python-yaml` | settings file and profiles |
| *recommended* | eSpeak NG | `espeak-ng` | `espeak-ng` | `espeak-ng` | clearer spoken timestamps |
| *optional* | Qt | `python3-pyqt6` | `python3-pyqt6` | `python-pyqt6` | the realtime aircraft window |
| *optional* | ffmpeg | `ffmpeg` | `ffmpeg` | `ffmpeg` | writing `.mp4` instead of `.gif` |
| *optional* | rtl-sdr tools | `rtl-sdr` | `rtl-sdr` | `rtl-sdr` | `rtl_test` and friends for diagnosis |
| *optional* | a speech recogniser | **pip only** | **pip only** | AUR | transcribing speech to text |
| *optional* | Matplotlib | `python3-matplotlib` | `python3-matplotlib` | `python-matplotlib` | nothing yet; reserved for plots |
**Only `librtlsdr` cannot come from pip.** It is a C library, so no virtual
environment brings it with it, and it is the one thing you have to install
from your distribution by hand. Everything that does not touch the hardware
works without it — reading logs, drawing maps, decoding recordings, the
simulated sky — and anything that does says so plainly rather than failing.
Two notes on the optional ones:
@ -1069,11 +1076,27 @@ brightest red there is and the one furthest from every altitude colour in
every theme. A softer red sat close enough to a low aeroplane on the default
map, and to a mid-altitude one on the red theme, to be taken for one.
**Nothing is ever drawn over the flag.** It goes down after everything else on
both pictures — after the aircraft, their trails and their boxes — because it
says where the receiver is standing, and that is the one mark that must not
end up behind an aeroplane that happened to fly over it. The halo of a vector
theme cannot cover it either: a halo only ever goes on the ground, the grid
and the empty background.
The flag is drawn **only where the receiver was actually told where it is**.
Without a position the middle of the picture is worked out from whatever flew
past, which is not a place anybody is standing, and a flag on it would say
that somebody is.
**`--box-opacity PERCENT`** (85 by default) is how solid the card behind each
information box is. The words beside an aircraft are readable over water and
not over a city, so a card goes behind them; at 0 they sit straight on the map
and at 100 the map does not show through at all. An indexed picture cannot
blend, so in the animation this darkens the ground under the box instead —
which leaves the coastline faintly visible through it, where a flat wash would
not. The animated pictures had **no card at all** before this, so `0` is what
they used to look like.
**Range rings** put faint discs at a quarter, a half and three quarters of the
radius, concentric on the receiver and each labelled with its distance. They
are translucent and they stack, so the ground inside the innermost is lifted
@ -1632,6 +1655,82 @@ rings meet the nearer wins, which is what happens on the tube as well. It
costs about 55 ms a frame at 1400×1258, and the default theme skips the pass
entirely.
## Every ADS-B option
Thirty-four of them, in the six groups the menu shows. Each is a flag on the
command line and a line in `bandsaunter` → 5, and both are generated from one
table in the source, so they cannot disagree. `man bandsaunter` has the long
form of every one.
**receiver**
| option | flag | default | what it does |
| --- | --- | --- | --- |
| Receiver | `--device` | `0` | which receiver to use, when more than one is plugged in |
| Gain | `--gain` | `auto` | tuner gain in dB, or automatic |
| Sample rate | `--rate` | `2 MHz` | how fast to sample; two megasamples a second is the minimum |
| Receiver at | `--at` | — | where the receiver is, as latitude,longitude (blank = work it out) |
| Invent a sky | `--simulate` | `no` | fly imaginary aircraft past an imaginary receiver |
| Imaginary sky near | `--near` | `47.55,-122.30` | where the simulated aircraft are flying |
**listening**
| option | flag | default | what it does |
| --- | --- | --- | --- |
| Listen for | `--seconds` | `until stopped` | how long to listen before stopping (0 = until interrupted) |
| Show every frame | `--frames` | `no` | print each frame as it arrives, rather than a running count |
| Write the log | `--log` `--no-log` | `yes` | write every frame to a file as it arrives |
| Also write a KML | `--kml` | `no` | write the flight paths for Google Earth as well |
| Keep on screen for | `--hold` | `45 s` | how long an aircraft stays on the display after its last frame |
| Draw when finished | `--map` | `no` | draw the map as soon as the listening stops |
**aircraft**
| option | flag | default | what it does |
| --- | --- | --- | --- |
| Look the aircraft up | `--lookup` `--no-lookup` | `yes` | ask the public registers who each aircraft is |
| Schedule services | `--schedules` | — | which paid schedule services to ask, in order (blank = all with keys) |
| Check the positions | `--recheck` | `no` | throw out positions the aircraft could not have been in |
**animation**
| option | flag | default | what it does |
| --- | --- | --- | --- |
| Picture | `--out` | `gif` | what kind of picture to draw |
| Animation length | `--seconds` | `30 s` | how long the animation should run for |
| Speed | `--speed` | `fit to the length` | seconds of flying per second of animation (0 = fit to the length) |
| Frames a second | `--fps` | `12` | how many frames of animation each second holds |
| Picture width | `--width` | `960 px` | how many pixels across the picture is |
| Trail | `--trail` | `the whole path` | how much of the path to leave behind each aircraft (0 = all of it) |
| Fade out over | `--fade` | `20 s` | how long an aircraft takes to fade away once it has gone quiet |
| Forget after | `--stale` | `300 s` | stop drawing an aircraft this long after its last report |
**the map**
| option | flag | default | what it does |
| --- | --- | --- | --- |
| Map underneath | `--basemap` `--no-basemap` | `yes` | draw a real map under the flight paths |
| Tile server | `--tiles` | — | where the map tiles come from |
| Colour theme | `--theme` | `night` | how the map looks: the colours, and whether the lines glow |
| Map brightness | `--map-brightness` | `70 %` | how bright the map under the aircraft is drawn, as a percentage |
| Map radius | `--radius` | `100` | how far around the receiver the map reaches (0 = fit whatever was heard) |
| Mark the airports | `--airports` `--no-airports` | `yes` | mark every aerodrome on the map, not only the ones flown between |
| Range rings on the pictures | `--rings` `--no-rings` | `yes` | faint discs at a quarter, a half and three quarters of the radius |
| Range rings in the window | `--window-rings` `--no-window-rings` | `yes` | the same discs on the realtime display |
**labels**
| option | flag | default | what it does |
| --- | --- | --- | --- |
| Box translucency | `--box-opacity` | `85 %` | how solid the card behind each information box is, as a percentage |
| Label the aircraft | `--labels` `--no-labels` | `yes` | write the callsign, height and speed beside each aircraft |
| Speed in | `--speed-unit` | `knots` | what to show speeds and distances in |
**Where they live.** Saved with `s` in the menu to
`~/.config/bandsaunter/aircraft.yaml`, which is plain YAML you can edit or
copy between machines. API keys are deliberately **not** kept there — see
[Knowing the leg for certain](#knowing-the-leg-for-certain).
## Meters and weather sensors
Two things on the ISM bands are worth naming rather than reporting as hex.

View file

@ -9,7 +9,7 @@ and transcribing speech.
# 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 = 2
VERSION_REVISION = 3
__version__ = f"{VERSION_DATE}_{VERSION_REVISION:02d}"

View file

@ -121,6 +121,7 @@ class AircraftOptions:
rings: bool = True
window_rings: bool = True
theme: str = "night"
box_opacity: int = 85
map_brightness: int = 70
tile_url: str = ""
@ -169,7 +170,7 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
"average, so a handful of wrong positions cannot drag it anywhere. "
"Setting it explicitly is worth doing if you want the same frame every "
"night regardless of which way the traffic went.",
example="32.54,-111.17", metavar="LAT,LON"),
flags=("--at",), example="32.54,-111.17", metavar="LAT,LON"),
O("simulate", "Invent a sky", "Receiver", "bool",
"fly imaginary aircraft past an imaginary receiver",
"Six aircraft that are not there, broadcasting real frames with real "
@ -220,7 +221,8 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
"heard.",
unit="s", minimum=1.0, example="45",
guidance="Long enough that a gap in reception does not make rows jump "
"about; short enough that the screen is the sky now."),
"about; short enough that the screen is the sky now.",
flags=("--hold",), metavar="SECONDS"),
O("draw_after", "Draw when finished", "Listening", "bool",
"draw the map as soon as the listening stops",
"Saves running the map separately. It uses the drawing options below.",
@ -246,6 +248,7 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
"copied between machines and pasted into messages asking for help: "
"BANDSAUNTER_AEROAPI_KEY, BANDSAUNTER_FR24_TOKEN, BANDSAUNTER_OAG_KEY, "
"and BANDSAUNTER_CIRIUM_APP_ID with BANDSAUNTER_CIRIUM_APP_KEY.",
flags=("--schedules",), metavar="NAMES",
example="flightaware,cirium",
guidance="Leave it blank unless you want one service tried before "
"another. With no keys set, nothing changes."),
@ -339,6 +342,7 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
"OpenStreetMap one, whose tiles are free to use within its usage "
"policy: identify yourself, cache what you fetch, and do not bulk "
"download. This program does all three.",
flags=("--tiles",), metavar="URL",
example="https://tile.openstreetmap.org/{z}/{x}/{y}.png"),
O("theme", "Colour theme", "The map", "choice",
"how the map looks: the colours, and whether the lines glow",
@ -416,6 +420,19 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
"one more than the other depending on which you are doing.",
flags=("--window-rings",), off_flags=("--no-window-rings",),
guidance="Turn it off if the window is busy enough already."),
O("box_opacity", "Box translucency", "Labels", "int",
"how solid the card behind each information box is, as a percentage",
"The words beside an aircraft are readable over water and not over a "
"city, so a card goes behind them. At nothing they sit straight on "
"the map as they used to; at the whole way the map does not show "
"through at all and the box is a solid panel. In between it darkens "
"the ground under the box, so the coastline still shows faintly "
"through it. It applies to the window and to the animated pictures "
"alike -- the pictures had no card at all before, and setting this "
"to nothing is what they used to look like.",
unit="%", minimum=0, maximum=100, flags=("--box-opacity",),
metavar="PERCENT", example="85",
guidance="Turn it up over a busy map and down over an empty one."),
O("labels", "Label the aircraft", "Labels", "bool",
"write the callsign, height and speed beside each aircraft",
"Height is the flight level -- hundreds of feet -- the way it is said "
@ -432,6 +449,7 @@ OPTIONS: tuple[Setting, ...] = ( O("device", "Receiver", "Receiver", "int",
"miles an hour, kilometres with km/h, so that one picture never "
"carries two different miles.",
choices=("knots", "mph", "kph"),
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."),
)
@ -749,7 +767,8 @@ def watch(console, options: AircraftOptions, output_dir: str,
brightness=max(10, options.map_brightness) / 100.0,
fade=max(0.0, options.fade),
airports=options.airports,
rings=options.window_rings)
rings=options.window_rings,
box_opacity=max(0, options.box_opacity) / 100.0)
sky.started = started
sky.log_name = log.path.name if log is not None else ""
if options.simulate:
@ -960,6 +979,7 @@ def draw(console, options: AircraftOptions, tracks, out_path, book=None):
brightness=max(10, options.map_brightness) / 100.0,
airports=options.airports,
rings=options.rings,
box_opacity=max(0, options.box_opacity) / 100.0,
radius_nm=radius_in_nm(options),
centre=read_position(options.location))
except (OSError, RuntimeError, ValueError) as exc:

View file

@ -55,7 +55,9 @@ examples:
bandsaunter bands --category Aviation browse the US band plan
bandsaunter devices list attached dongles
bandsaunter adsb read the aircraft on 1090 MHz
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 scan -b 2m --simulate try it without hardware
""")
# The GNU form: the version, then who holds the copyright and what the
@ -172,6 +174,9 @@ examples:
"(default: adsb_<time>.jsonl in the output directory)")
ad.add_argument("--no-log", dest="log_frames", action="store_false",
help="listen without writing anything down")
ad.add_argument("--lookup", dest="lookup",
action="store_true", default=None,
help="ask the public registers who each aircraft is")
ad.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
ad.add_argument("--schedules", default=None, metavar="NAMES",
@ -190,6 +195,9 @@ examples:
choices=("knots", "mph", "kph"),
help="what to show speeds and distances in "
"(default: knots, which is what aircraft broadcast)")
ad.add_argument("--basemap", dest="basemap",
action="store_true", default=None,
help="draw a real map under the flight paths")
ad.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None,
help="draw the map with no real map under it")
@ -209,6 +217,49 @@ examples:
ad.add_argument("--no-window-rings", dest="window_rings",
action="store_false", default=None,
help="no range rings on the realtime window")
ad.add_argument("--box-opacity", type=int, default=None,
metavar="PERCENT",
help="how solid the card behind each information box is "
"(0-100; 0 puts the words straight on the map)")
ad.add_argument("--at", default=None, metavar="LAT,LON",
help="where the receiver is: the middle of the window "
"and of any map drawn afterwards")
ad.add_argument("--radius", type=float, default=None, metavar="MILES",
help="how far around the receiver the window and the map "
"reach, in the same unit as the speeds")
ad.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long an aircraft stays on the display after "
"its last frame")
ad.add_argument("--tiles", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)")
ad.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT",
help="how bright the map under the aircraft is (10-100)")
ad.add_argument("--width", type=int, default=None,
help="how wide any map drawn afterwards is, in pixels")
ad.add_argument("--fps", type=float, default=None,
help="frames a second in any animation drawn afterwards")
ad.add_argument("--trail", type=float, default=None, metavar="SECONDS",
help="how much of the path to leave behind each aircraft")
ad.add_argument("--fade", type=float, default=None, metavar="SECONDS",
help="how long an aircraft takes to fade away once it "
"has gone quiet")
ad.add_argument("--stale", type=float, default=None, metavar="SECONDS",
help="stop drawing an aircraft this long after its last "
"report")
ad.add_argument("--airports", dest="airports", action="store_true",
default=None,
help="mark every aerodrome on the map")
ad.add_argument("--no-airports", dest="airports", action="store_false",
default=None,
help="do not mark the aerodromes")
ad.add_argument("--labels", dest="labels", action="store_true",
default=None,
help="write the callsign, height and speed beside each "
"aircraft")
ad.add_argument("--no-labels", dest="labels", action="store_false",
default=None,
help="draw the aircraft without labels beside them")
ad.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad",
@ -242,10 +293,16 @@ examples:
fl.add_argument("--fade", type=float, default=None, metavar="SECONDS",
help="how long an aircraft takes to fade away once it "
"has gone quiet (0 to remove it at once)")
fl.add_argument("--labels", dest="labels",
action="store_true", default=None,
help="write the callsign, height and speed beside each aircraft")
fl.add_argument("--no-labels", dest="labels", action="store_false",
help="draw the aircraft without callsigns beside them")
fl.add_argument("--no-map", dest="draw", action="store_false",
help="report only, draw nothing")
fl.add_argument("--lookup", dest="lookup",
action="store_true", default=None,
help="ask the public registers who each aircraft is")
fl.add_argument("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
fl.add_argument("--schedules", default=None, metavar="NAMES",
@ -260,6 +317,9 @@ examples:
choices=("knots", "mph", "kph"),
help="what to show speeds and distances in "
"(default: knots, which is what aircraft broadcast)")
fl.add_argument("--basemap", dest="basemap",
action="store_true", default=None,
help="draw a real map under the flight paths")
fl.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None,
help="draw the tracks on their own, with no map under them")
@ -275,6 +335,10 @@ examples:
fl.add_argument("--no-rings", dest="rings", action="store_false",
default=None,
help="draw no range rings on the map")
fl.add_argument("--box-opacity", type=int, default=None,
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("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad",
@ -282,6 +346,9 @@ examples:
help="how the map looks: night (the default), or the "
"vector-display themes digital, phosphor, amber "
"and red")
fl.add_argument("--airports", dest="airports",
action="store_true", default=None,
help="mark every aerodrome on the map")
fl.add_argument("--no-airports", dest="airports", action="store_false",
default=None,
help="do not mark the aerodromes under the flight paths")
@ -1131,10 +1198,22 @@ def cmd_adsb(args) -> int:
options.schedules = args.schedules
if getattr(args, "theme", None):
options.theme = args.theme
if getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity
if getattr(args, "rings", None) is not None:
options.rings = args.rings
if getattr(args, "window_rings", None) is not None:
options.window_rings = args.window_rings
for flag, key in (("at", "location"), ("radius", "radius"),
("hold", "hold"), ("tiles", "tile_url"),
("map_brightness", "map_brightness"),
("width", "width"), ("fps", "fps"), ("trail", "trail"),
("fade", "fade"), ("stale", "stale"),
("airports", "airports"), ("labels", "labels"),
("basemap", "basemap"), ("lookup", "lookup")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
if args.map:
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
@ -1189,6 +1268,8 @@ def cmd_flights(args) -> int:
options.schedules = args.schedules
if getattr(args, "theme", None):
options.theme = args.theme
if getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity
if getattr(args, "rings", None) is not None:
options.rings = args.rings
if getattr(args, "window_rings", None) is not None:

View file

@ -659,6 +659,11 @@ def background(view: Projection, title: str = "", airports=(),
home=None, rings: float = 0.0) -> np.ndarray:
"""The map without anything flying on it: ground, grid, scale, key, title.
``home`` is where the receiver is standing, and here it only says where
the range rings are centred. The flag that marks it is drawn by the
frame rather than by the ground, so that nothing can be drawn over it
afterwards.
``ground`` is the real map underneath, as brightness levels covering the
body of the picture, or None for the plain grid. Where there is one the
graticule is drawn lighter over it, because the coastline is doing the
@ -706,13 +711,6 @@ def background(view: Projection, title: str = "", airports=(),
if rings:
draw_rings(img, view, home, rings, unit)
# Last of the things on the ground, so the flag stands over the
# aerodromes rather than under them: it is the one position on the
# picture that was not worked out from anything received.
if home is not None and view.inside(home[0], home[1]):
x, y = view.xy(home[0], home[1])
draw_home(img, x, y)
if title:
draw_text(img, MARGIN, (TITLE_H - GLYPH_H) // 2, title, INK)
if attribution:
@ -969,7 +967,8 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
clock: str = "", unit: str = DEFAULT_SPEED_UNIT,
project: bool = True, known=None,
fade: float = 0.0, places: "LabelPlaces | None" = None,
step: float = 0.0) -> np.ndarray:
step: float = 0.0, home=None,
box_opacity: float = 0.0) -> 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
@ -1018,13 +1017,21 @@ def render_frame(base: np.ndarray, view: Projection, tracks: list[Track],
entry=(known.get(track.icao)
if known and not crowded else None),
places=places, step=step, strength=strength,
brief=crowded)
brief=crowded, opacity=box_opacity)
if places is not None:
places.end()
if clock:
_clock_strip(img, view, clock, flying)
# The flag goes down after everything else, and nothing is drawn after
# it: it says where the receiver is standing, which is the one thing on
# the picture that must never be hidden behind an aeroplane that
# happened to fly over it.
if home is not None and view.inside(home[0], home[1]):
x, y = view.xy(home[0], home[1])
draw_home(img, x, y)
# Last of all, so that everything drawn this frame glows and nothing
# drawn after it paints over the halo.
# drawn after it paints over the halo. The halo only ever goes on the
# ground, the grid and the background, so it cannot cover the flag.
return bloom(img)
@ -1174,7 +1181,8 @@ def _label(img: np.ndarray, x: int, y: int, track: Track, now,
colour: int, taken: list | None = None,
unit: str = DEFAULT_SPEED_UNIT, entry=None,
places: "LabelPlaces | None" = None, step: float = 0.0,
strength: float = 1.0, brief: bool = False) -> None:
strength: float = 1.0, brief: bool = False,
opacity: float = 0.0) -> None:
"""Who it is, and everything else known about it, beside the aircraft.
Two aircraft that pass close together would otherwise have their labels
@ -1248,6 +1256,14 @@ def _label(img: np.ndarray, x: int, y: int, track: Track, now,
# way out -- which left the brightest thing on that part of the picture
# being the one aeroplane nothing had been heard from.
level = fade_level(strength)
# A card behind the words, as translucent as the setting asks for. An
# indexed picture cannot blend, so this darkens the ground under the
# label towards the panel colour instead: at nothing it is untouched and
# the words sit straight on the map as they always did, and at the whole
# way it is the panel and nothing of the map shows through.
if opacity > 0.0:
_panel(img, left - 3, top - 2, left + span + 3, top + tall,
opacity * strength)
# A line from the label to the aircraft it belongs to, dashed and in its
# own colour. The window has always had one; here there was nothing at
# all, and a label pushed out into one of the rings by a crowd had
@ -1271,6 +1287,35 @@ def _label(img: np.ndarray, x: int, y: int, track: Track, now,
at += GLYPH_H + 2
def _panel(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
opacity: float) -> None:
"""Darken a rectangle towards the panel colour, by however much.
The ground has thirty-two shades and this walks them down: at a tenth
the map is very nearly untouched, at nine tenths it is a dark card with
the coastline just showing through, and at the whole way it is the panel
colour with nothing behind it at all. The background, which is already
as dark as the picture goes, is left alone until the very top of the
range, where the whole rectangle becomes the panel.
"""
height, width = img.shape
x0, x1 = max(0, int(x0)), min(width, int(x1))
y0, y1 = max(0, int(y0)), min(height, int(y1))
if x1 <= x0 or y1 <= y0:
return
part = max(0.0, min(1.0, float(opacity)))
if part <= 0.0:
return
patch = img[y0:y1, x0:x1]
if part >= 0.98:
patch[:] = PANEL
return
ground = (patch >= GROUND) & (patch < GROUND + GROUND_SHADES)
shade = (patch.astype(np.int16) - GROUND) * (1.0 - part)
patch[:] = np.where(ground, (GROUND + shade.round()).astype(np.uint8),
patch)
def draw_flag(img: np.ndarray, x: int, y: int, country: str,
level: int = 0) -> None:
"""Twelve pixels by eight of a flag, or the country's letters instead.
@ -1628,6 +1673,7 @@ 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,
fade: float = 0.0) -> Animation | None:
"""Draw the whole log as a moving map.
@ -1716,7 +1762,9 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
trail_seconds=trail_seconds,
stale=stale, labels=labels,
clock=clock, unit=unit, known=known,
fade=fade, places=places, step=step),
fade=fade, places=places, step=step,
home=centre,
box_opacity=box_opacity),
canvas_w, canvas_h)
path = Path(out_path)
@ -1725,7 +1773,8 @@ def animate(tracks: list[Track], out_path, *, fps: float = 12.0,
# Not an animation at all: the whole log at once, every path drawn.
still = render_frame(base, view, located, finish, stale=covers + 1,
labels=labels, unit=unit, project=False,
known=known,
known=known, home=centre,
box_opacity=box_opacity,
clock=datetime.fromtimestamp(finish)
.strftime("%H:%M:%S"))
write_png(path, PALETTE[still])

View file

@ -360,7 +360,8 @@ class Sky:
def __init__(self, unit: str = DEFAULT_SPEED_UNIT, hold: float = 45.0,
home=None, radius_nm: float = 100.0,
brightness: float = 0.70, fade: float = 20.0,
airports: bool = False, rings: bool = False):
airports: bool = False, rings: bool = False,
box_opacity: float = 0.85):
import threading
self.unit = unit
@ -374,6 +375,10 @@ class Sky:
# anything using this as a library has to say so on purpose.
self.show_airports = airports
self.show_rings = rings
# How solid the card behind each information box is. Nothing at
# 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)))
self.frames = 0
self.aircraft_seen = 0
self.started = time.time()
@ -830,10 +835,6 @@ def _build():
self._draw_rings(painter, view)
self._draw_graticule(painter, view)
self._draw_airports(painter, view)
# Over the aerodromes and under the aircraft: it is the one
# position on the picture that was not worked out from anything
# received, and the one thing on it that never moves.
self._draw_home(painter, view)
flying = self.sky.flying()
if self.trails:
for blip in flying:
@ -867,6 +868,12 @@ def _build():
for laid in sorted((x for x in labels if x is not None),
key=lambda laid: laid[0]):
self._paint_label(painter, laid)
# The flag goes down after everything else and nothing is drawn
# after it: it says where the receiver is standing, which is the
# one thing on the picture that must never end up behind an
# aeroplane that happened to fly over it, or behind the box
# belonging to one.
self._draw_home(painter, view)
# An aircraft with no box this time -- gone, faded past reading,
# or the detail turned down -- is forgotten, so one that comes
# back is placed afresh instead of gliding in from wherever it
@ -1308,7 +1315,11 @@ def _build():
shape = QRectF(bx, by, width, height)
# The panel colour, from the theme, so a box on a phosphor
# screen is the black of the tube rather than a blue-grey card.
back = rgb(PANEL, int(215 * (colour.alpha() / 255)))
# As solid as the setting asks for, and no more solid than the
# label it is behind: a card at full strength under a label that
# is fading out would be the brightest thing left of it.
back = rgb(PANEL, int(255 * self.sky.box_opacity
* (colour.alpha() / 255)))
painter.setBrush(back)
painter.setPen(QPen(colour, 1.2))
painter.drawRoundedRect(shape, 3.0, 3.0)

View file

@ -1,5 +1,5 @@
.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_02" "User Commands"
.TH BANDSAUNTER 1 "2026-09-06" "bandsaunter 2026-09-06_03" "User Commands"
.SH NAME
bandsaunter \- scan, record and identify radio signals with an RTL-SDR
.SH SYNOPSIS
@ -1651,6 +1651,306 @@ instead goes straight to that option, and part of a name lists everything it
could mean; a name that matches exactly wins outright, so "speed" reaches the
setting called speed rather than that one and every other whose description
mentions the word.
.SS The card behind a box
.BI \-\-box\-opacity " PERCENT"
is how solid the card behind each information box is. The words beside an
aircraft are readable over water and not over a city, so a card goes behind
them: at nothing they sit straight on the map and at the whole way the map
does not show through at all. An indexed picture cannot blend, so in the
animation this darkens the ground under the box instead, which leaves the
coastline faintly visible through it. The animated pictures had no card at all
before, so nothing is what they used to look like.
.PP
The flag marking the receiver is drawn after everything else on both pictures
\[em] after the aircraft, their trails and their boxes \[em] since it says
where the receiver is standing and that is the one mark that must not end up
behind an aeroplane that happened to fly over it. A vector theme's halo cannot
cover it either, a halo only ever going on the ground, the grid and the
background.
.SH AIRCRAFT OPTIONS
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.
.SS Receiver
.TP
.B --device
Receiver \[em] which receiver to use, when more than one is plugged in.
.br
Setting name \fBdevice\fR, default \fB0\fR.
.br
Accepts: at least 0.
.TP
.B --gain
Gain \[em] tuner gain in dB, or automatic.
.br
Setting name \fBgain\fR, default \fBauto\fR.
.TP
.B --rate
Sample rate \[em] how fast to sample; two megasamples a second is the minimum (Hz).
.br
Setting name \fBrate\fR, default \fB2 MHz\fR.
.br
Accepts: at least 2e+06.
.TP
.B --at
Receiver at \[em] where the receiver is, as latitude,longitude (blank = work it out).
.br
Setting name \fBlocation\fR, default \fBblank\fR.
.TP
.B --simulate
Invent a sky \[em] fly imaginary aircraft past an imaginary receiver.
.br
Setting name \fBsimulate\fR, default \fBno\fR.
.RS
.PP
Turn this on to see what the whole thing does without hardware. Turn it off to hear real aircraft.
.RE
.TP
.B --near
Imaginary sky near \[em] where the simulated aircraft are flying.
.br
Setting name \fBnear\fR, default \fB47.55,-122.30\fR.
.PP
.SS Listening
.TP
.B --seconds
Listen for \[em] how long to listen before stopping (0 = until interrupted) (s).
.br
Setting name \fBseconds\fR, default \fBuntil stopped\fR.
.br
Accepts: at least 0.
.RS
.PP
Sixty seconds is enough to know whether aircraft are being heard. An evening of traffic wants no limit.
.RE
.TP
.B --frames
Show every frame \[em] print each frame as it arrives, rather than a running count.
.br
Setting name \fBframes\fR, default \fBno\fR.
.TP
.B --log / --no-log
Write the log \[em] write every frame to a file as it arrives.
.br
Setting name \fBlog\fR, default \fByes\fR.
.TP
.B --kml
Also write a KML \[em] write the flight paths for Google Earth as well.
.br
Setting name \fBkml\fR, default \fBno\fR.
.TP
.B --hold
Keep on screen for \[em] how long an aircraft stays on the display after its last frame (s).
.br
Setting name \fBhold\fR, default \fB45 s\fR.
.br
Accepts: at least 1.
.RS
.PP
Long enough that a gap in reception does not make rows jump about; short enough that the screen is the sky now.
.RE
.TP
.B --map
Draw when finished \[em] draw the map as soon as the listening stops.
.br
Setting name \fBdraw_after\fR, default \fBno\fR.
.PP
.SS Aircraft
.TP
.B --lookup / --no-lookup
Look the aircraft up \[em] ask the public registers who each aircraft is.
.br
Setting name \fBlookup\fR, default \fByes\fR.
.TP
.B --schedules
Schedule services \[em] which paid schedule services to ask, in order (blank = all with keys).
.br
Setting name \fBschedules\fR, default \fBblank\fR.
.RS
.PP
Leave it blank unless you want one service tried before another. With no keys set, nothing changes.
.RE
.TP
.B --recheck
Check the positions \[em] throw out positions the aircraft could not have been in.
.br
Setting name \fBrecheck\fR, default \fBno\fR.
.RS
.PP
Worth turning on for anything recorded before this version. Newer logs have the check applied as they are written, so it finds almost nothing.
.RE
.PP
.SS Animation
.TP
.B --out
Picture \[em] what kind of picture to draw.
.br
Setting name \fBpicture\fR, default \fBgif\fR.
.br
Accepts: one of: gif, mp4, png.
.RS
.PP
Start with gif. Use png when you want one picture to look at or send.
.RE
.TP
.B --seconds
Animation length \[em] how long the animation should run for (s).
.br
Setting name \fBlength\fR, default \fB30 s\fR.
.br
Accepts: at least 1.
.TP
.B --speed
Speed \[em] seconds of flying per second of animation (0 = fit to the length) (x).
.br
Setting name \fBspeed\fR, default \fBfit to the length\fR.
.br
Accepts: at least 0.
.TP
.B --fps
Frames a second \[em] how many frames of animation each second holds.
.br
Setting name \fBfps\fR, default \fB12\fR.
.br
Accepts: at least 1.
.TP
.B --width
Picture width \[em] how many pixels across the picture is (px).
.br
Setting name \fBwidth\fR, default \fB960 px\fR.
.br
Accepts: at least 160.
.TP
.B --trail
Trail \[em] how much of the path to leave behind each aircraft (0 = all of it) (s).
.br
Setting name \fBtrail\fR, default \fBthe whole path\fR.
.br
Accepts: at least 0.
.TP
.B --fade
Fade out over \[em] how long an aircraft takes to fade away once it has gone quiet (s).
.br
Setting name \fBfade\fR, default \fB20 s\fR.
.br
Accepts: at least 0.
.RS
.PP
Long enough to notice, short enough that a busy sky is not half ghosts.
.RE
.TP
.B --stale
Forget after \[em] stop drawing an aircraft this long after its last report (s).
.br
Setting name \fBstale\fR, default \fB300 s\fR.
.br
Accepts: at least 1.
.PP
.SS The map
.TP
.B --basemap / --no-basemap
Map underneath \[em] draw a real map under the flight paths.
.br
Setting name \fBbasemap\fR, default \fByes\fR.
.RS
.PP
Turn it off for a picture with nothing but the tracks on it, or where there is no network and no cached tiles.
.RE
.TP
.B --tiles
Tile server \[em] where the map tiles come from.
.br
Setting name \fBtile_url\fR, default \fBblank\fR.
.TP
.B --theme
Colour theme \[em] how the map looks: the colours, and whether the lines glow.
.br
Setting name \fBtheme\fR, default \fBnight\fR.
.br
Accepts: one of: night, digital, phosphor, amber, red.
.RS
.PP
night to read it, the others to look at it.
.RE
.TP
.B --map-brightness
Map brightness \[em] how bright the map under the aircraft is drawn, as a percentage (%).
.br
Setting name \fBmap_brightness\fR, default \fB70 %\fR.
.br
Accepts: at least 10, at most 100.
.RS
.PP
Turn it up until the coast and the roads are readable, and no further. On a vector theme it takes rather more turning up than on the default one.
.RE
.TP
.B --radius
Map radius \[em] how far around the receiver the map reaches (0 = fit whatever was heard).
.br
Setting name \fBradius\fR, default \fB100\fR.
.br
Accepts: at least 0.
.RS
.PP
Set it to what your aerial can really hear. Zero goes back to fitting whatever turned up, mistakes and all.
.RE
.TP
.B --airports / --no-airports
Mark the airports \[em] mark every aerodrome on the map, not only the ones flown between.
.br
Setting name \fBairports\fR, default \fByes\fR.
.RS
.PP
Turn it off for a picture with nothing but the aircraft on it, or where there is no network and nothing cached.
.RE
.TP
.B --rings / --no-rings
Range rings on the pictures \[em] faint discs at a quarter, a half and three quarters of the radius.
.br
Setting name \fBrings\fR, default \fByes\fR.
.RS
.PP
Turn it off for a picture with nothing on it but the aircraft and the ground.
.RE
.TP
.B --window-rings / --no-window-rings
Range rings in the window \[em] the same discs on the realtime display.
.br
Setting name \fBwindow_rings\fR, default \fByes\fR.
.RS
.PP
Turn it off if the window is busy enough already.
.RE
.PP
.SS Labels
.TP
.B --box-opacity
Box translucency \[em] how solid the card behind each information box is, as a percentage (%).
.br
Setting name \fBbox_opacity\fR, default \fB85 %\fR.
.br
Accepts: at least 0, at most 100.
.RS
.PP
Turn it up over a busy map and down over an empty one.
.RE
.TP
.B --labels / --no-labels
Label the aircraft \[em] write the callsign, height and speed beside each aircraft.
.br
Setting name \fBlabels\fR, default \fByes\fR.
.TP
.B --speed-unit
Speed in \[em] what to show speeds and distances in.
.br
Setting name \fBspeed_unit\fR, default \fBknots\fR.
.br
Accepts: one of: knots, mph, kph.
.RS
.PP
knots is what aviation uses and what the aircraft actually said. mph or kph if that is what means something to you.
.RE
.PP
.SS Range rings
.B \-\-rings
puts faint discs at a quarter, a half and three quarters of the radius,

View file

@ -77,7 +77,7 @@ Architecture: ${arch}
Depends: python3 (>= 3.10), python3-numpy, python3-scipy, python3-rich,
python3-yaml, librtlsdr0
Recommends: bandsaunter-transcribe, espeak-ng
Suggests: rtl-sdr, python3-pyqt6
Suggests: rtl-sdr, python3-pyqt6, ffmpeg
Maintainer: bandsaunter
Installed-Size: $(du -ks "$pkgdir" | cut -f1)
Description: signal scanner and recorder for RTL-SDR receivers

View file

@ -56,6 +56,43 @@ def settings_section() -> list[str]:
return out
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.
"""
from bandsaunter import aircraft as air
out = []
defaults = air.AircraftOptions()
for group in air.OPTION_GROUPS:
out.append(f'.SS {esc(group)}')
for o in air.in_group(group):
flags = " ".join(o.flags)
if o.off_flags:
flags += " / " + " ".join(o.off_flags)
shown = air.format_option(o, getattr(defaults, o.key))
unit = f" ({o.unit})" if o.unit and o.kind != "bool" else ""
out.append('.TP')
out.append(f'.B {esc(flags) if flags else esc(o.key)}')
out.append(f'{esc(o.label)} \\[em] {esc(o.help)}{esc(unit)}.')
out.append('.br')
out.append(f'Setting name \\fB{esc(o.key)}\\fR, '
f'default \\fB{esc(shown) if shown else "blank"}\\fR.')
accepts = o.describe_range()
if accepts:
out.append('.br')
out.append(f'Accepts: {esc(accepts)}.')
if o.guidance:
out.append('.RS')
out.append('.PP')
out.append(esc(o.guidance))
out.append('.RE')
out.append('.PP')
return out
HEAD = r'''.\" Generated by packaging/make-man.py -- do not edit by hand.
.TH BANDSAUNTER 1 "{date}" "bandsaunter {version}" "User Commands"
.SH NAME
@ -1044,6 +1081,27 @@ instead goes straight to that option, and part of a name lists everything it
could mean; a name that matches exactly wins outright, so "speed" reaches the
setting called speed rather than that one and every other whose description
mentions the word.
.SS The card behind a box
.BI \-\-box\-opacity " PERCENT"
is how solid the card behind each information box is. The words beside an
aircraft are readable over water and not over a city, so a card goes behind
them: at nothing they sit straight on the map and at the whole way the map
does not show through at all. An indexed picture cannot blend, so in the
animation this darkens the ground under the box instead, which leaves the
coastline faintly visible through it. The animated pictures had no card at all
before, so nothing is what they used to look like.
.PP
The flag marking the receiver is drawn after everything else on both pictures
\[em] after the aircraft, their trails and their boxes \[em] since it says
where the receiver is standing and that is the one mark that must not end up
behind an aeroplane that happened to fly over it. A vector theme's halo cannot
cover it either, a halo only ever going on the ground, the grid and the
background.
.SH AIRCRAFT OPTIONS
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 Range rings
.B \-\-rings
puts faint discs at a quarter, a half and three quarters of the radius,
@ -1282,6 +1340,8 @@ def main() -> int:
out += settings_section()
out.append(TAIL)
text = "\n".join(out)
text = text.replace(".AIRCRAFT_OPTIONS_HERE",
"\n".join(aircraft_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

@ -527,3 +527,68 @@ def test_a_group_number_at_the_top_does_not_edit_the_option_of_that_number(
run(monkeypatch, console, ["1", "b", "b"], ScanConfig())
assert held.seconds == was
assert held.device == air.AircraftOptions().device
# ---------------------------------------------------------------------------
# Every option reachable from the command line as well as the menu
# ---------------------------------------------------------------------------
def _help_for(command: str) -> str:
import subprocess
import sys
return subprocess.run([sys.executable, "-m", "bandsaunter.cli", command,
"--help"], capture_output=True, text=True,
timeout=120).stdout
def test_every_option_records_the_flag_that_sets_it():
"""The manual is generated from this table, so an option whose flag is
not written down here is a flag the manual does not mention."""
missing = [o.key for o in air.OPTIONS if not (o.flags or o.off_flags)]
assert missing == [], missing
def test_every_flag_the_table_claims_actually_exists():
"""The other way round: a flag written down here and never added to a
parser is a promise the program does not keep."""
both = _help_for("adsb") + _help_for("flights")
for option in air.OPTIONS:
for flag in tuple(option.flags) + tuple(option.off_flags):
assert flag in both, f"{option.key}: {flag} is on no command"
def test_a_switch_can_be_turned_back_on_as_well_as_off():
"""An option turned off in the saved settings could not be turned back
on for one run: only the off half of each pair had a flag."""
both = _help_for("adsb") + _help_for("flights")
for key in ("lookup", "basemap", "airports", "labels"):
option = air.by_key(key)
assert option.flags and option.off_flags, key
for flag in tuple(option.flags) + tuple(option.off_flags):
assert flag in both, f"{key}: {flag}"
def test_the_window_takes_the_options_it_draws_with():
"""adsb opens the window and draws a map when it stops, so it has to
accept the settings that decide what those look like."""
text = _help_for("adsb")
for flag in ("--at", "--radius", "--theme", "--map-brightness", "--tiles",
"--rings", "--window-rings", "--box-opacity", "--fade",
"--hold", "--speed-unit"):
assert flag in text, flag
def test_the_readme_lists_every_option_and_its_flag():
"""The readme carries a table of them. A table written by hand goes
stale the first time an option is added, so this says when it has."""
from pathlib import Path
readme = Path(__file__).resolve().parent.parent / "README.md"
if not readme.exists(): # an installed copy has none
pytest.skip("no README beside the tests")
text = readme.read_text()
for option in air.OPTIONS:
assert f"| {option.label} |" in text, f"{option.key} is not in README"
for flag in tuple(option.flags) + tuple(option.off_flags):
assert f"`{flag}`" in text, f"{option.key}: {flag} is not in README"

View file

@ -1566,13 +1566,14 @@ def test_an_airport_cannot_be_mistaken_for_an_aircraft():
# The flag on the receiver
# ---------------------------------------------------------------------------
def test_the_background_flies_a_flag_where_the_receiver_was_told_it_is():
def test_the_frame_flies_a_flag_where_the_receiver_was_told_it_is():
track = straight()
view = fm.fit([track], width=600)
middle = ((view.south + view.north) / 2, (view.west + view.east) / 2)
plain = fm.background(view, unit="knots")
flagged = fm.background(view, unit="knots", home=middle)
assert not (plain == fm.HOME).any()
base = fm.background(view, unit="knots")
assert not (base == fm.HOME).any(), "the ground still flies the flag"
flagged = fm.render_frame(base, view, [track], track.fixes[0].at,
unit="knots", home=middle)
assert (flagged == fm.HOME).any(), "no flag was drawn"
# The foot of the pole is the position it is pointing at.
x, y = view.xy(*middle)
@ -1584,14 +1585,37 @@ def test_no_flag_where_nobody_said_the_receiver_is():
not a place anybody is standing, and a flag on it would say one is."""
track = straight()
view = fm.fit([track], width=600)
assert not (fm.background(view, unit="knots") == fm.HOME).any()
base = fm.background(view, unit="knots")
assert not (fm.render_frame(base, view, [track], track.fixes[0].at,
unit="knots") == fm.HOME).any()
def test_a_receiver_outside_the_picture_is_not_flagged_at_its_edge():
track = straight()
view = fm.fit([track], width=600)
base = fm.background(view, unit="knots")
away = (view.north + 20.0, view.east + 20.0)
assert not (fm.background(view, unit="knots", home=away) == fm.HOME).any()
assert not (fm.render_frame(base, view, [track], track.fixes[0].at,
unit="knots", home=away) == fm.HOME).any()
def test_nothing_is_drawn_over_the_flag():
"""It says where the receiver is standing, which is the one thing on
the picture that must never be hidden behind an aeroplane that happened
to fly over it."""
track = straight()
view = fm.fit([track], width=600)
base = fm.background(view, unit="knots")
# An aircraft exactly on top of the receiver, with a label and a trail.
here = (track.fixes[0].latitude, track.fixes[0].longitude)
img = fm.render_frame(base, view, [track], track.fixes[0].at,
unit="knots", labels=True, home=here,
trail_seconds=600.0)
x, y = view.xy(*here)
assert img[y, x] == fm.HOME, "the aircraft was drawn over the flag"
# The whole pole, not just its foot.
pole = img[y - fm.HOME_POLE:y + 1, x]
assert (pole == fm.HOME).all(), "part of the pole was painted over"
def test_the_animation_flies_the_flag_only_where_it_was_given_a_centre(tmp_path):
@ -1789,9 +1813,10 @@ def test_no_rings_without_a_radius_or_without_a_position():
assert np.array_equal(
fm.background(view, unit="knots", ground=ground, home=None,
rings=60.0), plain)
flagged = fm.background(view, unit="knots", ground=ground, home=home)
assert (flagged == fm.HOME).any() # the flag, but no rings
assert not (flagged == fm.GROUND + fm.RING_LIFT).any()
# A position on its own is not a reason to draw rings: it is the radius
# that says how far out they go.
placed = fm.background(view, unit="knots", ground=ground, home=home)
assert not (placed == fm.GROUND + fm.RING_LIFT).any()
def _labels_drawn(**over):
@ -1851,3 +1876,74 @@ def test_a_distance_field_measures_from_the_place_it_was_given():
assert away[0, 0] == pytest.approx(0.0, abs=0.01)
# A degree of latitude is sixty nautical miles, near enough.
assert away[1, 0] == pytest.approx(60.0, abs=0.5)
# ---------------------------------------------------------------------------
# The card behind a label
# ---------------------------------------------------------------------------
def _labelled(opacity, shade=20):
track = straight()
view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
ground = np.full((view.height, view.width), shade, dtype=np.uint8)
base = fm.background(view, unit="knots", ground=ground, brightness=1.0)
img = base.copy()
fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
entry=_Entry(), opacity=opacity)
return base, img
def test_no_card_at_all_puts_the_words_straight_on_the_map():
"""Which is what the pictures used to look like, and still can."""
base, img = _labelled(0.0)
# Only pixels that are still map in both: the words themselves turn map
# into ink, and that is the label being drawn rather than a card.
ground = (base >= fm.GROUND) & (base < fm.GROUND + fm.GROUND_SHADES)
still = ground & (img >= fm.GROUND) & (img < fm.GROUND + fm.GROUND_SHADES)
assert np.array_equal(img[still], base[still]), "the map was darkened"
def test_a_card_darkens_the_map_under_the_words():
base, img = _labelled(0.6)
darker = (img < base) & (base >= fm.GROUND) & \
(base < fm.GROUND + fm.GROUND_SHADES)
assert darker.sum() > 200, "nothing was darkened"
# And the coastline still shows through: not everything went to one shade.
under = img[darker]
assert under.max() > under.min() or under.min() > fm.GROUND
def test_the_card_gets_darker_the_more_of_it_is_asked_for():
def under(opacity):
base, img = _labelled(opacity)
return int(img[195:215, 295:400].astype(int).sum())
steps = [under(part) for part in (0.0, 0.3, 0.6, 0.9)]
assert steps == sorted(steps, reverse=True), steps
def test_a_card_the_whole_way_up_is_a_solid_panel():
base, img = _labelled(1.0)
assert (img == fm.PANEL).any(), "the panel colour was never used"
ground = (base >= fm.GROUND) & (base < fm.GROUND + fm.GROUND_SHADES)
covered = ground & (img == fm.PANEL)
assert covered.sum() > 200, "the map still shows through a solid panel"
def test_the_card_fades_with_the_label_it_is_behind():
"""A card at full strength under a label on its way out would be the
brightest thing left of it."""
track = straight()
view = fm.fit([track], width=800, box=(50.0, -3.0, 52.0, 3.0))
ground = np.full((view.height, view.width), 20, dtype=np.uint8)
base = fm.background(view, unit="knots", ground=ground, brightness=1.0)
def darkness(strength):
img = base.copy()
fm._label(img, 300, 200, track, track.fixes[0], fm.RAMP, [], "knots",
entry=_Entry(), opacity=0.9, strength=strength)
# How much darker, not how many pixels: a fainter card covers the
# same rectangle and merely takes less out of it.
return int((base.astype(int) - img.astype(int)).clip(0).sum())
assert darkness(1.0) > darkness(0.5) > 0

View file

@ -128,9 +128,43 @@ def test_it_warns_about_the_thing_every_debian_user_hits_first():
def test_it_lists_the_optional_dependencies_and_what_each_one_buys():
body = INSTALL.read_text()
for optional in ("espeak-ng", "ffmpeg", "faster-whisper", "vosk",
"rtl-sdr", "pyqt6"):
"rtl-sdr", "pyqt6", "openai-whisper", "pocketsphinx"):
assert optional in body, optional
assert "Optional dependencies" in body
# What each one buys, and what is lost without it: the two columns are
# the point of the table, not the list of names.
assert "Gives you" in body and "Without it" in body
assert "Optional." in body
def test_it_lists_the_required_dependencies_and_who_needs_them():
"""Somebody installing from scratch has to be told the whole of it,
including the one piece pip cannot bring."""
body = INSTALL.read_text()
for required in ("python3-numpy", "python3-scipy", "python3-rich",
"python3-yaml", "librtlsdr0"):
assert required in body, required
assert "Fedora" in body and "Arch" in body
def test_it_says_which_dependency_pip_cannot_install():
"""librtlsdr is a C library, so no virtual environment brings it and it
is the one thing that has to come from the distribution by hand."""
body = INSTALL.read_text()
assert "librtlsdr" in body
assert "pip` cannot install it" in body or "cannot come from pip" in body
assert "dnf install rtl-sdr" in body
assert "pacman -S rtl-sdr" in body
assert "brew install librtlsdr" in body
def test_it_says_what_reaches_a_network_and_where_it_is_cached():
"""Nothing is fetched behind anybody's back, and somebody installing
this on a metered or air-gapped machine has to be able to see that."""
body = INSTALL.read_text()
for source in ("api.adsbdb.com", "hexdb.io", "tile.openstreetmap.org",
"overpass-api.de"):
assert source in body, source
assert "~/.cache/bandsaunter/tiles" in body
def test_it_says_how_to_install_the_recogniser_and_the_model():

View file

@ -1843,3 +1843,79 @@ def test_the_ring_labels_are_in_the_unit_the_rest_of_the_window_uses():
["30 mi", "60 mi", "90 mi"]
assert [text for _nm, text in ring_labels(100.0, "kph")] == \
["46 km", "93 km", "139 km"]
# ---------------------------------------------------------------------------
# The card behind a box, and the flag over everything
# ---------------------------------------------------------------------------
@qt
def test_the_card_behind_a_box_is_as_solid_as_it_was_asked_to_be(app):
"""The words beside an aircraft are readable over water and not over a
city, so a card goes behind them; how much of the map it hides is the
setting."""
from bandsaunter.livemap import SkyView
from bandsaunter.flightmap import PALETTE, PANEL
want = PALETTE[PANEL]
def solid(part):
"""Pixels that came out the panel colour exactly, which only a card
drawn at full strength does."""
view = SkyView(a_sky(a_blip(), box_opacity=part))
view.show_ground = False
picture = _rendered(view)
return int(((picture[:, :, 2] == want[0])
& (picture[:, :, 1] == want[1])
& (picture[:, :, 0] == want[2])).sum())
# Measured against the picture with no card at all: the header strip is
# drawn in the panel colour too, and that is not a card.
none = solid(0.0)
assert solid(1.0) - none > 500, "no solid card was drawn"
# Half way is neither one nor the other: the map shows through it, so
# hardly a pixel comes out the panel colour exactly.
assert solid(0.5) - none < (solid(1.0) - none) / 4
@qt
def test_a_box_with_no_card_lets_the_map_through(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), box_opacity=0.0)
assert sky.box_opacity == 0.0
view = SkyView(sky)
view.show_ground = False
assert _rendered(view) is not None # it draws, and does not fail
def test_the_card_setting_is_kept_inside_its_range():
assert Sky(box_opacity=-2.0).box_opacity == 0.0
assert Sky(box_opacity=9.0).box_opacity == 1.0
@qt
def test_nothing_in_the_window_is_drawn_over_the_flag(app):
"""It says where the receiver is standing, which is the one thing on
the picture that must never end up behind an aeroplane that happened to
fly over it, or behind the box belonging to one."""
from bandsaunter.flightmap import HOME_POLE, HOME_RED
from bandsaunter.livemap import SkyView
home = (32.4325, -111.0841)
# An aircraft sitting exactly on the receiver, with a solid box.
sky = a_sky(a_blip(lat=home[0], lon=home[1]), home=home, box_opacity=1.0)
view = SkyView(sky)
view.show_ground = False
picture = _rendered(view)
projection = view.projection()
x, y = projection.xy(*home)
def is_flag(px, py):
pixel = picture[py, px]
return (int(pixel[2]), int(pixel[1]), int(pixel[0])) == HOME_RED
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"

View file

@ -135,3 +135,15 @@ def test_the_browser_page_renders_without_complaint(browse_page, tmp_path):
capture_output=True, text=True)
assert done.returncode == 0, done.stderr
assert not done.stderr.strip(), done.stderr
def test_the_manual_lists_every_aircraft_option(page):
"""It is generated from the same table the menu and the flags are, so
an option added to the program cannot quietly fail to be documented."""
from bandsaunter import aircraft as air
for option in air.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"