The map brightness setting could not make the map visible on a vector theme, which is the one place it was needed. Those themes want the ground well out of the way -- a tinted photograph of a county behind the vectors is the one thing that stops a vector display looking like one -- and that was done by multiplying the setting by about a quarter. A multiplier is a ceiling: turned the whole way up, the setting still gave a map at a tenth the brightness the default theme gives, which is to say invisible, and no amount of turning it up did anything about that. It is a curve now rather than a ceiling. The theme raises the setting to a power, so the middle of the range is still quiet -- seventy per cent lands where the old quarter did, which is the look these themes are for -- and the top of the range is a full-brightness map on every theme there is. On the green phosphor the setting now spans a luminance of six to seventy where it used to stop at twenty-one. And the options are in six groups rather than one list: receiver, listening, aircraft, animation, the map, labels. Thirty-three of them on one screen is a wall rather than a menu. A number opens a group and a number inside it changes an option, with the numbers still being each option's place in the whole list so that the same number means the same option wherever it is typed -- which meant reordering the list so that every group is contiguous, and there is a test that says so. A group menu makes a known option harder to reach than a flat list did, so the name works too: typing "map brightness" at the top goes straight to it, and part of a name lists everything it could mean. A name that matches exactly wins outright, so "speed" reaches the setting called speed rather than that one and every other whose description happens to mention the word. One thing to know: a bare number at the top of the menu now opens a group where it used to edit the option of that number. The tests that drove the menu that way would have gone on silently editing whatever option shared the number, so they ask by name now, and one of them checks that a group number changes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
1079 lines
48 KiB
Python
1079 lines
48 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
|
|
rings: bool = True
|
|
window_rings: bool = True
|
|
theme: str = "night"
|
|
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, ...] = ( O("device", "Receiver", "Receiver", "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", "Receiver", "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", "Receiver", "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("location", "Receiver at", "Receiver", "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("simulate", "Invent a sky", "Receiver", "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", "Receiver", "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"),
|
|
# -- 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("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("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("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",)),
|
|
O("lookup", "Look the aircraft up", "Aircraft", "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", "Aircraft", "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("recheck", "Check the positions", "Aircraft", "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."),
|
|
# -- drawing --------------------------------------------------------
|
|
O("picture", "Picture", "Animation", "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", "Animation", "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", "Animation", "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", "Animation", "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", "Animation", "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", "Animation", "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", "Animation", "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", "Animation", "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("basemap", "Map underneath", "The map", "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", "The map", "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("theme", "Colour theme", "The map", "choice",
|
|
"how the map looks: the colours, and whether the lines glow",
|
|
"The default draws a night-blue ground with height as colour, low "
|
|
"warm to high cold, which is what every other aircraft map does and "
|
|
"is the easiest to read. The rest are the screens the phrase 'air "
|
|
"defence display' calls to mind: a black tube, one phosphor, and thin "
|
|
"bright vector lines with a halo round them. Those have one colour to "
|
|
"spend, so height is brightness instead -- low is dim, high burns -- "
|
|
"the ground underneath is pushed well back so the lines carry the "
|
|
"picture, and a country is named in two letters rather than drawn as "
|
|
"a flag, because a flag needs half a dozen colours and a phosphor has "
|
|
"one. It applies to the window and to the animated pictures alike.",
|
|
choices=("night", "digital", "phosphor", "amber", "red"),
|
|
flags=("--theme",), metavar="NAME", example="phosphor",
|
|
guidance="night to read it, the others to look at it."),
|
|
O("map_brightness", "Map brightness", "The map", "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. The vector "
|
|
"themes bend the middle of this range down hard, because a tinted "
|
|
"photograph of a county behind the vectors is the one thing that "
|
|
"stops a vector display looking like one -- but the top of the range "
|
|
"is a full-brightness map on every theme, so if the ground is barely "
|
|
"visible this is the setting that fixes it.",
|
|
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. On a vector theme it takes rather more turning "
|
|
"up than on the default one."),
|
|
O("radius", "Map radius", "The map", "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("airports", "Mark the airports", "The map", "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("rings", "Range rings on the pictures", "The map", "bool",
|
|
"faint discs at a quarter, a half and three quarters of the radius",
|
|
"Concentric on the receiver and translucent, so that they stack: the "
|
|
"ground inside the innermost is lifted three times, the next twice, "
|
|
"the outer once. What that gives is a sense of how far away a thing "
|
|
"is without measuring anything -- an aircraft two shades in is about "
|
|
"halfway to the edge of what this receiver hears. Each is labelled "
|
|
"with its distance. They need a receiver position and a radius, and "
|
|
"are not drawn without both.",
|
|
flags=("--rings",), off_flags=("--no-rings",),
|
|
guidance="Turn it off for a picture with nothing on it but the "
|
|
"aircraft and the ground."),
|
|
O("window_rings", "Range rings in the window", "The map", "bool",
|
|
"the same discs on the realtime display",
|
|
"The same rings as the pictures get, on the window instead. They are "
|
|
"separate settings because the two are looked at differently: a "
|
|
"picture is studied and a window is glanced at, and the rings help "
|
|
"one more than the other depending on which you are doing.",
|
|
flags=("--window-rings",), off_flags=("--no-window-rings",),
|
|
guidance="Turn it off if the window is busy enough already."),
|
|
O("labels", "Label the aircraft", "Labels", "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",)),
|
|
O("speed_unit", "Speed in", "Labels", "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."),
|
|
)
|
|
|
|
OPTION_GROUPS = ("Receiver", "Listening", "Aircraft", "Animation", "The map", "Labels")
|
|
|
|
|
|
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))
|
|
# Set before the window is built, because the window reads its colours
|
|
# out of the palette this writes.
|
|
from .flightmap import set_theme
|
|
|
|
set_theme(options.theme)
|
|
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),
|
|
airports=options.airports,
|
|
rings=options.window_rings)
|
|
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")]
|
|
# One thread serves both the map and the aerodromes, and either on its
|
|
# own is reason enough to start it: the aerodromes are a separate
|
|
# question to a separate service, and asking for them with the tiles
|
|
# turned off is a perfectly ordinary thing to want.
|
|
if options.basemap or options.airports:
|
|
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, set_theme
|
|
|
|
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:
|
|
set_theme(options.theme)
|
|
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,
|
|
rings=options.rings,
|
|
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])
|