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:
The Dust Council 2026-09-04 00:05:32 -07:00
parent 4239635f74
commit 96fc21ac7d
18 changed files with 3298 additions and 281 deletions

View file

@ -24,7 +24,7 @@ from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
from .ranges import RangeError, ScanRange, parse_frequency
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
"first_run_setup", "TUIAbort"]
"aircraft_menu", "first_run_setup", "TUIAbort"]
_BACK = ("", "b", "back", "q", "quit", "x")
@ -192,6 +192,31 @@ def _pick_from(console: Console, cfg: ScanConfig,
console.print(" [yellow]some of these are below 24 MHz — they need "
"direct sampling and an HF antenna.[/yellow]")
console.print(f" [green]added {added} range(s)[/green]")
warn_about_aircraft_bands(console, cfg)
def warn_about_aircraft_bands(console: Console, cfg: ScanConfig) -> None:
"""Say so the moment a band that needs the aircraft mode is chosen.
The band plan lists 1090 MHz because that is where ADS-B is, so picking
it here is the obvious thing to do and the wrong one. It is not refused
-- looking at the spectrum there is a fair thing to want -- but it does
not happen silently, and the menu that does decode it is named.
"""
from . import aircraft as air
warning = air.scanning_aircraft_band(cfg.ranges)
if not warning:
return
console.print(Panel(Text.from_markup(
f"{warning}\n\n"
"Menu [cyan]5[/cyan], [bold]Aircraft (ADS-B)[/bold], listens to it "
"properly and draws where the aircraft went.\n\n"
"[grey62]Scanning it anyway is fine if what you want is the raw "
"spectrum \u2014 turn on 'Save raw IQ' to keep the samples."
"[/grey62]"),
title="[yellow]this band needs the aircraft mode",
border_style="yellow", padding=(0, 1)))
def _expand(answer: str, items: list) -> list:
@ -277,8 +302,8 @@ def _settings_table(console: Console, group: str, cfg: ScanConfig) -> list[st.Se
def setting_help(console: Console, setting: st.Setting,
cfg: ScanConfig) -> None:
default = ScanConfig()
cfg: ScanConfig, default=None) -> None:
default = ScanConfig() if default is None else default
body = [f"[bold]{setting.label}[/bold] [grey62]({setting.key})[/grey62]",
"", setting.help.capitalize() + "."]
if setting.detail:
@ -302,10 +327,17 @@ def setting_help(console: Console, setting: st.Setting,
border_style="blue", padding=(0, 1)))
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool:
"""Prompt for one value. Returns True if it changed."""
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig,
default=None, show_help=None) -> bool:
"""Prompt for one value. Returns True if it changed.
``cfg`` is whatever holds the value -- the scan config, or the aircraft
options, which are described by the same kind of table and so can be
edited by the same code. ``default`` is the object the built-in defaults
come from, and ``show_help`` the panel to print above the prompt.
"""
current = getattr(cfg, setting.key)
setting_help(console, setting, cfg)
(show_help or setting_help)(console, setting, cfg)
hint = "yes/no" if setting.kind == "bool" else (
"/".join(setting.choices) if setting.choices else
(setting.example or setting.metavar or "value"))
@ -317,7 +349,8 @@ def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool
if raw.strip() == "" or raw == st.format_value(setting, current):
return False
if raw.strip().lower() in ("d", "default"):
value = getattr(ScanConfig(), setting.key)
value = getattr(default if default is not None else ScanConfig(),
setting.key)
else:
try:
value = st.parse_value(setting, raw)
@ -634,6 +667,201 @@ into saved/, investigate/ or noise/, and delete the ones worth nothing."""),
}
# ---------------------------------------------------------------------------
# Aircraft
# ---------------------------------------------------------------------------
_AIRCRAFT_INTRO = (
"Every airliner overhead broadcasts its address, callsign, altitude, "
"position and speed twice a second on 1090 MHz. This is not a scan and "
"cannot be one \u2014 the signalling is a megabit a second, and the scan "
"path is 12.5 kHz wide \u2014 so it has its own listening mode here.\n\n"
"Everything heard is written to a log as it arrives; the map is drawn "
"from that log afterwards, and can be drawn again with different options "
"as often as you like."
)
def _options_table(console: Console, options, group: str) -> list[st.Setting]:
from . import aircraft as air
items = air.in_group(group)
default = air.AircraftOptions()
t = Table(box=None, header_style="bold", pad_edge=False,
title=f"[bold]{group.lower()}[/bold]", title_justify="left")
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("option", width=20)
t.add_column("value", width=16)
t.add_column("what it does", style="grey62", overflow="fold")
start = air.OPTIONS.index(items[0]) + 1
for i, o in enumerate(items, start):
value = air.format_option(o, getattr(options, o.key))
changed = getattr(options, o.key) != getattr(default, o.key)
t.add_row(str(i), o.label + (" *" if changed else ""),
Text(value, style="bold cyan" if changed else "white"),
o.help)
console.print(t)
return items
def aircraft_menu(console: Console, cfg: ScanConfig) -> None:
"""Listen to aircraft, and draw where they went, without a command line.
The options are the same ones the command line takes, described in the
same table, so the help here is the help there.
"""
from . import aircraft as air
options = air.load_options()
while True:
_rule(console, "aircraft (ADS-B)")
console.print(Panel(Text(_AIRCRAFT_INTRO), border_style="blue",
padding=(0, 1)))
_options_table(console, options, "Listening")
console.print()
_options_table(console, options, "Drawing")
logs = air.logs_in(cfg.output_dir)
kept = "no logs yet" if not logs else \
f"{len(logs)} log{'s' if len(logs) != 1 else ''}"
console.print(
f"\n [cyan]l[/cyan] [bold green]Listen now[/bold green]"
f" [grey62]{air.describe(options)}[/grey62]\n"
f" [cyan]m[/cyan] Draw a map from a log [grey62]{kept} in "
f"{cfg.output_dir}[/grey62]\n"
f" [cyan]N[/cyan] change option N "
f"[grey62]and [cyan]?N[/cyan] says what option N does[/grey62]\n"
f" [cyan]s[/cyan] Save these as default "
f"[grey62]kept in {air.options_path()}[/grey62]\n"
f" [cyan]d[/cyan] Reset them\n"
f" [cyan]b[/cyan] Back\n")
answer = _ask(console, " choice", "l").strip().lower()
if answer in _BACK:
return
if answer in ("l", "listen"):
_listen(console, cfg, options)
elif answer in ("m", "map", "draw"):
_draw_from_menu(console, cfg, options, logs)
elif answer == "s":
try:
where = air.save_options(options)
console.print(f" [green]saved to {where}[/green]")
except OSError as exc:
console.print(f" [red]could not save: {exc}[/red]")
elif answer == "d":
if _confirm(" reset every aircraft option"):
options = air.AircraftOptions()
console.print(" [green]reset[/green]")
elif answer.lstrip("?").strip().isdigit():
_edit_option(console, options, answer)
else:
console.print(" [yellow]enter a number from the lists, or "
"l, m, s, d or b[/yellow]")
def _edit_option(console: Console, options, answer: str) -> None:
"""Change one option, or explain it when asked with a question mark."""
from . import aircraft as air
want_help = answer.startswith("?")
index = int(answer.lstrip("?").strip())
if not 1 <= index <= len(air.OPTIONS):
console.print(" [yellow]no such number[/yellow]")
return
option = air.OPTIONS[index - 1]
if want_help:
option_help(console, option, options)
else:
edit_setting(console, option, options,
default=air.AircraftOptions(), show_help=option_help)
def option_help(console: Console, option: st.Setting, options) -> None:
"""The same help panel the settings menu shows, for an aircraft option."""
from . import aircraft as air
body = [f"[bold]{option.label}[/bold] [grey62]({option.key})[/grey62]",
"", option.help.capitalize() + "."]
if option.detail:
body += ["", option.detail]
if option.guidance and option.guidance != option.detail:
body += ["", f"[grey62]{option.guidance}[/grey62]"]
default = air.AircraftOptions()
body += ["", f"[grey62]now:[/grey62] "
f"{air.format_option(option, getattr(options, option.key))}"
f" [grey62]default:[/grey62] "
f"{air.format_option(option, getattr(default, option.key))}"]
rng = option.describe_range()
if rng:
body.append(f"[grey62]accepts:[/grey62] {rng}")
if option.flags:
flags = " ".join(option.flags)
if option.off_flags:
flags += " / " + " ".join(option.off_flags)
body.append(f"[grey62]command line:[/grey62] {flags}")
console.print(Panel(Text.from_markup("\n".join(body)),
border_style="blue", padding=(0, 1)))
def _listen(console: Console, cfg: ScanConfig, options) -> None:
"""Run a listening session from the menu and come back afterwards."""
from . import aircraft as air
errs = options.validate()
if errs:
for e in errs:
console.print(f" [red]{e}[/red]")
return
if not options.simulate:
console.print("[grey62]control-C stops listening and comes back "
"here.[/grey62]")
try:
heard = air.listen(console, options, cfg.output_dir)
except Exception as exc: # a menu must survive it
console.print(f" [red]{exc}[/red]")
return
if heard.log_path and not options.draw_after:
console.print(" [grey62]draw it with [cyan]m[/cyan], or set "
"\"Draw when finished\"[/grey62]")
def _draw_from_menu(console: Console, cfg: ScanConfig, options, logs) -> None:
"""Pick a log and draw it, newest first because that is usually the one."""
from . import aircraft as air
if not logs:
console.print(f" [yellow]no logs in {cfg.output_dir} yet \u2014 "
"listen first, or turn the simulated sky on[/yellow]")
return
t = Table(box=None, header_style="bold")
t.add_column("#", style="grey62", width=3, justify="right")
t.add_column("log")
t.add_column("when", style="grey62")
t.add_column("size", style="grey62", justify="right")
for i, path in enumerate(logs[:12], 1):
stat = path.stat()
t.add_row(str(i), path.name,
_when(stat.st_mtime), f"{stat.st_size/1e6:.1f} MB")
console.print(t)
answer = _ask(console, " which log", "1").strip()
if answer in _BACK:
return
if not answer.isdigit() or not 1 <= int(answer) <= len(logs[:12]):
console.print(" [yellow]no such log[/yellow]")
return
chosen = logs[int(answer) - 1]
try:
air.draw_log(console, options, [chosen])
except Exception as exc:
console.print(f" [red]{exc}[/red]")
def _when(stamp: float) -> str:
from datetime import datetime
return datetime.fromtimestamp(stamp).strftime("%y-%m-%d %H:%M")
def help_screen(console: Console) -> None:
while True:
_rule(console, "help")
@ -750,6 +978,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
f" [cyan]3[/cyan] Settings "
f"[grey62]{_summary(cfg)}[/grey62]\n"
f" [cyan]4[/cyan] Saved settings and profiles\n"
f" [cyan]5[/cyan] Aircraft (ADS-B) "
f"[grey62]listen on 1090 MHz, draw where they went[/grey62]\n"
f" [cyan]h[/cyan] Help\n"
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
f" [cyan]q[/cyan] Quit\n")
@ -763,6 +993,8 @@ def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
settings_menu(console, cfg)
elif choice == "4":
cfg = profiles_menu(console, cfg)
elif choice == "5":
aircraft_menu(console, cfg)
elif choice in ("h", "?", "help"):
help_screen(console)
elif choice in ("s", "start", "go"):