Write the aircraft down, and draw where they went

ADS-B was a live table and nothing else: an aircraft was overhead for four
minutes and then gone, with nothing kept.  Now everything heard goes into
adsb_<time>.jsonl as it arrives -- one object per frame, the raw hex beside
what was read out of it, flushed per line because a listening session ends
with control-C -- with a readable report beside it.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
This commit is contained in:
The Dust Council 2026-09-03 22:22:49 -07:00
parent a8a8548369
commit eae60cb04d
15 changed files with 3857 additions and 177 deletions

View file

@ -7,6 +7,7 @@ import json
import signal
import sys
import time
from datetime import datetime
from pathlib import Path
from rich.console import Console
@ -55,6 +56,8 @@ examples:
bandsaunter config hang_seconds=5 set one setting and save it
bandsaunter bands --category Aviation browse the US band plan
bandsaunter devices list attached dongles
bandsaunter adsb read the aircraft on 1090 MHz
bandsaunter flights --out sky.gif animate what they did
bandsaunter scan -b 2m --simulate try it without hardware
""")
p.add_argument("--version", action="version", version=f"bandsaunter {__version__}")
@ -152,9 +155,8 @@ examples:
pr = sub.add_parser("profiles", help="list saved profiles")
pr.add_argument("--show", metavar="NAME", help="print one profile")
# -- analyse ------------------------------------------------------------
ad = sub.add_parser("adsb",
help="listen to aircraft on 1090 MHz")
# -- adsb ---------------------------------------------------------------
ad = sub.add_parser("adsb", help="listen to aircraft on 1090 MHz")
ad.add_argument("--seconds", type=float, default=0.0,
help="stop after this long (default: until interrupted)")
ad.add_argument("--rate", type=float, default=2_000_000.0,
@ -163,9 +165,57 @@ examples:
ad.add_argument("--device", type=int, default=0, help="which receiver")
ad.add_argument("--frames", action="store_true",
help="print every frame as it arrives, not a summary")
ad.add_argument("--kml", nargs="?", const="aircraft.kml", default=None,
metavar="FILE",
help="write what was heard as a map and exit")
ad.add_argument("--log", default=None, metavar="FILE",
help="where to write the frame log "
"(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("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
ad.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
help="also write the flight paths for Google Earth")
ad.add_argument("--map", nargs="?", const="", default=None, metavar="FILE",
help="draw the animated map when the listening stops")
ad.add_argument("--simulate", action="store_true",
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.set_defaults(log_frames=True, lookup=True)
# -- flights --------------------------------------------------------------
fl = sub.add_parser("flights",
help="read an ADS-B log: report, map, animation")
fl.add_argument("path", nargs="*",
help="frame logs (default: the newest in the output directory)")
fl.add_argument("--out", default=None, metavar="FILE",
help="the animation to write: .gif, .mp4 or .png "
"(default: beside the log, as a GIF)")
fl.add_argument("--fps", type=float, default=12.0,
help="frames a second in the animation")
fl.add_argument("--seconds", type=float, default=30.0,
help="how long the animation should run for")
fl.add_argument("--speed", type=float, default=0.0, metavar="X",
help="seconds of flying per second of animation "
"(overrides --seconds)")
fl.add_argument("--width", type=int, default=960, help="picture width")
fl.add_argument("--trail", type=float, default=0.0, metavar="SECONDS",
help="how much of the path to leave behind "
"(default: all of it)")
fl.add_argument("--stale", type=float, default=300.0, metavar="SECONDS",
help="drop an aircraft this long after its last report")
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("--no-lookup", dest="lookup", action="store_false",
help="do not ask the registers who the aircraft are")
fl.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
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.set_defaults(labels=True, draw=True, lookup=True)
# -- analyse ------------------------------------------------------------
a = sub.add_parser("analyze", aliases=["analyse"],
help="identify a signal in a recorded file")
@ -929,50 +979,89 @@ def cmd_profiles(args) -> int:
def cmd_adsb(args) -> int:
"""Park the receiver on 1090 MHz and read the aircraft overhead.
"""Park the receiver on 1090 MHz and write down the aircraft overhead.
A command of its own because ADS-B does not fit through the scanner. It
is a megabit a second, which needs two megasamples a second of raw
receiver output; the scan path decimates everything to a channel twelve
and a half kilohertz wide before anything sees it, and a megabit will not
go through that.
"""
from .adsb import ADSB_HZ, AircraftRegistry, SAMPLE_RATE, decode_adsb
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.
"""
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
cfg, _ = load_default()
if args.rate < SAMPLE_RATE:
console.print(f"[red]ADS-B needs at least {SAMPLE_RATE/1e6:g} MS/s; "
f"{args.rate/1e6:g} is not enough to see a bit."
"[/red]")
f"{args.rate/1e6:g} is not enough to see a bit.[/red]")
return 2
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
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
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
started = time.time()
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
frames, registry = decode_adsb(samples, args.rate, registry)
total += len(frames)
if args.frames:
for frame in frames:
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)
elif frames:
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:
@ -981,12 +1070,88 @@ def cmd_adsb(args) -> int:
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]")
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}"
"[/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",
@ -994,44 +1159,130 @@ def cmd_adsb(args) -> int:
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.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)
if args.kml is not None:
written = _write_aircraft_kml(Path(args.kml).expanduser(), registry)
console.print(f"[green]{written}[/green]" if written
else "[red]could not write the map[/red]")
return 0
def _write_aircraft_kml(path: Path, registry) -> Path | None:
"""Every located aircraft as a placemark, for Google Earth."""
from xml.sax.saxutils import escape as xml_escape
located = [a for a in registry.aircraft.values() if a.located]
if not located:
return None
out = ['<?xml version="1.0" encoding="UTF-8"?>',
'<kml xmlns="http://www.opengis.net/kml/2.2">', " <Document>",
" <name>bandsaunter — aircraft heard</name>"]
for craft in sorted(located, key=lambda a: a.icao):
name = f"{craft.icao} {craft.callsign}".strip()
out += [" <Placemark>",
f" <name>{xml_escape(name)}</name>",
f" <description>{xml_escape(craft.describe())}</description>",
" <Point><coordinates>"
f"{craft.longitude:.6f},{craft.latitude:.6f},"
f"{craft.altitude_ft * 0.3048:.0f}</coordinates></Point>",
" </Placemark>"]
out += [" </Document>", "</kml>", ""]
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:
path.write_text("\n".join(out))
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.
The log is a list of times and places; this is the tool that reads it
back, asks who the aircraft were, prints what it found and draws the
whole evening as a map with the clock running.
"""
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())
if not paths:
console.print("[yellow]no ADS-B logs found. Record one with "
"`bandsaunter adsb`.[/yellow]")
return 1
for path in paths:
if not path.exists():
console.print(f"[red]no such file: {path}[/red]")
return 1
tracks = read_logs(paths)
if not tracks:
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
return 1
book = FlightBook(online=args.lookup)
if args.lookup:
for track in tracks:
book.get(track.icao, track.callsign)
book.wait(20.0)
book.save()
title = f"bandsaunter — {paths[0].name}"
lines = report(tracks, book if args.lookup else None, title=title)
console.print(escape("\n".join(lines)), highlight=False)
if args.report is not None:
where = Path(args.report).expanduser() if args.report \
else paths[0].with_suffix(".txt")
try:
where.write_text("\n".join(lines))
console.print(f"[green]{where}[/green]")
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
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)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if not args.draw:
return 0
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)
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 None
return path
return []
return logs[-1:]
def cmd_analyze(args) -> int:
@ -1195,6 +1446,7 @@ def main(argv=None) -> int:
"config": cmd_config, "transcribe": cmd_transcribe,
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
"flights": cmd_flights,
}
try:
return handlers[args.command](args)