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:
parent
4239635f74
commit
96fc21ac7d
18 changed files with 3298 additions and 281 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue