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:
parent
6d2436cde1
commit
8ed01f991f
16 changed files with 1012 additions and 46 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -16,3 +16,6 @@ recordings/
|
|||
# local environments
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# a personal helper, not part of the program
|
||||
resume.sh
|
||||
|
|
|
|||
78
INSTALL.md
78
INSTALL.md
|
|
@ -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
101
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue