Aircraft, from the menus, on a live board, over a real map

Four things the ADS-B mode was missing, and one it was actively getting
wrong.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-04 00:05:32 -07:00
parent 4239635f74
commit 96fc21ac7d
18 changed files with 3298 additions and 281 deletions

View file

@ -7,7 +7,6 @@ import json
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from rich.console import Console
@ -23,8 +22,7 @@ from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
is_first_run, list_profiles, load_config, load_default,
save_config, save_default)
from .device import (RtlSdrDevice, RtlSdrError, list_devices,
set_driver_messages)
from .device import RtlSdrError, list_devices, set_driver_messages
from .librtlsdr import load_error
from . import settings as st
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
@ -184,6 +182,13 @@ examples:
help="invent a sky, for a receiver with no aerial")
ad.add_argument("--near", default=None, metavar="LAT,LON",
help="where the simulated aircraft are flying")
ad.add_argument("--speed-unit", default=None,
choices=("knots", "mph", "kph"),
help="what to show speeds and distances in "
"(default: knots, which is what aircraft broadcast)")
ad.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None,
help="draw the map with no real map under it")
ad.set_defaults(log_frames=True, lookup=True)
# -- flights --------------------------------------------------------------
@ -217,6 +222,15 @@ examples:
help="also write the flight paths for Google Earth")
fl.add_argument("--report", nargs="?", const="", default=None, metavar="FILE",
help="also write the readable report to a file")
fl.add_argument("--speed-unit", default=None,
choices=("knots", "mph", "kph"),
help="what to show speeds and distances in "
"(default: knots, which is what aircraft broadcast)")
fl.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None,
help="draw the tracks on their own, with no map under them")
fl.add_argument("--tiles", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)")
fl.set_defaults(labels=True, draw=True, lookup=True)
# -- analyse ------------------------------------------------------------
@ -338,6 +352,33 @@ def _maybe_first_run(cfg: ScanConfig, args) -> None:
console.print()
def _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
"""Say so when a sweep is pointed at something it cannot decode.
The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do and the wrong one. The
sweep is not stopped -- looking at the spectrum there is a fair thing to
want -- but it no longer happens silently.
"""
from . import aircraft as air
warning = air.scanning_aircraft_band(cfg.ranges)
if not warning:
return
console.print(Panel(
Text.from_markup(
f"{escape(warning)}\n\n"
"[bold]bandsaunter adsb[/bold] decodes it properly: aircraft, "
"positions, altitudes and speeds, written to a log.\n"
"[bold]bandsaunter flights[/bold] then draws where they went.\n\n"
"[grey62]Both are in the menus as well, under Aircraft "
"(ADS-B). Scanning it anyway is fine if what you want is the "
"raw spectrum \u2014 add --save-iq to keep the samples."
"[/grey62]"),
title="[yellow]this band needs the aircraft mode",
border_style="yellow", padding=(0, 1)))
def _make_device(cfg: ScanConfig, simulate: bool):
if simulate:
from .simulator import SimulatedDevice
@ -394,6 +435,8 @@ def cmd_scan(args) -> int:
console.print(f"[red]{e}[/red]")
return 2
_warn_about_aircraft_bands(cfg)
if args.dry_run:
_print_plan(cfg)
return 0
@ -991,15 +1034,11 @@ def cmd_adsb(args) -> int:
and a half kilohertz wide before anything sees it, and a megabit will not
go through that.
Everything heard goes into a log as it arrives, because an aircraft is
overhead for four minutes and then gone: the summary on the screen is for
the person watching, and the log is for everything afterwards -- the
report, the map and the animation.
The listening itself is in :mod:`bandsaunter.aircraft`, because the menus
do exactly the same thing and neither front end should own it.
"""
from .adsb import (ADSB_HZ, AircraftRegistry, SAMPLE_RATE, SimulatedSky,
decode_frames, default_sky)
from .flightlog import FlightLog, read_logs, report, write_kml
from .flights import FlightBook
from .adsb import SAMPLE_RATE
from . import aircraft as air
cfg, _ = load_default()
if args.rate < SAMPLE_RATE:
@ -1007,216 +1046,41 @@ def cmd_adsb(args) -> int:
f"{args.rate/1e6:g} is not enough to see a bit.[/red]")
return 2
if args.simulate:
sky = default_sky(*_near(args.near)) if args.near else default_sky()
device = SimulatedSky(sky, sample_rate=args.rate,
realtime=True).open()
console.print("[yellow]simulated: these aircraft are not there."
"[/yellow]")
else:
try:
device = RtlSdrDevice(index=args.device, sample_rate=int(args.rate),
gain=args.gain, agc=args.gain == "auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
return 1
options = air.load_options()
options.seconds = args.seconds
options.rate = args.rate
options.gain = args.gain
options.device = args.device
options.frames = args.frames
options.log = args.log_frames
options.lookup = args.lookup
options.simulate = args.simulate
options.kml = args.kml is not None
options.draw_after = args.map is not None
if args.near:
options.near = args.near
if args.speed_unit:
options.speed_unit = args.speed_unit
if args.basemap is not None:
options.basemap = args.basemap
if args.map:
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
started = time.time()
log = None
if args.log_frames:
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
where = Path(args.log).expanduser() if args.log else \
Path(cfg.output_dir).expanduser() / f"adsb_{stamp}.jsonl"
try:
log = FlightLog(where, frequency=ADSB_HZ, sample_rate=args.rate,
receiver="simulated" if args.simulate else
f"device {args.device}", started=started)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
log = None
registry = AircraftRegistry()
total = 0
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
f"{args.rate/1e6:g} MS/s — control-C to stop[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
try:
device.tune(ADSB_HZ)
block = int(args.rate) # a second at a time
while True:
at = time.time()
samples = device.read_samples(block)
if samples is None or samples.size == 0:
break
for frame in decode_frames(samples, args.rate):
# The real time the frame arrived, not its offset in the
# block: everything downstream is a clock, and a log that
# started again from zero every second would be unusable.
when = at + frame.at_sample / args.rate
craft = registry.add(frame, when=when)
total += 1
if log is not None:
log.append(frame, craft, when=when)
if args.frames:
console.print(f"[cyan]{frame.icao}[/cyan] "
f"{escape(frame.describe())}",
highlight=False)
if not args.frames and total:
console.print(f"[grey62]{len(registry)} aircraft, "
f"{total} frames[/grey62]", highlight=False)
if args.seconds and time.time() - started >= args.seconds:
break
except KeyboardInterrupt:
pass
finally:
device.close()
if log is not None:
log.close()
if not registry:
console.print("[yellow]nothing heard. ADS-B needs an aerial cut for "
"1090 MHz; the whip that came with the dongle will "
"hear the airport and not much else.[/yellow]")
heard = air.listen(console, options, cfg.output_dir,
log_path=args.log)
if not heard.aircraft:
return 1
book = FlightBook(online=args.lookup)
_aircraft_table(registry, book, total)
if args.lookup:
book.wait(12.0)
book.save()
_lookup_table(registry, book)
tracks = read_logs(log.path) if log is not None else _tracks_from(registry)
if args.kml is not None:
where = Path(args.kml).expanduser() if args.kml else \
(log.path.with_suffix(".kml") if log is not None
else Path("aircraft.kml"))
written = write_kml(where, tracks, book if args.lookup else None)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if log is not None:
told = log.path.with_suffix(".txt")
try:
told.write_text("\n".join(report(
tracks, book if args.lookup else None,
title=f"bandsaunter — aircraft heard "
f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}")))
console.print(f"[green]{told}[/green]")
except OSError as exc:
console.print(f"[red]cannot write {told}: {exc}[/red]")
if args.map is not None:
_draw_flights(tracks, args.map or (log.path.with_suffix(".gif")
if log is not None
else Path("aircraft.gif")),
book if args.lookup else None)
elif log is not None:
console.print(f"[grey62]draw it: bandsaunter flights {log.path}"
if args.kml and heard.kml_path is None:
# An explicit path was given, so honour it rather than the one beside
# the log that `listen` writes by default.
from .flightlog import write_kml
write_kml(Path(args.kml).expanduser(), heard.tracks)
if heard.log_path is not None and not options.draw_after:
console.print(f"[grey62]draw it: bandsaunter flights {heard.log_path}"
"[/grey62]")
return 0
def _near(text: str) -> tuple[float, float]:
"""Read a LAT,LON pair, falling back to the default sky."""
try:
lat, lon = (float(x) for x in str(text).split(",", 1))
return lat, lon
except (TypeError, ValueError):
console.print(f"[yellow]cannot read {text!r} as a latitude and "
"longitude; flying somewhere else instead[/yellow]")
return 47.55, -122.30
def _tracks_from(registry):
"""Tracks from a registry, for a session that wrote no log.
One position each: what is on the screen is all there is, because nothing
kept the ones before it.
"""
from .flightlog import Fix, Track
out = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
track = Track(icao=craft.icao, callsign=craft.callsign,
frames=craft.messages, first_seen=craft.first_seen,
last_seen=craft.last_seen)
if craft.located:
track.fixes.append(Fix(at=craft.last_seen, latitude=craft.latitude,
longitude=craft.longitude,
altitude_ft=craft.altitude_ft,
ground_speed_kt=craft.ground_speed_kt,
track_deg=craft.track_deg,
vertical_rate_fpm=craft.vertical_rate_fpm))
out.append(track)
return out
def _aircraft_table(registry, book, total: int) -> None:
"""What was heard, as it was heard: no register, only the air."""
t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
header_style="bold")
for column in ("ICAO", "callsign", "altitude", "position", "speed",
"frames"):
t.add_column(column)
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
t.add_row(craft.icao, craft.callsign or "",
f"{craft.altitude_ft:,} ft" if craft.altitude_ft else "",
(f"{craft.latitude:.4f}, {craft.longitude:.4f}"
if craft.located else ""),
(f"{craft.ground_speed_kt:.0f} kt {craft.track_deg:.0f}°"
if craft.ground_speed_kt else ""),
str(craft.messages))
console.print(t)
def _lookup_table(registry, book) -> None:
"""And what the registers say about them, kept separate on purpose."""
rows = []
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
entry = book.get(craft.icao, craft.callsign)
told = entry.summary()
if told or entry.country:
rows.append((craft.icao, craft.callsign or "", entry.country,
told or ""))
if not rows:
return
t = Table(title="what the registers say", box=None, header_style="bold")
for column in ("ICAO", "callsign", "registered", "aircraft, operator, route"):
t.add_column(column, overflow="fold")
for row in rows:
t.add_row(*row)
console.print(t)
def _draw_flights(tracks, out_path, book, **over) -> bool:
"""Draw the animation, saying what it is drawing and what came out."""
from .flightmap import animate, ffmpeg_available
out_path = Path(out_path).expanduser()
if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \
not ffmpeg_available():
console.print("[yellow]ffmpeg is not installed; writing a GIF "
"instead[/yellow]")
out_path = out_path.with_suffix(".gif")
console.print(f"[grey62]drawing {out_path.name}…[/grey62]")
try:
drawn = animate(tracks, out_path, book=book, **over)
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
return False
if drawn is None:
console.print("[yellow]nothing was placed on the map: no aircraft "
"reported a position[/yellow]")
return False
size = drawn.path.stat().st_size / 1e6
console.print(f"[green]{drawn.path}[/green] "
f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
return True
def cmd_flights(args) -> int:
"""Turn a log of ADS-B frames into something worth looking at.
@ -1224,12 +1088,13 @@ def cmd_flights(args) -> int:
back, asks who the aircraft were, prints what it found and draws the
whole evening as a map with the clock running.
"""
from . import aircraft as air
from .flightlog import read_logs, report, write_kml
from .flights import FlightBook
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else _newest_log(Path(cfg.output_dir).expanduser())
else air.logs_in(cfg.output_dir)[:1]
if not paths:
console.print("[yellow]no ADS-B logs found. Record one with "
"`bandsaunter adsb`.[/yellow]")
@ -1250,8 +1115,12 @@ def cmd_flights(args) -> int:
book.wait(20.0)
book.save()
options = air.load_options()
if args.speed_unit:
options.speed_unit = args.speed_unit
title = f"bandsaunter — {paths[0].name}"
lines = report(tracks, book if args.lookup else None, title=title)
lines = report(tracks, book if args.lookup else None, title=title,
unit=options.speed_unit)
console.print(escape("\n".join(lines)), highlight=False)
if args.report is not None:
where = Path(args.report).expanduser() if args.report \
@ -1264,31 +1133,31 @@ def cmd_flights(args) -> int:
if args.kml is not None:
where = Path(args.kml).expanduser() if args.kml \
else paths[0].with_suffix(".kml")
written = write_kml(where, tracks, book if args.lookup else None)
written = write_kml(where, tracks, book if args.lookup else None,
unit=options.speed_unit)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if not args.draw:
return 0
options.fps = args.fps
options.length = args.seconds
options.speed = args.speed
options.width = args.width
options.trail = args.trail
options.stale = args.stale
options.labels = args.labels
if args.basemap is not None:
options.basemap = args.basemap
if args.tiles:
options.tile_url = args.tiles
out = Path(args.out).expanduser() if args.out else \
paths[0].with_suffix(".gif")
drawn = _draw_flights(tracks, out, book if args.lookup else None,
fps=args.fps, seconds=args.seconds, speed=args.speed,
width=args.width, trail_seconds=args.trail,
stale=args.stale, labels=args.labels)
paths[0].with_suffix("." + options.picture)
drawn = air.draw(console, options, tracks, out,
book if args.lookup else None)
return 0 if drawn else 1
def _newest_log(directory: Path) -> list[Path]:
"""The last ADS-B log written, which is nearly always the one wanted."""
try:
logs = sorted(directory.glob("adsb_*.jsonl"),
key=lambda p: p.stat().st_mtime)
except OSError:
return []
return logs[-1:]
def cmd_analyze(args) -> int:
import numpy as np
from .classify import classify