bandsaunter/bandsaunter/aircraft.py
The Dust Council 8eb3bdbb86 Ask a service that knows which leg it is, and fetch a map worth the screen
A callsign is a flight number rather than a leg, and the free registers hold
one route per number, so an aircraft over Arizona kept being handed a hop
between two airports in Texas.  Nothing on the air settles it: ADS-B carries
no origin or destination.  A commercial schedule service does know, because
it holds the day's actual movements.

Four are wired up and all four are optional: FlightAware AeroAPI,
Flightradar24, OAG and Cirium.  Each is asked before the free databases and
each answers for the moment the aircraft was overhead rather than for the
flight number in general, so the leg chosen is the one that was in the air.
With no keys set nothing changes at all: a source with no key is skipped
rather than asked and refused, and the free databases answer as before.

Keys come from the environment and are never written to the settings file,
because a settings file is meant to be copied between machines and pasted
into a message asking for help, and an API key is not.  There is a test that
holds that line.

None of the four has been run against its live service, since each wants a
paid account.  They were written from the published response shapes and are
tested against those shapes, so each reader finds what it recognises and
returns nothing otherwise: a service that has changed since costs a route
rather than a scan.  Cirium's plain departureTime is local and carries no
offset, so the UTC field is preferred where it is there -- reading the local
one as UTC is up to half a day out, which is exactly far enough to pick the
wrong leg of the same number.  Reading now happens inside the same guard as
asking, as an answer shaped differently from the documented one is the
failure most likely to actually happen.

And the map.  The zoom is now chosen from how wide the picture is rather
than from the area alone, with half again over the width fetched and
averaged down, since a downscaled tile is sharp and an upscaled one is not.
The window fetches a little more world than it shows so panning does not
leave the ground blank, and now fetches that bigger piece at the bigger
piece's own size: rendering it into the window's own pixels and stretching
it back was a fifth of an upscale over the whole map, which is what a sharp
map looks like when it looks blurred.  The comment in the fetcher said the
opposite of what the code did, which is how it stayed hidden.

At 1920 by 1080 over a hundred miles the tiles now hold about 1.6 times the
pixels the window wants.  At 3840 by 2160 the tile budget is reached, the
zoom stops climbing and the map is enlarged after all; a smaller radius buys
the detail back, and somebody else's tile server is not a thing to fetch a
thousand tiles from for one picture.  The README says so rather than
implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 20:19:53 -07:00

1024 lines
45 KiB
Python

"""Listening to aircraft, and drawing where they went, from either front end.
The command line and the menus do the same two things here -- park on 1090 MHz
and write down what arrives, then turn a log of that into a map -- so the doing
of it lives here and both front ends call it. Neither imports the other, and
the options are described once, in the same shape as every other setting in the
program, so the menu can print help for each one without knowing what any of
them mean.
ADS-B does not go through the scanner and cannot be made to: it is a megabit a
second, which needs at least two megasamples a second of raw receiver output,
and the scan path decimates everything to a channel twelve and a half kilohertz
wide long before a decoder sees it. A scan of 1090 MHz records the envelope of
the bursts as clicks in a WAV file and decodes nothing, which is the mistake
:func:`scanning_aircraft_band` exists to catch.
"""
from __future__ import annotations
import time
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
import yaml
from .adsb import ADSB_HZ, SAMPLE_RATE
from .flightlog import read_position
from .settings import Setting, format_value
__all__ = ["AircraftOptions", "OPTIONS", "listen", "watch", "draw",
"logs_in", "open_device", "open_log", "pump", "finish",
"windowed",
"format_option",
"load_options", "save_options", "options_path",
"scanning_aircraft_band", "AIRCRAFT_BANDS", "SCAN_WARNING"]
# ---------------------------------------------------------------------------
# The bands that look like a scan and are not one
# ---------------------------------------------------------------------------
# (name, low, high, what to do instead)
AIRCRAFT_BANDS = (
("ADS-B", 1_089_000_000.0, 1_091_000_000.0, "bandsaunter adsb"),
("UAT / ADS-B 978", 977_000_000.0, 979_000_000.0, None),
)
SCAN_WARNING = (
"These ranges cover a band the scanner cannot decode: {bands}. "
"The signalling is a megabit a second and the scan path is 12.5 kHz "
"wide, so a scan of it records clicks and finds no aircraft."
)
def scanning_aircraft_band(ranges) -> str:
"""Warn when a sweep covers a band that needs the ADS-B mode instead.
Returns the warning to print, or an empty string. The band plan lists
1090 MHz because that is where ADS-B is, and choosing it from the band
plan is the obvious thing to do and the wrong one; saying so before the
sweep starts costs a line and saves an evening.
"""
hit = []
for name, low, high, _ in AIRCRAFT_BANDS:
for r in ranges or ():
start = float(getattr(r, "start", 0.0) or 0.0)
stop = float(getattr(r, "stop", start) or start)
if start <= high and stop >= low:
hit.append(name)
break
if not hit:
return ""
return SCAN_WARNING.format(bands=", ".join(hit))
# ---------------------------------------------------------------------------
# The options, described the way every other setting is
# ---------------------------------------------------------------------------
@dataclass
class AircraftOptions:
"""Everything the aircraft mode can be told, in one place.
Kept apart from :class:`~bandsaunter.config.ScanConfig` because none of it
controls a scan: a sweep has no sample rate this high, no animation and no
aircraft. Saved in its own small file for the same reason.
"""
# -- listening ------------------------------------------------------
seconds: float = 0.0
rate: float = float(SAMPLE_RATE)
gain: str = "auto"
device: int = 0
frames: bool = False
log: bool = True
lookup: bool = True
schedules: str = ""
kml: bool = False
hold: float = 45.0
speed_unit: str = "knots"
draw_after: bool = False
simulate: bool = False
near: str = "47.55,-122.30"
# -- drawing --------------------------------------------------------
picture: str = "gif"
length: float = 30.0
speed: float = 0.0
fps: float = 12.0
width: int = 960
trail: float = 0.0
stale: float = 300.0
fade: float = 20.0
labels: bool = True
radius: float = 100.0
location: str = ""
recheck: bool = False
basemap: bool = True
airports: bool = True
map_brightness: int = 70
tile_url: str = ""
def validate(self) -> list[str]:
out = []
if self.rate < SAMPLE_RATE:
out.append(f"sample rate must be at least {SAMPLE_RATE/1e6:g} MS/s "
"or a bit is too narrow to see")
if self.fps <= 0:
out.append("frames a second must be more than zero")
if self.width < 160:
out.append("the picture must be at least 160 pixels across")
if self.seconds < 0 or self.length <= 0:
out.append("times cannot be negative")
return out
def to_dict(self) -> dict:
return asdict(self)
O = Setting
OPTIONS: tuple[Setting, ...] = (
# -- listening ------------------------------------------------------
O("seconds", "Listen for", "Listening", "float",
"how long to listen before stopping (0 = until interrupted)",
"A whole evening is 0: listening runs until control-C, and everything "
"heard is on the disk as it arrives, so stopping never loses anything. "
"A number is useful for a quick look at whether the aerial hears "
"anything at all.",
unit="s", minimum=0.0, flags=("--seconds",), example="600",
guidance="Sixty seconds is enough to know whether aircraft are being "
"heard. An evening of traffic wants no limit."),
O("simulate", "Invent a sky", "Listening", "bool",
"fly imaginary aircraft past an imaginary receiver",
"Six aircraft that are not there, broadcasting real frames with real "
"checksums through the real decoder. Nothing touches the receiver, so "
"the log, the lookups, the report and the map can all be tried before "
"an aerial exists.",
flags=("--simulate",),
guidance="Turn this on to see what the whole thing does without "
"hardware. Turn it off to hear real aircraft."),
O("near", "Imaginary sky near", "Listening", "text",
"where the simulated aircraft are flying",
"Latitude and longitude, as two numbers. Only used when the sky is "
"invented; it decides where the map ends up centred.",
flags=("--near",), example="47.55,-122.30", metavar="LAT,LON"),
O("device", "Receiver", "Listening", "int",
"which receiver to use, when more than one is plugged in",
"The index shown by `bandsaunter devices`. Zero unless you have "
"several dongles.",
minimum=0, flags=("--device",), example="0"),
O("gain", "Gain", "Listening", "gain",
"tuner gain in dB, or automatic",
"ADS-B is a weak burst from a long way off, and the automatic gain "
"control usually does well enough. A fixed high gain can hear more "
"where there is nothing strong nearby to overload the front end.",
flags=("--gain",), example="auto"),
O("rate", "Sample rate", "Listening", "float",
"how fast to sample; two megasamples a second is the minimum",
"One microsecond per bit means two samples per bit at 2 MS/s, which is "
"the least that can read one. Higher rates decode a little more of the "
"weak traffic and cost proportionally more processing.",
unit="Hz", minimum=float(SAMPLE_RATE), flags=("--rate",),
example="2000000"),
O("frames", "Show every frame", "Listening", "bool",
"print each frame as it arrives, rather than a running count",
"Every frame, with what was read out of it. Useful once, to see that "
"it is working; unreadable for an evening.",
flags=("--frames",)),
O("log", "Write the log", "Listening", "bool",
"write every frame to a file as it arrives",
"The JSON Lines log is what the report and the map are made from, and "
"it holds the raw hexadecimal of every frame beside what was decoded "
"from it. Turning this off leaves nothing behind but the screen.",
flags=("--log",), off_flags=("--no-log",)),
O("lookup", "Look the aircraft up", "Listening", "bool",
"ask the public registers who each aircraft is",
"Two registers are asked -- adsbdb, then hexdb -- for the "
"registration, the type, the operator and the route, and the answers "
"are cached for a month. Only the address and callsign heard on the "
"air are ever sent. What the address block and the callsign say on "
"their own is worked out offline either way.",
flags=("--lookup",), off_flags=("--no-lookup",)),
O("schedules", "Schedule services", "Listening", "text",
"which paid schedule services to ask, in order (blank = all with keys)",
"A callsign is a flight number and an airline runs the same number over "
"several legs in a day, so the free route databases -- which hold one "
"route per number -- often name somebody else's leg. A commercial "
"schedule service holds the timetable and the day's movements and can "
"say which leg is in the air now. Four are wired up: flightaware, "
"flightradar24, oag and cirium. Each wants a key, none is required, "
"and a service with no key is skipped in silence. Keys are read from "
"the environment rather than kept here, because a settings file gets "
"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.",
example="flightaware,cirium",
guidance="Leave it blank unless you want one service tried before "
"another. With no keys set, nothing changes."),
O("kml", "Also write a KML", "Listening", "bool",
"write the flight paths for Google Earth as well",
"One line per aircraft on the globe, with a pin where it was last "
"heard, in a .kml beside the log.",
flags=("--kml",)),
O("hold", "Keep on screen for", "Listening", "float",
"how long an aircraft stays on the display after its last frame",
"An aircraft that has gone out of range stops sending, and its line "
"would otherwise sit there for the rest of the evening saying the same "
"thing. When nothing has been heard from it for this long the line is "
"removed and everything below it moves up. Nothing is lost by it: the "
"log holds every frame, and the report at the end lists every aircraft "
"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."),
O("speed_unit", "Speed in", "Listening", "choice",
"what to show speeds and distances in",
"Aircraft broadcast knots and the log keeps knots, because that is "
"what the standard sends; this is the unit they are shown in. It "
"changes the heading of the live display, the speeds written beside "
"each aircraft on the map and in the report, and the distance unit "
"that goes with them -- nautical miles with knots, statute miles with "
"miles an hour, kilometres with km/h, so that one picture never "
"carries two different miles.",
choices=("knots", "mph", "kph"),
guidance="knots is what aviation uses and what the aircraft actually "
"said. mph or kph if that is what means something to you."),
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.",
flags=("--map",)),
# -- drawing --------------------------------------------------------
O("picture", "Picture", "Drawing", "choice",
"what kind of picture to draw",
"gif is an animation that plays anywhere and needs nothing installed. "
"mp4 is smaller and smoother but needs ffmpeg. png is one still "
"picture of the whole session, every path drawn at once.",
choices=("gif", "mp4", "png"), flags=("--out",),
guidance="Start with gif. Use png when you want one picture to look at "
"or send."),
O("length", "Animation length", "Drawing", "float",
"how long the animation should run for",
"The whole session is fitted into this many seconds, so an evening of "
"flying plays in half a minute. Ignored when a speed is given.",
unit="s", minimum=1.0, flags=("--seconds",), example="30"),
O("speed", "Speed", "Drawing", "float",
"seconds of flying per second of animation (0 = fit to the length)",
"60 means a minute of real flying every second. Setting this overrides "
"the length above: a long session simply makes a longer animation.",
unit="x", minimum=0.0, flags=("--speed",), example="60"),
O("fps", "Frames a second", "Drawing", "float",
"how many frames of animation each second holds",
"Twelve is smooth enough for aircraft, which do not move quickly on a "
"map. A GIF can only hold whole hundredths of a second per frame, so "
"the real rate is rounded to the nearest one it can express.",
minimum=1.0, flags=("--fps",), example="12"),
O("width", "Picture width", "Drawing", "int",
"how many pixels across the picture is",
"The height follows from the shape of the area the aircraft covered, "
"so that a mile across looks like a mile up the picture.",
unit="px", minimum=160, flags=("--width",), example="960"),
O("trail", "Trail", "Drawing", "float",
"how much of the path to leave behind each aircraft (0 = all of it)",
"The whole flight is drawn by default, which is what makes the picture "
"a map of the evening rather than a snapshot. A number of seconds "
"leaves a comet tail instead, which is easier to follow when many "
"aircraft cross the same piece of sky.",
unit="s", minimum=0.0, flags=("--trail",), example="0"),
O("fade", "Fade out over", "Drawing", "float",
"how long an aircraft takes to fade away once it has gone quiet",
"An aircraft that stops transmitting has not stopped existing, and "
"taking it off the picture between one frame and the next says that it "
"did. Instead it is left where it was last actually seen and fades from "
"there, which reads as an aircraft going quiet rather than as a blink. "
"Nothing is invented by it: the fading happens at the last known "
"position, never at a reckoned one, because the reason for giving up on "
"an aircraft in the first place is that where it would be by now is a "
"guess. Zero takes it away the moment it is given up on.",
unit="s", minimum=0.0, flags=("--fade",), example="20",
guidance="Long enough to notice, short enough that a busy sky is not "
"half ghosts."),
O("stale", "Forget after", "Drawing", "float",
"stop drawing an aircraft this long after its last report",
"Between reports an aircraft is dead-reckoned from the speed and "
"heading it last gave. After a few minutes of that it has flown fifty "
"miles on a guess, so it is dropped instead of invented.",
unit="s", minimum=1.0, flags=("--stale",), example="300"),
O("radius", "Map radius", "Drawing", "float",
"how far around the receiver the map reaches (0 = fit whatever was heard)",
"An aerial hears a hundred miles on a good day, and a position that "
"decoded wrongly can land anywhere on Earth. A map drawn to fit "
"everything heard is therefore drawn to fit the mistakes: the aircraft "
"come out a pixel wide in the middle of an empty continent. This frames "
"the picture on the receiver instead, so the scale stays the same from "
"one evening to the next and anything further out is left off the edge. "
"In the same unit as the speeds -- nautical miles with knots, statute "
"miles with mph, kilometres with kph.",
minimum=0.0, flags=("--radius",), example="100",
guidance="Set it to what your aerial can really hear. Zero goes back "
"to fitting whatever turned up, mistakes and all."),
O("location", "Receiver at", "Drawing", "text",
"where the receiver is, as latitude,longitude (blank = work it out)",
"The centre of the map. Left blank it is taken from the middle of "
"everything heard, which is very close to right: a receiver hears "
"aircraft all around it, and the middle is a median rather than an "
"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"),
O("recheck", "Check the positions", "Drawing", "bool",
"throw out positions the aircraft could not have been in",
"For logs recorded before the decoder checked how old the two halves "
"of a position were. A compact-position report is half a position: an "
"even frame and an odd one, and the pair only means anything while the "
"aircraft has not moved between them. An even frame kept from ten "
"minutes ago decodes against a fresh odd one to a place on the wrong "
"side of the world, and that gets written down as confidently as a "
"real position. This reads the log back and keeps, for each aircraft, "
"the longest run of positions that could describe one aeroplane. "
"Nothing is changed in the log itself.",
flags=("--recheck",),
guidance="Worth turning on for anything recorded before this version. "
"Newer logs have the check applied as they are written, so it "
"finds almost nothing."),
O("airports", "Mark the airports", "Drawing", "bool",
"mark every aerodrome on the map, not only the ones flown between",
"A route names the two airports its aircraft is flying between, and "
"those are almost never the ones underneath: a receiver hears aircraft "
"over its own county, and the county's airports are what say where on "
"the map you are looking. They are asked for once per area from the "
"same map data the tiles are drawn from, and kept on disk for a month "
"afterwards, since a runway does not move.",
flags=("--airports",), off_flags=("--no-airports",),
guidance="Turn it off for a picture with nothing but the aircraft on "
"it, or where there is no network and nothing cached."),
O("map_brightness", "Map brightness", "Drawing", "int",
"how bright the map under the aircraft is drawn, as a percentage",
"The map is the ground, not the subject, so it is drawn dark enough "
"that the aircraft and their trails stay the brightest things on the "
"picture. Too dark and a coastline cannot be made out at all; too "
"bright and a city washes out the aircraft crossing it. This is which "
"way to err on the screen you are actually looking at.",
unit="%", minimum=10, maximum=100, flags=("--map-brightness",),
metavar="PERCENT", example="70",
guidance="Turn it up until the coast and the roads are readable, and "
"no further."),
O("basemap", "Map underneath", "Drawing", "bool",
"draw a real map under the flight paths",
"A flight path over a black rectangle says how the aircraft moved and "
"nothing about where it was; over a coastline it says which airport it "
"left. The map is fetched from a standard tile server the first time an "
"area is drawn and kept on the disk afterwards, so drawing the same "
"evening again costs nothing and needs no network. A few dozen tiles "
"at most, dimmed so the aircraft stay the brightest thing on the "
"picture, and the credit the tiles require is written on it.",
flags=("--basemap",), off_flags=("--no-basemap",),
guidance="Turn it off for a picture with nothing but the tracks on it, "
"or where there is no network and no cached tiles."),
O("tile_url", "Tile server", "Drawing", "text",
"where the map tiles come from",
"Any server that serves 256-pixel tiles as {z}/{x}/{y}.png will do, "
"including one of your own. The default is the standard "
"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.",
example="https://tile.openstreetmap.org/{z}/{x}/{y}.png"),
O("labels", "Label the aircraft", "Drawing", "bool",
"write the callsign, height and speed beside each aircraft",
"Height is the flight level -- hundreds of feet -- the way it is said "
"on the radio. Turning labels off leaves the shapes of the traffic, "
"which is worth seeing on a busy evening.",
flags=("--labels",), off_flags=("--no-labels",)),
)
OPTION_GROUPS = ("Listening", "Drawing")
def in_group(group: str) -> list[Setting]:
return [o for o in OPTIONS if o.group == group]
def by_key(key: str) -> Setting | None:
return next((o for o in OPTIONS if o.key == key), None)
# What a zero means, per option: "0 s" is true and unhelpful.
_ZERO_MEANS = {"seconds": "until stopped", "speed": "fit to the length",
"trail": "the whole path"}
def format_option(option: Setting, value) -> str:
"""Render an option the way the menu should show it."""
if not value and option.key in _ZERO_MEANS:
return _ZERO_MEANS[option.key]
return format_value(option, value)
def describe(options: AircraftOptions) -> str:
"""One line for a menu: what listening and drawing would do now."""
how_long = ("until stopped" if not options.seconds
else f"{options.seconds:g} s")
where = "simulated" if options.simulate else "1090 MHz"
return (f"{where}, {how_long}, "
f"{'looked up' if options.lookup else 'no lookups'}, "
f"{options.picture}")
# ---------------------------------------------------------------------------
# Where the options are kept
# ---------------------------------------------------------------------------
def options_path(directory=None) -> Path:
from .config import DEFAULT_CONFIG_DIR
return Path(directory or DEFAULT_CONFIG_DIR) / "aircraft.yaml"
def load_options(directory=None) -> AircraftOptions:
"""The saved options, or the defaults. A broken file is not an error."""
options = AircraftOptions()
try:
body = yaml.safe_load(options_path(directory).read_text()) or {}
except (OSError, ValueError, yaml.YAMLError):
# A hand-edited file with a typo in it should cost the defaults, not
# the menu it is read from.
return options
if not isinstance(body, dict):
return options
known = set(options.__dict__)
for key, value in body.items():
if key in known and value is not None:
try:
setattr(options, key, type(getattr(options, key))(value))
except (TypeError, ValueError):
pass
return options
def save_options(options: AircraftOptions, directory=None) -> Path:
path = options_path(directory)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w") as fh:
yaml.safe_dump(options.to_dict(), fh, sort_keys=False,
default_flow_style=False)
return path
def logs_in(directory) -> list[Path]:
"""Every ADS-B log in a directory, newest first."""
try:
found = list(Path(directory).expanduser().glob("adsb_*.jsonl"))
except OSError:
return []
return sorted(found, key=lambda p: p.stat().st_mtime, reverse=True)
def coordinates(text: str) -> tuple[float, float]:
"""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):
return 47.55, -122.30
# ---------------------------------------------------------------------------
# Listening
# ---------------------------------------------------------------------------
@dataclass
class Heard:
"""What one listening session came to."""
frames: int = 0
registry: object = None
log_path: Path | None = None
report_path: Path | None = None
kml_path: Path | None = None
picture: object = None
tracks: list = field(default_factory=list)
@property
def aircraft(self) -> int:
return len(self.registry) if self.registry is not None else 0
def windowed() -> bool:
"""Whether the realtime window can be opened on this machine."""
from . import livemap
return livemap.available()
def open_device(console, options: AircraftOptions):
"""The receiver, or an invented sky, or None if neither can be had."""
from rich.panel import Panel
from rich.text import Text
from .adsb import SimulatedSky, default_sky
from .device import RtlSdrDevice, RtlSdrError
if options.simulate:
console.print("[yellow]simulated: these aircraft are not there."
"[/yellow]")
return SimulatedSky(default_sky(*coordinates(options.near)),
sample_rate=options.rate, realtime=True).open()
try:
device = RtlSdrDevice(index=options.device,
sample_rate=int(options.rate),
gain=options.gain,
agc=options.gain == "auto")
device.open()
except RtlSdrError as exc:
console.print(Panel(Text(str(exc)),
title="[red]cannot open the receiver",
border_style="red"))
return None
return device
def open_log(console, options: AircraftOptions, output_dir: str,
started: float, log_path=None):
"""The frame log, or None if it was not wanted or cannot be written."""
from .flightlog import FlightLog
if not options.log:
return None
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
where = Path(log_path).expanduser() if log_path else \
Path(output_dir).expanduser() / f"adsb_{stamp}.jsonl"
try:
return FlightLog(where, frequency=ADSB_HZ, sample_rate=options.rate,
receiver="simulated" if options.simulate else
f"device {options.device}", started=started)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return None
def pump(device, options: AircraftOptions, registry, log, book,
started: float, on_block=None, on_frame=None, stopping=None) -> int:
"""Read the receiver until it stops, or until told to.
The one loop both the terminal board and the window are driven from, so
that what is written to the log cannot depend on which one you happened
to be looking at.
"""
from .adsb import decode_frames
total = 0
device.tune(ADSB_HZ)
block = int(options.rate) # a second at a time
while stopping is None or not stopping():
at = time.time()
samples = device.read_samples(block)
if samples is None or samples.size == 0:
break
for frame in decode_frames(samples, options.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 / options.rate
craft = registry.add(frame, when=when)
total += 1
if log is not None:
log.append(frame, craft, when=when)
if options.lookup:
book.get(frame.icao, craft.callsign)
if on_frame is not None:
on_frame(frame, craft)
if on_block is not None:
on_block(total)
if options.seconds and time.time() - started >= options.seconds:
break
return total
def listen(console, options: AircraftOptions, output_dir: str,
log_path=None) -> Heard:
"""Park on 1090 MHz and write down the aircraft overhead.
Everything heard goes into the log as it arrives, because an aircraft is
overhead for four minutes and then gone: the screen is for the person
watching, and the log is for the report, the map and everything
afterwards.
"""
from .adsb import AircraftRegistry
from .flights import FlightBook
heard = Heard()
device = open_device(console, options)
if device is None:
return heard
started = time.time()
log = open_log(console, options, output_dir, started, log_path)
registry = AircraftRegistry()
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
f"{options.rate/1e6:g} MS/s — control-C to stop[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
# The registers are asked while the listening runs rather than after it,
# so a registration and a route appear on the line as they arrive. The
# book answers immediately with what it knows and fills itself in later.
book = FlightBook(online=options.lookup,
schedules=schedule_names(options))
display, live = _open_display(console, options, book, started)
def on_block(total: int) -> None:
if live is not None:
display.update(registry, total,
log.path if log is not None else None)
live.update(display.render())
elif not options.frames and total:
console.print(f"[grey62]{len(registry)} aircraft, "
f"{total} frames[/grey62]", highlight=False)
def on_frame(frame, craft) -> None:
if options.frames:
console.print(f"[cyan]{frame.icao}[/cyan] "
f"{frame.describe()}", highlight=False)
total = 0
try:
total = pump(device, options, registry, log, book, started,
on_block=on_block, on_frame=on_frame)
except KeyboardInterrupt:
pass
finally:
if live is not None:
live.stop()
console.print("[grey62]stopped listening[/grey62]")
device.close()
if log is not None:
log.close()
heard.log_path = log.path
return finish(console, options, output_dir, heard, registry, book,
started, total)
def watch(console, options: AircraftOptions, output_dir: str,
log_path=None) -> Heard:
"""The same listening, in a window with a map in it.
The receiver runs on its own thread and the window paints from a copy of
what it found, so that a slow repaint can never cost a frame and a slow
network can never stop the picture moving. Everything else -- the log,
the report, the lookups, the map drawn at the end -- is exactly what the
passive capture does, because it is the same code.
"""
import threading
from rich.panel import Panel
from rich.text import Text
from . import livemap
from .adsb import AircraftRegistry
from .flightlog import read_position
from .flights import FlightBook
heard = Heard()
if not livemap.available():
console.print(Panel(Text(livemap.MISSING_QT),
title="[yellow]no window to open",
border_style="yellow"))
return heard
device = open_device(console, options)
if device is None:
return heard
started = time.time()
log = open_log(console, options, output_dir, started, log_path)
registry = AircraftRegistry()
book = FlightBook(online=options.lookup,
schedules=schedule_names(options))
sky = livemap.Sky(unit=options.speed_unit, hold=options.hold,
home=read_position(options.location),
radius_nm=radius_in_nm(options) or 100.0,
brightness=max(10, options.map_brightness) / 100.0,
fade=max(0.0, options.fade))
sky.started = started
sky.log_name = log.path.name if log is not None else ""
if options.simulate:
sky.note = "simulated"
def told_about(craft):
"""What a register says about one aircraft, for the window."""
if not options.lookup:
return None
entry = book.get(craft.icao, craft.callsign)
if craft.located:
# A source that listed a whole day's stops has said which leg
# this is, once there is a position to read it with.
entry = book.resolve(entry, craft.latitude, craft.longitude)
return entry
def on_block(total: int) -> None:
sky.update([livemap.blip_for(craft, told_about(craft))
for craft in registry.aircraft.values()],
total, len(registry))
counted = {"frames": 0}
def listening() -> None:
try:
counted["frames"] = pump(device, options, registry, log, book,
started, on_block=on_block,
stopping=lambda: sky.stopping)
except Exception as exc: # a window must survive it
sky.note = str(exc)[:60]
finally:
# However it ended -- the time ran out, the receiver stopped, or
# it fell over -- the window is told, and shuts itself.
sky.finished = True
threads = [threading.Thread(target=listening, daemon=True,
name="adsb-receiver")]
if options.basemap:
threads.append(threading.Thread(
target=livemap.fetch_ground, args=(sky, options.tile_url),
daemon=True, name="adsb-basemap"))
for thread in threads:
thread.start()
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz — close the "
"window to stop[/grey62]")
if log is not None:
console.print(f"[grey62]writing {log.path}[/grey62]")
try:
livemap.show(sky, "bandsaunter — aircraft on 1090 MHz")
finally:
sky.stopping = True
for thread in threads:
thread.join(timeout=3.0)
device.close()
if log is not None:
log.close()
heard.log_path = log.path
return finish(console, options, output_dir, heard, registry, book,
started, counted["frames"])
def finish(console, options: AircraftOptions, output_dir: str, heard: Heard,
registry, book, started: float, total: int) -> Heard:
"""Everything that happens once the listening stops.
Shared by the passive capture and the window, so that closing a window
leaves exactly the same files behind as pressing control-C does.
"""
from .flightlog import read_logs, report, write_kml
heard.frames = total
heard.registry = registry
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 heard
aircraft_table(console, registry, total, options.speed_unit)
if options.lookup:
book.wait(12.0)
book.save()
lookup_table(console, registry, book)
heard.tracks = read_logs(heard.log_path) if heard.log_path \
else tracks_from(registry)
if options.kml:
where = (heard.log_path.with_suffix(".kml") if heard.log_path
else Path(output_dir).expanduser() / "aircraft.kml")
written = write_kml(where, heard.tracks,
book if options.lookup else None,
unit=options.speed_unit)
heard.kml_path = written
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if heard.log_path is not None:
told = heard.log_path.with_suffix(".txt")
try:
told.write_text("\n".join(report(
heard.tracks, book if options.lookup else None,
title=f"bandsaunter — aircraft heard "
f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}",
unit=options.speed_unit)))
heard.report_path = told
console.print(f"[green]{told}[/green]")
except OSError as exc:
console.print(f"[red]cannot write {told}: {exc}[/red]")
if options.draw_after:
heard.picture = draw(console, options, heard.tracks,
out_path=_picture_path(heard.log_path, options,
output_dir),
book=book if options.lookup else None)
return heard
def _open_display(console, options: AircraftOptions, book, started: float):
"""A live table where there is a terminal to draw it on, else nothing.
Piped output, a test or a log file gets the running count it had before:
a display that redraws itself four times a second is unreadable as a
stream of text, and worse than useless in a file.
"""
if options.frames or not getattr(console, "is_terminal", False):
return None, None
from rich.live import Live
from .ui import AircraftDisplay
display = AircraftDisplay(console, book=book if options.lookup else None,
hold=options.hold, unit=options.speed_unit)
display.started = started
live = Live(display.render(), console=console, refresh_per_second=4,
screen=False, transient=False, vertical_overflow="crop")
live.start()
return display, live
def _picture_path(log_path, options: AircraftOptions, output_dir: str) -> Path:
suffix = "." + options.picture
if log_path is not None:
return Path(log_path).with_suffix(suffix)
return Path(output_dir).expanduser() / f"aircraft{suffix}"
# ---------------------------------------------------------------------------
# Drawing
# ---------------------------------------------------------------------------
def checked(console, options: AircraftOptions, tracks):
"""Throw out impossible positions, if asked, and say how many went."""
if not options.recheck:
return tracks
from .flightlog import recheck as recheck_tracks
before = sum(len(t.fixes) for t in tracks)
tracks, dropped = recheck_tracks(tracks)
if dropped:
console.print(f"[yellow]dropped {dropped:,} of {before:,} positions "
f"({100.0 * dropped / max(1, before):.1f}%) that no "
"aircraft could have been in[/yellow]")
else:
console.print("[grey62]every position checks out[/grey62]")
return tracks
def schedule_names(options: AircraftOptions) -> list[str]:
"""The schedule services to ask, in the order given."""
return [name.strip() for name in str(options.schedules or "").split(",")
if name.strip()]
def radius_in_nm(options: AircraftOptions) -> float:
"""The map radius as the drawing wants it, in nautical miles.
The option is in whatever the speeds are in, because a picture that
measured its speeds in one unit and its own extent in another would be
a puzzle rather than a map.
"""
from .flightlog import speed_unit
return max(0.0, float(options.radius)) / speed_unit(options.speed_unit)[3]
def draw(console, options: AircraftOptions, tracks, out_path, book=None):
"""Draw the map, saying what it is drawing and what came out of it."""
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, fps=options.fps,
seconds=options.length, speed=options.speed,
width=options.width, trail_seconds=options.trail,
stale=options.stale, labels=options.labels,
fade=max(0.0, options.fade),
unit=options.speed_unit, ground=options.basemap,
tile_url=options.tile_url,
brightness=max(10, options.map_brightness) / 100.0,
airports=options.airports,
radius_nm=radius_in_nm(options),
centre=read_position(options.location))
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]{exc}[/red]")
return None
if drawn is None:
console.print("[yellow]nothing was placed on the map: no aircraft "
"reported a position[/yellow]")
return None
size = drawn.path.stat().st_size / 1e6
console.print(f"[green]{drawn.path}[/green] "
f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
return drawn
def draw_log(console, options: AircraftOptions, paths, out_path=None):
"""Read logs back and draw them: the whole of what ``flights`` does."""
from .flightlog import read_logs
from .flights import FlightBook
paths = [Path(p).expanduser() for p in paths]
tracks = read_logs(paths)
if not tracks:
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
return None
tracks = checked(console, options, tracks)
book = FlightBook(online=options.lookup,
schedules=schedule_names(options))
if options.lookup:
for track in tracks:
book.get(track.icao, track.callsign)
book.wait(20.0)
book.save()
if out_path is None:
out_path = paths[0].with_suffix("." + options.picture)
return draw(console, options, tracks, out_path,
book if options.lookup else None)
# ---------------------------------------------------------------------------
# What was heard, on the screen
# ---------------------------------------------------------------------------
def aircraft_table(console, registry, total: int,
unit: str = "knots") -> None:
"""What was heard, as it was heard: no register, only the air."""
from rich.table import Table
from .flightlog import in_speed, speed_label
t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
header_style="bold")
for column in ("ICAO", "callsign", "altitude", "position",
f"speed ({speed_label(unit)})", "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"{in_speed(craft.ground_speed_kt, unit):.0f} "
f"{craft.track_deg:.0f}°"
if craft.ground_speed_kt else ""),
str(craft.messages))
console.print(t)
def lookup_table(console, registry, book) -> None:
"""And what the registers say about them, kept separate on purpose."""
from rich.table import Table
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 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 summarise(options: AircraftOptions) -> str:
"""The options as one line, for a menu row."""
return ", ".join(f"{o.label.lower()} {format_option(o, getattr(options, o.key))}"
for o in OPTIONS[:3])