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:
parent
a8a8548369
commit
eae60cb04d
15 changed files with 3857 additions and 177 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue