ADS-B was a live table and nothing else: an aircraft was overhead for four minutes and then gone, with nothing kept. Now everything heard goes into adsb_<time>.jsonl as it arrives -- one object per frame, the raw hex beside what was read out of it, flushed per line because a listening session ends with control-C -- with a readable report beside it. flights.py asks who the aircraft are: adsbdb for the airframe and the route, hexdb behind it, cached for a month. What needs no website is answered without one, because the ICAO address block says which country registered the aircraft and the first three letters of an airline callsign are its designator. Nothing but the address and the callsign heard on the air is ever sent. bandsaunter flights [LOG...] --out sky.gif reads a log back and draws the evening as a map with the clock running. Every frame is a moment: each aircraft is where it actually was then, interpolated between the position reports either side of it and dead-reckoned from its last speed and heading between them, and dropped rather than guessed at once it has not been heard for --stale seconds. The GIF is written here -- palette, LZW, frame differencing against a transparent index -- so nothing but numpy is needed; ffmpeg writes an MP4 where it happens to be installed, and .png draws the whole evening at once. The decoder needed 6.3 s to read a second of sky, so a live capture was losing six frames in seven. Reading the bits off a running total instead of summing each window takes that to 0.6 s, with identical output. --simulate flies six aircraft that are not there past a receiver that is not there, through the real encoder, the real checksum and the real decoder, so all of this can be tried without an aerial. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
1461 lines
61 KiB
Python
Executable file
1461 lines
61 KiB
Python
Executable file
"""Command line interface."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import signal
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from rich.console import Console
|
|
from rich.prompt import Confirm
|
|
from rich.live import Live
|
|
from rich.markup import escape
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
from rich.text import Text
|
|
|
|
from . import __version__
|
|
from .bandplan import CATEGORIES, PRESETS, fmt_hz, in_category, search
|
|
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, ScanConfig,
|
|
is_first_run, list_profiles, load_config, load_default,
|
|
save_config, save_default)
|
|
from .device import (RtlSdrDevice, RtlSdrError, list_devices,
|
|
set_driver_messages)
|
|
from .librtlsdr import load_error
|
|
from . import settings as st
|
|
from .ranges import (RangeError, ScanRange, build_plan, parse_range_list)
|
|
from .scanner import Scanner, ScannerCallbacks
|
|
from .tui import TUIAbort, first_run_setup, run_tui, settings_menu
|
|
from .ui import KeyReader, ScanDisplay, print_band_table, print_hit
|
|
|
|
console = Console()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# argument parsing
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(
|
|
prog="bandsaunter",
|
|
description="Scan, record and identify signals with an RTL-SDR.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""\
|
|
examples:
|
|
bandsaunter interactive setup
|
|
bandsaunter scan -r 144M-148M -r 420M-450M two ranges
|
|
bandsaunter scan -b gmrs -b marine-vhf band-plan presets
|
|
bandsaunter scan -b 2m --record 30 --hang 3 record 30 s max, 3 s squelch tail
|
|
bandsaunter scan -b 2m --record 0 --hang 6 whole conversations, gaps and all
|
|
bandsaunter scan -r 14.0M-14.35M --mode cw HF CW (needs direct sampling)
|
|
bandsaunter config settings menu
|
|
bandsaunter config hang_seconds=5 set one setting and save it
|
|
bandsaunter bands --category Aviation browse the US band plan
|
|
bandsaunter devices list attached dongles
|
|
bandsaunter adsb read the aircraft on 1090 MHz
|
|
bandsaunter flights --out sky.gif animate what they did
|
|
bandsaunter scan -b 2m --simulate try it without hardware
|
|
""")
|
|
p.add_argument("--version", action="version", version=f"bandsaunter {__version__}")
|
|
sub = p.add_subparsers(dest="command")
|
|
|
|
# -- scan ------------------------------------------------------------
|
|
s = sub.add_parser("scan", help="run a scan",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
|
g = s.add_argument_group("what to scan")
|
|
g.add_argument("-r", "--range", action="append", default=[], metavar="SPEC",
|
|
help="frequency range, e.g. 144M-148M or 462M-468M/12.5k@nfm; "
|
|
"repeat for as many pairs as you like")
|
|
g.add_argument("-b", "--band", action="append", default=[], metavar="KEY",
|
|
help="US band plan preset key (see `bandsaunter bands`); repeatable")
|
|
g.add_argument("--mode", default=None,
|
|
choices=["auto", "nfm", "wfm", "am", "usb", "lsb", "cw", "raw"],
|
|
help="force a demodulator for every range")
|
|
g.add_argument("-p", "--profile", metavar="NAME",
|
|
help="load a saved profile")
|
|
g.add_argument("--save-profile", metavar="NAME",
|
|
help="save the resulting configuration and exit")
|
|
|
|
st.add_arguments(s)
|
|
|
|
g = s.add_argument_group("presentation")
|
|
g.add_argument("--simulate", action="store_true",
|
|
help="use a synthetic receiver instead of real hardware")
|
|
g.add_argument("--dry-run", action="store_true",
|
|
help="show the sweep plan and exit")
|
|
g.add_argument("--no-config", action="store_true",
|
|
help="ignore the saved settings file and start from defaults")
|
|
g.add_argument("--keep-carriers", action="store_true",
|
|
help="also record steady unmodulated carriers")
|
|
g.add_argument("--settings", action="store_true",
|
|
help="open the settings menu before scanning")
|
|
g.add_argument("--save", action="store_true",
|
|
help="save the resulting settings as the default and exit")
|
|
|
|
# -- bands ------------------------------------------------------------
|
|
b = sub.add_parser("bands", help="browse the US band plan")
|
|
b.add_argument("term", nargs="?", help="search term")
|
|
b.add_argument("-c", "--category", help="show one category")
|
|
b.add_argument("--categories", action="store_true", help="list categories")
|
|
b.add_argument("--json", action="store_true", help="machine-readable output")
|
|
|
|
# -- config -----------------------------------------------------------
|
|
c = sub.add_parser("config", help="view or change the saved settings")
|
|
c.add_argument("assignment", nargs="*", metavar="KEY=VALUE",
|
|
help="set one or more settings, e.g. hang_seconds=5")
|
|
c.add_argument("--show", action="store_true", help="print every setting")
|
|
c.add_argument("--path", action="store_true",
|
|
help="print where the settings file lives")
|
|
c.add_argument("--edit", action="store_true",
|
|
help="open the settings menu")
|
|
c.add_argument("--reset", action="store_true",
|
|
help="delete the saved settings")
|
|
c.add_argument("--describe", metavar="KEY",
|
|
help="explain one setting in full")
|
|
|
|
# -- transcribe ---------------------------------------------------------
|
|
tr = sub.add_parser("transcribe",
|
|
help="transcribe recordings, or check the recognisers")
|
|
tr.add_argument("path", nargs="*",
|
|
help="WAV files or directories of them")
|
|
tr.add_argument("--engines", action="store_true",
|
|
help="list the speech recognisers and which are installed")
|
|
tr.add_argument("--engine", default=None, metavar="NAME")
|
|
tr.add_argument("--model", default=None, metavar="NAME")
|
|
tr.add_argument("--language", default=None, metavar="CODE")
|
|
tr.add_argument("--stdout", action="store_true",
|
|
help="print instead of writing _transcription.txt files")
|
|
|
|
# -- waterfall ---------------------------------------------------------
|
|
wf = sub.add_parser("waterfall",
|
|
help="draw recordings that produced no readable words")
|
|
wf.add_argument("path", nargs="*",
|
|
help="WAV files or directories of them "
|
|
"(default: the scanner's output directory)")
|
|
wf.add_argument("--all", action="store_true",
|
|
help="draw every recording, not only the unreadable ones")
|
|
wf.add_argument("--redraw", action="store_true",
|
|
help="draw again where a picture already exists")
|
|
wf.add_argument("--min-chars", type=int, default=None, metavar="N",
|
|
help="a transcript shorter than this counts as none")
|
|
wf.add_argument("--check-morse", action="store_true",
|
|
help="listen for a CW ident in the recordings a sidecar "
|
|
"calls readable, and draw the ones that have one")
|
|
|
|
# -- devices ----------------------------------------------------------
|
|
d = sub.add_parser("devices", help="list attached RTL-SDR devices")
|
|
d.add_argument("--test", action="store_true",
|
|
help="open the device and capture a test block")
|
|
|
|
# -- profiles ----------------------------------------------------------
|
|
pr = sub.add_parser("profiles", help="list saved profiles")
|
|
pr.add_argument("--show", metavar="NAME", help="print one profile")
|
|
|
|
# -- adsb ---------------------------------------------------------------
|
|
ad = sub.add_parser("adsb", help="listen to aircraft on 1090 MHz")
|
|
ad.add_argument("--seconds", type=float, default=0.0,
|
|
help="stop after this long (default: until interrupted)")
|
|
ad.add_argument("--rate", type=float, default=2_000_000.0,
|
|
help="sample rate in Hz; two megasamples is the minimum")
|
|
ad.add_argument("--gain", default="auto", help="tuner gain in dB, or auto")
|
|
ad.add_argument("--device", type=int, default=0, help="which receiver")
|
|
ad.add_argument("--frames", action="store_true",
|
|
help="print every frame as it arrives, not a summary")
|
|
ad.add_argument("--log", default=None, metavar="FILE",
|
|
help="where to write the frame log "
|
|
"(default: adsb_<time>.jsonl in the output directory)")
|
|
ad.add_argument("--no-log", dest="log_frames", action="store_false",
|
|
help="listen without writing anything down")
|
|
ad.add_argument("--no-lookup", dest="lookup", action="store_false",
|
|
help="do not ask the registers who the aircraft are")
|
|
ad.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
|
|
help="also write the flight paths for Google Earth")
|
|
ad.add_argument("--map", nargs="?", const="", default=None, metavar="FILE",
|
|
help="draw the animated map when the listening stops")
|
|
ad.add_argument("--simulate", action="store_true",
|
|
help="invent a sky, for a receiver with no aerial")
|
|
ad.add_argument("--near", default=None, metavar="LAT,LON",
|
|
help="where the simulated aircraft are flying")
|
|
ad.set_defaults(log_frames=True, lookup=True)
|
|
|
|
# -- flights --------------------------------------------------------------
|
|
fl = sub.add_parser("flights",
|
|
help="read an ADS-B log: report, map, animation")
|
|
fl.add_argument("path", nargs="*",
|
|
help="frame logs (default: the newest in the output directory)")
|
|
fl.add_argument("--out", default=None, metavar="FILE",
|
|
help="the animation to write: .gif, .mp4 or .png "
|
|
"(default: beside the log, as a GIF)")
|
|
fl.add_argument("--fps", type=float, default=12.0,
|
|
help="frames a second in the animation")
|
|
fl.add_argument("--seconds", type=float, default=30.0,
|
|
help="how long the animation should run for")
|
|
fl.add_argument("--speed", type=float, default=0.0, metavar="X",
|
|
help="seconds of flying per second of animation "
|
|
"(overrides --seconds)")
|
|
fl.add_argument("--width", type=int, default=960, help="picture width")
|
|
fl.add_argument("--trail", type=float, default=0.0, metavar="SECONDS",
|
|
help="how much of the path to leave behind "
|
|
"(default: all of it)")
|
|
fl.add_argument("--stale", type=float, default=300.0, metavar="SECONDS",
|
|
help="drop an aircraft this long after its last report")
|
|
fl.add_argument("--no-labels", dest="labels", action="store_false",
|
|
help="draw the aircraft without callsigns beside them")
|
|
fl.add_argument("--no-map", dest="draw", action="store_false",
|
|
help="report only, draw nothing")
|
|
fl.add_argument("--no-lookup", dest="lookup", action="store_false",
|
|
help="do not ask the registers who the aircraft are")
|
|
fl.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
|
|
help="also write the flight paths for Google Earth")
|
|
fl.add_argument("--report", nargs="?", const="", default=None, metavar="FILE",
|
|
help="also write the readable report to a file")
|
|
fl.set_defaults(labels=True, draw=True, lookup=True)
|
|
|
|
# -- analyse ------------------------------------------------------------
|
|
|
|
a = sub.add_parser("analyze", aliases=["analyse"],
|
|
help="identify a signal in a recorded file")
|
|
a.add_argument("path", help=".cf32/.cs16 IQ file or a .wav from a recording")
|
|
a.add_argument("--rate", type=float, help="sample rate of the file (Hz)")
|
|
a.add_argument("--freq", type=float, default=0.0,
|
|
help="centre frequency in Hz, for band-aware naming")
|
|
a.add_argument("--morse", action="store_true", help="force a CW decode")
|
|
return p
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_config(args) -> tuple[ScanConfig, Path | None]:
|
|
"""Layer the configuration: saved settings, then a profile, then flags.
|
|
|
|
Returns the config and where its saved settings came from, so the user can
|
|
be told what is in force.
|
|
"""
|
|
source = None
|
|
if getattr(args, "no_config", False):
|
|
cfg = ScanConfig()
|
|
else:
|
|
cfg, source = load_default()
|
|
if args.profile:
|
|
cfg = load_config(args.profile)
|
|
source = Path(cfg._source_path) if hasattr(cfg, "_source_path") else None
|
|
|
|
ranges: list[ScanRange] = []
|
|
for spec in args.range:
|
|
ranges.extend(parse_range_list(spec))
|
|
for key in args.band:
|
|
ranges.extend(parse_range_list(key))
|
|
if ranges:
|
|
cfg.ranges = ranges
|
|
if args.mode and args.mode != "auto":
|
|
for r in cfg.ranges:
|
|
r.mode = args.mode
|
|
|
|
# Everything else comes straight off the shared settings table, so a flag
|
|
# cannot exist without the matching entry in the in-app menu.
|
|
st.apply_args(cfg, args)
|
|
|
|
if getattr(args, "keep_carriers", False) and "carrier" not in cfg.accept:
|
|
cfg.accept = list(cfg.accept) + ["carrier"]
|
|
return cfg, source
|
|
|
|
|
|
def _print_plan(cfg: ScanConfig) -> None:
|
|
try:
|
|
steps = build_plan(cfg.ranges, cfg.sample_rate, cfg.usable_fraction,
|
|
dc_guard=cfg.dc_guard_hz)
|
|
except RangeError as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
return
|
|
t = Table(title="sweep plan", box=None, header_style="bold")
|
|
t.add_column("range")
|
|
t.add_column("from", justify="right")
|
|
t.add_column("to", justify="right")
|
|
t.add_column("mode", justify="center")
|
|
t.add_column("steps", justify="right")
|
|
counts: dict[int, int] = {}
|
|
for s in steps:
|
|
counts[s.range_index] = counts.get(s.range_index, 0) + 1
|
|
for i, r in enumerate(cfg.ranges):
|
|
if not r.enabled:
|
|
continue
|
|
t.add_row(r.label, fmt_hz(r.start), fmt_hz(r.stop), r.mode,
|
|
str(counts.get(i, 0)))
|
|
console.print(t)
|
|
cycle = len(steps) * cfg.dwell_seconds
|
|
total = sum(r.span for r in cfg.ranges if r.enabled)
|
|
gate = (", ".join(cfg.accept) if cfg.require_signal
|
|
else "everything above the squelch (content check off)")
|
|
combine = ""
|
|
if cfg.combine_by_frequency:
|
|
combine = "\nOne file per frequency"
|
|
if cfg.announce_timestamps:
|
|
from .announce import available_engine
|
|
chosen = (available_engine() if cfg.announce_engine == "auto"
|
|
else cfg.announce_engine)
|
|
using = (f"{chosen}" if chosen and chosen != "builtin"
|
|
else "the built-in synthesiser")
|
|
combine += f", timestamps spoken by {using}"
|
|
combine += "."
|
|
|
|
console.print(
|
|
f"[grey62]{len(steps)} tuner steps, {fmt_hz(total)} of spectrum, "
|
|
f"about {cycle:.1f} s per sweep (excluding time spent recording)."
|
|
f"\nSquelch +{cfg.threshold_db:g} dB; "
|
|
+ (f"record up to {cfg.record_seconds:g} s per signal"
|
|
if cfg.record_seconds else "record for as long as the signal lasts")
|
|
+ f"; resume after {cfg.hang_seconds:g} s of quiet "
|
|
f"(shorter gaps are recorded through)."
|
|
f"\nRecording: {gate}.{combine}[/grey62]")
|
|
|
|
|
|
def _maybe_first_run(cfg: ScanConfig, args) -> None:
|
|
"""Ask where to save, the first time, when there is someone to ask.
|
|
|
|
Skipped when the settings file is being ignored, when nothing is going to
|
|
be written, and when input is not a terminal -- a script must never block
|
|
on a question.
|
|
"""
|
|
if not is_first_run() or getattr(args, "no_config", False):
|
|
return
|
|
if getattr(args, "dry_run", False) or getattr(args, "save_profile", None):
|
|
return
|
|
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
return
|
|
try:
|
|
first_run_setup(console, cfg)
|
|
except TUIAbort:
|
|
console.print()
|
|
|
|
|
|
def _make_device(cfg: ScanConfig, simulate: bool):
|
|
if simulate:
|
|
from .simulator import SimulatedDevice
|
|
console.print("[magenta]Using the built-in simulator "
|
|
"(no hardware involved).[/magenta]")
|
|
return SimulatedDevice(sample_rate=cfg.sample_rate, realtime=True).open()
|
|
from .device import RtlSdrDevice
|
|
dev = RtlSdrDevice(index=cfg.device_index, sample_rate=cfg.sample_rate,
|
|
gain=cfg.gain, ppm=cfg.ppm, agc=cfg.agc,
|
|
bias_tee=cfg.bias_tee,
|
|
offset_tuning=cfg.offset_tuning,
|
|
direct_sampling=cfg.direct_sampling)
|
|
return dev.open()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# commands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def cmd_scan(args) -> int:
|
|
try:
|
|
cfg, source = _build_config(args)
|
|
except (RangeError, FileNotFoundError, ValueError, st.SettingError) as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
return 2
|
|
|
|
_maybe_first_run(cfg, args)
|
|
|
|
if getattr(args, "settings", False):
|
|
try:
|
|
settings_menu(console, cfg)
|
|
except TUIAbort:
|
|
return 0
|
|
|
|
if not cfg.ranges:
|
|
cfg = run_tui(console, cfg, source)
|
|
if cfg is None:
|
|
return 0
|
|
elif source and not cfg.quiet:
|
|
console.print(f"[grey62]settings from {source}[/grey62]")
|
|
|
|
if args.save_profile:
|
|
path = save_config(cfg, args.save_profile)
|
|
console.print(f"[green]saved profile to {path}[/green]")
|
|
return 0
|
|
if getattr(args, "save", False):
|
|
path = save_default(cfg)
|
|
console.print(f"[green]saved as the default settings: {path}[/green]")
|
|
return 0
|
|
|
|
errs = cfg.validate()
|
|
if errs:
|
|
for e in errs:
|
|
console.print(f"[red]{e}[/red]")
|
|
return 2
|
|
|
|
if args.dry_run:
|
|
_print_plan(cfg)
|
|
return 0
|
|
|
|
if not cfg.quiet:
|
|
_print_plan(cfg)
|
|
|
|
try:
|
|
device = _make_device(cfg, args.simulate)
|
|
except RtlSdrError as exc:
|
|
console.print(Panel(Text(str(exc)), title="[red]cannot open the receiver",
|
|
border_style="red"))
|
|
console.print("[grey62]Try `bandsaunter devices` to check what is "
|
|
"attached, or `--simulate` to run without "
|
|
"hardware.[/grey62]")
|
|
return 1
|
|
|
|
if args.simulate and cfg.save_lockouts:
|
|
# The demo band is invented. A lock-out taken from it would sit in
|
|
# the real settings file for ever, skipping whatever genuine signal
|
|
# happened to land near a made-up frequency. Locking out still works
|
|
# for the run in hand; it is only the writing back that is refused.
|
|
cfg.save_lockouts = False
|
|
if args.simulate and (cfg.callsign_lookup or cfg.kml_file):
|
|
# Same reason, and a sharper one. The demo band is made up but the
|
|
# callsigns in it are real people -- the beacon identifies itself as
|
|
# W1AW, which is the ARRL's own station -- so a simulated run would
|
|
# look up a licence nobody heard and pin it to the same map a real
|
|
# scan writes. Callsigns are still found and shown; it is the
|
|
# contacting and the recording that are refused.
|
|
cfg.callsign_lookup = False
|
|
cfg.kml_file = ""
|
|
console.print("[magenta]Callsigns in the demo band belong to real "
|
|
"stations, so they are not looked up and not "
|
|
"mapped.[/magenta]")
|
|
|
|
scanner = Scanner(cfg, device=device, callbacks=ScannerCallbacks())
|
|
try:
|
|
scanner.prepare()
|
|
except (ValueError, RangeError, RtlSdrError) as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
device.close()
|
|
return 2
|
|
|
|
signal.signal(signal.SIGINT, lambda *a: scanner.stop())
|
|
rc = (_run_plain(scanner, cfg) if (cfg.plain or cfg.quiet or
|
|
not sys.stdout.isatty())
|
|
else _run_live(scanner, cfg))
|
|
_print_summary(scanner)
|
|
return rc
|
|
|
|
|
|
def _run_plain(scanner: Scanner, cfg: ScanConfig) -> int:
|
|
scanner.cb.on_record_end = lambda hit: print_hit(console, hit)
|
|
if not cfg.quiet:
|
|
scanner.cb.on_status = lambda m: console.print(f"[grey62]{m}[/grey62]")
|
|
scanner.cb.on_error = lambda e: console.print(f"[red]{type(e).__name__}: {e}[/red]")
|
|
console.print("[grey62]scanning -- Ctrl-C to stop[/grey62]")
|
|
try:
|
|
scanner.run()
|
|
except KeyboardInterrupt:
|
|
scanner.stop()
|
|
return 0
|
|
|
|
|
|
def _run_live(scanner: Scanner, cfg: ScanConfig) -> int:
|
|
display = ScanDisplay(scanner)
|
|
display.attach()
|
|
import threading
|
|
worker = threading.Thread(target=scanner.run, daemon=True, name="scan")
|
|
|
|
with KeyReader() as keys:
|
|
# crop rather than let an oversized frame scroll: a display taller
|
|
# than the terminal cannot be redrawn in place, and every refresh
|
|
# would leave another copy behind.
|
|
with Live(display.render(), console=console, refresh_per_second=8,
|
|
screen=False, transient=False,
|
|
vertical_overflow="crop") as live:
|
|
worker.start()
|
|
try:
|
|
while worker.is_alive():
|
|
key = keys.get()
|
|
if key:
|
|
_handle_key(key, scanner, display)
|
|
if display.resized():
|
|
# Start again from a blank screen. The frame rich is
|
|
# about to erase is no longer where it thinks it is,
|
|
# and the text above it has been reflowed by the
|
|
# terminal in any case.
|
|
console.clear()
|
|
live.update(display.render())
|
|
time.sleep(0.1)
|
|
except KeyboardInterrupt:
|
|
scanner.stop()
|
|
finally:
|
|
scanner.stop()
|
|
worker.join(timeout=5.0)
|
|
live.update(display.render())
|
|
return 0
|
|
|
|
|
|
def _handle_key(key: str, scanner: Scanner, display: ScanDisplay) -> None:
|
|
k = key.lower()
|
|
if k == "q":
|
|
scanner.stop()
|
|
elif k == "p":
|
|
scanner.pause(not scanner.paused)
|
|
display.on_status("paused" if scanner.paused else "resumed")
|
|
elif k == "s":
|
|
scanner.skip()
|
|
display.on_status("skipping this signal")
|
|
elif k == "l":
|
|
freq = display._rec.frequency or scanner.stats.current_freq
|
|
if freq:
|
|
# The scanner says so itself, and says whether it was remembered.
|
|
scanner.lockout(freq)
|
|
scanner.skip()
|
|
elif k in ("+", "="):
|
|
scanner.cfg.threshold_db += 1.0
|
|
display.on_status(f"squelch +{scanner.cfg.threshold_db:g} dB")
|
|
elif k == "-":
|
|
scanner.cfg.threshold_db = max(1.0, scanner.cfg.threshold_db - 1.0)
|
|
display.on_status(f"squelch +{scanner.cfg.threshold_db:g} dB")
|
|
|
|
|
|
def _print_summary(scanner: Scanner) -> None:
|
|
st = scanner.stats
|
|
console.print()
|
|
console.rule("[bold]scan summary[/bold]", style="blue")
|
|
console.print(
|
|
f" ran for {st.elapsed:.0f} s over {st.cycles} sweep(s), "
|
|
f"{st.steps_done} tuner steps\n"
|
|
f" {st.detections} detection(s), {st.recordings} recording(s) kept, "
|
|
f"{st.discarded} discarded\n"
|
|
f" {st.seconds_recorded:.0f} s of audio captured")
|
|
if st.truncated:
|
|
limit = scanner.cfg.record_seconds
|
|
console.print(
|
|
f"[yellow] {st.truncated} recording(s) were cut off at the "
|
|
f"{limit:g} s record limit while the signal was still "
|
|
f"transmitting.[/yellow]\n"
|
|
f"[grey62] Set 'Record for' to 0 (or --record 0) to follow a "
|
|
f"transmission to its end; 'Wait for quiet' then decides when to "
|
|
f"move on.[/grey62]")
|
|
worker = getattr(scanner, "transcriber", None)
|
|
if worker is not None and (worker.written or worker.empty or worker.dropped):
|
|
bits = [f"{worker.written} transcript(s) written"]
|
|
if worker.empty:
|
|
bits.append(f"{worker.empty} with no recognisable speech")
|
|
if worker.dropped:
|
|
bits.append(f"{worker.dropped} skipped, the recogniser fell behind")
|
|
console.print(f"[grey62] {', '.join(bits)}[/grey62]")
|
|
heard = getattr(scanner, "heard", None)
|
|
if heard:
|
|
book = getattr(scanner, "callsigns", None)
|
|
console.print(f"[green] {len(heard)} callsign(s) heard:[/green]")
|
|
for call in sorted(heard):
|
|
entry = book.get(call) if book is not None else None
|
|
who = entry.summary() if entry is not None else ""
|
|
console.print(f" [bold]{call:<8}[/bold] [grey62]{who}[/grey62]")
|
|
kml = getattr(scanner, "kml", None)
|
|
if kml is not None and len(kml):
|
|
console.print(f"[grey62] mapped in {kml.path}[/grey62]")
|
|
if st.control_channels:
|
|
console.print(f"[yellow] {len(st.control_channels)} trunking control "
|
|
f"channel(s) skipped:[/yellow]")
|
|
for hz, name in sorted(st.control_channels.items()):
|
|
console.print(f"[grey62] {fmt_hz(hz):>14} {name}[/grey62]")
|
|
if st.rejected_by_category:
|
|
drops = ", ".join(f"{n} {cat}"
|
|
for cat, n in sorted(st.rejected_by_category.items(),
|
|
key=lambda kv: -kv[1]) if cat)
|
|
if drops:
|
|
console.print(f"[grey62] discarded without recording: {drops}[/grey62]")
|
|
if scanner.hits:
|
|
counts: dict[str, int] = {}
|
|
for h in scanner.hits:
|
|
counts[h.classification or "unclassified"] = \
|
|
counts.get(h.classification or "unclassified", 0) + 1
|
|
t = Table(box=None, header_style="bold")
|
|
t.add_column("identified as")
|
|
t.add_column("count", justify="right")
|
|
for label, n in sorted(counts.items(), key=lambda kv: -kv[1]):
|
|
t.add_row(label, str(n))
|
|
console.print(t)
|
|
out = Path(scanner.cfg.output_dir)
|
|
console.print(f"[grey62]recordings in {out.resolve()}, "
|
|
f"log in {(out / scanner.cfg.log_file).name} "
|
|
f"and .csv[/grey62]")
|
|
|
|
|
|
def cmd_config(args) -> int:
|
|
cfg, source = load_default()
|
|
|
|
if args.path:
|
|
print(DEFAULT_CONFIG_PATH)
|
|
return 0
|
|
if args.reset:
|
|
if DEFAULT_CONFIG_PATH.exists():
|
|
DEFAULT_CONFIG_PATH.unlink()
|
|
console.print(f"[green]deleted {DEFAULT_CONFIG_PATH}[/green]")
|
|
else:
|
|
console.print("[yellow]nothing saved to delete[/yellow]")
|
|
return 0
|
|
if args.describe:
|
|
setting = st.by_key(args.describe) or next(iter(st.search(args.describe)), None)
|
|
if setting is None:
|
|
console.print(f"[red]no setting called {args.describe!r}[/red]")
|
|
return 1
|
|
from .tui import setting_help
|
|
setting_help(console, setting, cfg)
|
|
return 0
|
|
|
|
if args.assignment:
|
|
changed = []
|
|
for item in args.assignment:
|
|
key, _, value = item.partition("=")
|
|
setting = st.by_key(key.strip())
|
|
if setting is None:
|
|
console.print(f"[red]no setting called {key.strip()!r}[/red]")
|
|
matches = st.search(key.strip())
|
|
if matches:
|
|
console.print("[grey62]did you mean: "
|
|
+ ", ".join(m.key for m in matches[:5])
|
|
+ "[/grey62]")
|
|
return 2
|
|
try:
|
|
setattr(cfg, setting.key, st.parse_value(setting, value))
|
|
except st.SettingError as exc:
|
|
console.print(f"[red]{setting.key}: {exc}[/red]")
|
|
return 2
|
|
changed.append(setting)
|
|
errs = [e for e in cfg.validate() if "frequency ranges" not in e]
|
|
if errs:
|
|
for e in errs:
|
|
console.print(f"[red]{e}[/red]")
|
|
return 2
|
|
path = save_default(cfg)
|
|
for setting in changed:
|
|
console.print(f"[green]{setting.key} = "
|
|
f"{st.format_value(setting, getattr(cfg, setting.key))}"
|
|
f"[/green]")
|
|
console.print(f"[grey62]saved to {path}[/grey62]")
|
|
return 0
|
|
|
|
if args.edit or not args.show:
|
|
try:
|
|
settings_menu(console, cfg)
|
|
except TUIAbort:
|
|
console.print()
|
|
return 0
|
|
try:
|
|
save = Confirm.ask("save these settings as the default",
|
|
default=True)
|
|
except (EOFError, KeyboardInterrupt):
|
|
save = False
|
|
if save:
|
|
console.print(f"[green]saved to {save_default(cfg)}[/green]")
|
|
return 0
|
|
|
|
default = ScanConfig()
|
|
console.print(f"[grey62]{'saved settings: ' + str(source) if source else 'no settings file yet; showing defaults'}[/grey62]")
|
|
for group in st.GROUPS:
|
|
t = Table(title=group, box=None, header_style="bold", title_justify="left")
|
|
t.add_column("key", style="cyan")
|
|
t.add_column("value")
|
|
t.add_column("default", style="grey62")
|
|
t.add_column("what it does", style="grey62", overflow="fold")
|
|
for setting in st.in_group(group):
|
|
value = st.format_value(setting, getattr(cfg, setting.key))
|
|
dflt = st.format_value(setting, getattr(default, setting.key))
|
|
t.add_row(setting.key, value, "" if value == dflt else dflt,
|
|
setting.help)
|
|
console.print(t)
|
|
console.print()
|
|
return 0
|
|
|
|
|
|
def cmd_bands(args) -> int:
|
|
if args.categories:
|
|
t = Table(title="band plan categories", box=None, header_style="bold")
|
|
t.add_column("category")
|
|
t.add_column("presets", justify="right")
|
|
for c in CATEGORIES:
|
|
t.add_row(c, str(len(in_category(c))))
|
|
console.print(t)
|
|
return 0
|
|
if args.category:
|
|
presets = in_category(args.category)
|
|
if not presets:
|
|
matches = [c for c in CATEGORIES
|
|
if args.category.lower() in c.lower()]
|
|
if len(matches) == 1:
|
|
presets = in_category(matches[0])
|
|
else:
|
|
console.print(f"[red]no such category: {args.category}[/red]")
|
|
console.print("[grey62]try: " + ", ".join(CATEGORIES) + "[/grey62]")
|
|
return 2
|
|
title = args.category
|
|
elif args.term:
|
|
presets = search(args.term)
|
|
title = f"matching {args.term!r}"
|
|
if not presets:
|
|
console.print(f"[yellow]nothing matched {args.term!r}[/yellow]")
|
|
return 1
|
|
else:
|
|
presets = list(PRESETS)
|
|
title = f"US band plan ({len(PRESETS)} presets)"
|
|
|
|
if args.json:
|
|
print(json.dumps([{
|
|
"key": p.key, "name": p.name, "category": p.category,
|
|
"start": p.start, "stop": p.stop, "step": p.step,
|
|
"mode": p.mode, "bandwidth": p.bandwidth, "note": p.note,
|
|
"members": list(p.members),
|
|
} for p in presets], indent=2))
|
|
return 0
|
|
print_band_table(console, presets, title)
|
|
console.print("[grey62]use a key with: bandsaunter scan -b <key>[/grey62]")
|
|
return 0
|
|
|
|
|
|
def cmd_transcribe(args) -> int:
|
|
from .transcribe import available_engine, describe_engines, transcribe
|
|
from .recorder import read_wav
|
|
|
|
if args.engines or not args.path:
|
|
t = Table(title="speech recognisers", box=None, header_style="bold")
|
|
t.add_column("engine", style="cyan")
|
|
t.add_column("installed")
|
|
t.add_column("how to get it", style="grey62")
|
|
for name, present, how in describe_engines():
|
|
t.add_row(name,
|
|
"[green]yes[/green]" if present else "[red]no[/red]", how)
|
|
console.print(t)
|
|
chosen = available_engine()
|
|
console.print(f"[grey62]{'auto would use ' + chosen if chosen else
|
|
'nothing installed — transcription is unavailable'}"
|
|
f"[/grey62]")
|
|
if not args.path:
|
|
return 0 if chosen else 1
|
|
|
|
cfg, _ = load_default()
|
|
engine = args.engine or cfg.transcribe_engine
|
|
model = args.model or cfg.transcribe_model
|
|
language = args.language if args.language is not None \
|
|
else cfg.transcribe_language
|
|
|
|
files: list[Path] = []
|
|
for item in args.path:
|
|
p = Path(item)
|
|
if p.is_dir():
|
|
files.extend(sorted(p.glob("*.wav")))
|
|
elif p.exists():
|
|
files.append(p)
|
|
else:
|
|
console.print(f"[red]no such file: {p}[/red]")
|
|
return 1
|
|
if not files:
|
|
console.print("[yellow]nothing to transcribe[/yellow]")
|
|
return 1
|
|
|
|
failures = 0
|
|
for wav in files:
|
|
try:
|
|
audio, rate = read_wav(wav)
|
|
except (OSError, ValueError) as exc:
|
|
console.print(f"[red]{wav.name}: {exc}[/red]")
|
|
failures += 1
|
|
continue
|
|
console.print(f"[grey62]{wav.name}: {audio.size / rate:.1f} s[/grey62]")
|
|
result = transcribe(audio, rate, engine, model, language)
|
|
if result is None:
|
|
console.print("[red]no speech recogniser installed — "
|
|
"see `bandsaunter transcribe --engines`[/red]")
|
|
return 1
|
|
if result.note:
|
|
console.print(f"[yellow]{result.note}[/yellow]")
|
|
text = result.text.strip() or "[no speech recognised]"
|
|
if args.stdout:
|
|
console.print(text)
|
|
else:
|
|
out = wav.with_suffix("")
|
|
out = out.with_name(out.name + "_transcription.txt")
|
|
out.write_text(text + "\n")
|
|
console.print(f" [green]{out.name}[/green]: {text[:70]}")
|
|
return 1 if failures else 0
|
|
|
|
|
|
def cmd_waterfall(args) -> int:
|
|
"""Draw the captures nobody can read, for a directory already recorded."""
|
|
import json as _json
|
|
from .morse import find_morse
|
|
from .recorder import read_wav
|
|
from .waterfall import draw_for_recording, is_readable, waterfall_path
|
|
|
|
cfg, _ = load_default()
|
|
floor = args.min_chars if args.min_chars is not None \
|
|
else cfg.waterfall_min_chars
|
|
|
|
targets = args.path or [cfg.output_dir]
|
|
files: list[Path] = []
|
|
for item in targets:
|
|
p = Path(item).expanduser()
|
|
if p.is_dir():
|
|
files.extend(sorted(p.glob("*.wav")))
|
|
elif p.exists():
|
|
files.append(p)
|
|
else:
|
|
console.print(f"[red]no such file: {p}[/red]")
|
|
return 1
|
|
if not files:
|
|
console.print("[yellow]no recordings to draw[/yellow]")
|
|
return 1
|
|
|
|
drawn = skipped = failed = 0
|
|
for wav in files:
|
|
out = waterfall_path(wav)
|
|
if out.exists() and not args.redraw:
|
|
skipped += 1
|
|
continue
|
|
|
|
hit = {}
|
|
meta = wav.with_suffix(".json")
|
|
if meta.exists():
|
|
try:
|
|
hit = _json.loads(meta.read_text()).get("hit") or {}
|
|
except (OSError, ValueError):
|
|
hit = {}
|
|
transcript = ""
|
|
words = wav.with_name(wav.stem + "_transcription.txt")
|
|
if words.exists():
|
|
try:
|
|
transcript = words.read_text().strip()
|
|
except OSError:
|
|
transcript = ""
|
|
transcript = transcript or str(hit.get("transcript") or "")
|
|
# The same rule the scanner applies as it records: voice that
|
|
# produced words worth the name is readable, and everything else is
|
|
# a picture waiting to be drawn.
|
|
readable = is_readable(hit, transcript, floor)
|
|
if readable and not args.all and not args.check_morse:
|
|
skipped += 1
|
|
continue
|
|
|
|
try:
|
|
audio, rate = read_wav(wav)
|
|
except (OSError, ValueError) as exc:
|
|
console.print(f"[red]{wav.name}: {exc}[/red]")
|
|
failed += 1
|
|
continue
|
|
|
|
# A sidecar written before the decoder could hear an ident over an
|
|
# FM carrier calls a repeater readable, because the recogniser turned
|
|
# its tones into a long string of digits. Listening again is the
|
|
# only way to know, and it is only worth it for the ones that would
|
|
# otherwise be skipped.
|
|
ident = None
|
|
if readable and not args.all:
|
|
ident = find_morse(audio, rate)
|
|
if ident is None or not ident.is_morse:
|
|
skipped += 1
|
|
continue
|
|
console.print(f" [grey62]{wav.name}: CW ident "
|
|
f'"{ident.complete_text}"[/grey62]')
|
|
|
|
iq = str(hit.get("iq_path") or "")
|
|
try:
|
|
picture = draw_for_recording(
|
|
wav, audio=audio, rate=rate,
|
|
frequency=float(hit.get("frequency") or 0.0),
|
|
mode=str(hit.get("mode") or ""),
|
|
classification=str(hit.get("classification") or ""),
|
|
iq_path=iq, iq_rate=float(hit.get("iq_rate") or 0.0),
|
|
iq_format=cfg.iq_format, out_path=out)
|
|
except (OSError, ValueError) as exc:
|
|
console.print(f"[red]{wav.name}: {exc}[/red]")
|
|
failed += 1
|
|
continue
|
|
if picture is None:
|
|
failed += 1
|
|
continue
|
|
drawn += 1
|
|
console.print(f" [green]{out.name}[/green] "
|
|
f"[grey62]{picture.summary()}[/grey62]")
|
|
if meta.exists():
|
|
try:
|
|
body = _json.loads(meta.read_text())
|
|
if isinstance(body.get("hit"), dict):
|
|
body["hit"]["waterfall_path"] = picture.path
|
|
if ident is not None:
|
|
# Found the hard way; worth keeping, so the browser
|
|
# shows the ident and the next run knows without
|
|
# listening again.
|
|
body["hit"]["morse_text"] = ident.text
|
|
body["hit"]["morse_complete"] = ident.complete_text
|
|
body["hit"]["morse_wpm"] = round(ident.wpm, 1)
|
|
meta.write_text(_json.dumps(body, indent=2, default=str))
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
console.print(f"[bold]{drawn}[/bold] drawn, {skipped} skipped"
|
|
+ (f", [red]{failed} failed[/red]" if failed else ""))
|
|
return 1 if failed and not drawn else 0
|
|
|
|
|
|
def cmd_devices(args) -> int:
|
|
# This is the command people run when something is wrong, so let the
|
|
# driver say what it is doing.
|
|
set_driver_messages(True)
|
|
err = load_error()
|
|
if err:
|
|
console.print(Panel(Text(err), title="[red]librtlsdr not available",
|
|
border_style="red"))
|
|
return 1
|
|
devs = list_devices()
|
|
if not devs:
|
|
console.print("[yellow]No RTL-SDR devices found.[/yellow]")
|
|
console.print(
|
|
"[grey62]Check `lsusb` for a Realtek RTL2832/RTL2838. If it is "
|
|
"listed, the DVB-T kernel driver has probably claimed it:\n"
|
|
" echo 'blacklist dvb_usb_rtl28xxu' | "
|
|
"sudo tee /etc/modprobe.d/blacklist-rtl.conf\n"
|
|
" sudo rmmod dvb_usb_rtl28xxu[/grey62]")
|
|
return 1
|
|
t = Table(title="RTL-SDR devices", box=None, header_style="bold")
|
|
t.add_column("#", justify="right")
|
|
t.add_column("name")
|
|
t.add_column("manufacturer")
|
|
t.add_column("product")
|
|
t.add_column("serial")
|
|
for d in devs:
|
|
t.add_row(str(d.index), d.name, d.manufacturer, d.product, d.serial)
|
|
console.print(t)
|
|
|
|
if args.test:
|
|
from .device import RtlSdrDevice
|
|
import numpy as np
|
|
for d in devs:
|
|
try:
|
|
with RtlSdrDevice(index=d.index) as dev:
|
|
dev.tune(100_000_000)
|
|
x = dev.read_samples(65536, flush=True)
|
|
rms = float(np.sqrt(np.mean(np.abs(x) ** 2)))
|
|
gains = dev.available_gains
|
|
console.print(
|
|
f" [green]device {d.index} works[/green]: tuner "
|
|
f"{dev.tuner}, {len(gains)} gain steps "
|
|
f"({min(gains):.1f}-{max(gains):.1f} dB), "
|
|
f"test capture RMS {rms:.4f}")
|
|
except RtlSdrError as exc:
|
|
console.print(f" [red]device {d.index}: {exc}[/red]")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
def cmd_profiles(args) -> int:
|
|
if args.show:
|
|
try:
|
|
cfg = load_config(args.show)
|
|
except (OSError, FileNotFoundError) as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
return 1
|
|
console.print_json(json.dumps(cfg.to_dict(), default=str))
|
|
return 0
|
|
profiles = list_profiles()
|
|
if not profiles:
|
|
console.print(f"[yellow]no profiles in {DEFAULT_CONFIG_DIR}[/yellow]")
|
|
console.print("[grey62]create one with: "
|
|
"bandsaunter scan -b 2m --save-profile myscan[/grey62]")
|
|
return 0
|
|
t = Table(title=f"profiles in {DEFAULT_CONFIG_DIR}", box=None,
|
|
header_style="bold")
|
|
t.add_column("name")
|
|
t.add_column("ranges", justify="right")
|
|
t.add_column("record", justify="right")
|
|
t.add_column("hang", justify="right")
|
|
for p in profiles:
|
|
try:
|
|
cfg = load_config(str(p))
|
|
t.add_row(p.stem, str(len(cfg.ranges)),
|
|
f"{cfg.record_seconds:g}s", f"{cfg.hang_seconds:g}s")
|
|
except Exception:
|
|
t.add_row(p.stem, "[red]unreadable[/red]", "", "")
|
|
console.print(t)
|
|
return 0
|
|
|
|
|
|
def cmd_adsb(args) -> int:
|
|
"""Park the receiver on 1090 MHz and write down the aircraft overhead.
|
|
|
|
A command of its own because ADS-B does not fit through the scanner. It
|
|
is a megabit a second, which needs two megasamples a second of raw
|
|
receiver output; the scan path decimates everything to a channel twelve
|
|
and a half kilohertz wide before anything sees it, and a megabit will not
|
|
go through that.
|
|
|
|
Everything heard goes into a log as it arrives, because an aircraft is
|
|
overhead for four minutes and then gone: the summary on the screen is for
|
|
the person watching, and the log is for everything afterwards -- the
|
|
report, the map and the animation.
|
|
"""
|
|
from .adsb import (ADSB_HZ, AircraftRegistry, SAMPLE_RATE, SimulatedSky,
|
|
decode_frames, default_sky)
|
|
from .flightlog import FlightLog, read_logs, report, write_kml
|
|
from .flights import FlightBook
|
|
|
|
cfg, _ = load_default()
|
|
if args.rate < SAMPLE_RATE:
|
|
console.print(f"[red]ADS-B needs at least {SAMPLE_RATE/1e6:g} MS/s; "
|
|
f"{args.rate/1e6:g} is not enough to see a bit.[/red]")
|
|
return 2
|
|
|
|
if args.simulate:
|
|
sky = default_sky(*_near(args.near)) if args.near else default_sky()
|
|
device = SimulatedSky(sky, sample_rate=args.rate,
|
|
realtime=True).open()
|
|
console.print("[yellow]simulated: these aircraft are not there."
|
|
"[/yellow]")
|
|
else:
|
|
try:
|
|
device = RtlSdrDevice(index=args.device, sample_rate=int(args.rate),
|
|
gain=args.gain, agc=args.gain == "auto")
|
|
device.open()
|
|
except RtlSdrError as exc:
|
|
console.print(Panel(Text(str(exc)),
|
|
title="[red]cannot open the receiver",
|
|
border_style="red"))
|
|
return 1
|
|
|
|
started = time.time()
|
|
log = None
|
|
if args.log_frames:
|
|
stamp = datetime.fromtimestamp(started).strftime("%Y-%m-%d_%H_%M_%S")
|
|
where = Path(args.log).expanduser() if args.log else \
|
|
Path(cfg.output_dir).expanduser() / f"adsb_{stamp}.jsonl"
|
|
try:
|
|
log = FlightLog(where, frequency=ADSB_HZ, sample_rate=args.rate,
|
|
receiver="simulated" if args.simulate else
|
|
f"device {args.device}", started=started)
|
|
except OSError as exc:
|
|
console.print(f"[red]cannot write {where}: {exc}[/red]")
|
|
log = None
|
|
|
|
registry = AircraftRegistry()
|
|
total = 0
|
|
console.print(f"[grey62]listening on {ADSB_HZ/1e6:g} MHz at "
|
|
f"{args.rate/1e6:g} MS/s — control-C to stop[/grey62]")
|
|
if log is not None:
|
|
console.print(f"[grey62]writing {log.path}[/grey62]")
|
|
try:
|
|
device.tune(ADSB_HZ)
|
|
block = int(args.rate) # a second at a time
|
|
while True:
|
|
at = time.time()
|
|
samples = device.read_samples(block)
|
|
if samples is None or samples.size == 0:
|
|
break
|
|
for frame in decode_frames(samples, args.rate):
|
|
# The real time the frame arrived, not its offset in the
|
|
# block: everything downstream is a clock, and a log that
|
|
# started again from zero every second would be unusable.
|
|
when = at + frame.at_sample / args.rate
|
|
craft = registry.add(frame, when=when)
|
|
total += 1
|
|
if log is not None:
|
|
log.append(frame, craft, when=when)
|
|
if args.frames:
|
|
console.print(f"[cyan]{frame.icao}[/cyan] "
|
|
f"{escape(frame.describe())}",
|
|
highlight=False)
|
|
if not args.frames and total:
|
|
console.print(f"[grey62]{len(registry)} aircraft, "
|
|
f"{total} frames[/grey62]", highlight=False)
|
|
if args.seconds and time.time() - started >= args.seconds:
|
|
break
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
device.close()
|
|
if log is not None:
|
|
log.close()
|
|
|
|
if not registry:
|
|
console.print("[yellow]nothing heard. ADS-B needs an aerial cut for "
|
|
"1090 MHz; the whip that came with the dongle will "
|
|
"hear the airport and not much else.[/yellow]")
|
|
return 1
|
|
|
|
book = FlightBook(online=args.lookup)
|
|
_aircraft_table(registry, book, total)
|
|
if args.lookup:
|
|
book.wait(12.0)
|
|
book.save()
|
|
_lookup_table(registry, book)
|
|
|
|
tracks = read_logs(log.path) if log is not None else _tracks_from(registry)
|
|
if args.kml is not None:
|
|
where = Path(args.kml).expanduser() if args.kml else \
|
|
(log.path.with_suffix(".kml") if log is not None
|
|
else Path("aircraft.kml"))
|
|
written = write_kml(where, tracks, book if args.lookup else None)
|
|
console.print(f"[green]{written}[/green]" if written
|
|
else "[yellow]nothing was placed on the map[/yellow]")
|
|
if log is not None:
|
|
told = log.path.with_suffix(".txt")
|
|
try:
|
|
told.write_text("\n".join(report(
|
|
tracks, book if args.lookup else None,
|
|
title=f"bandsaunter — aircraft heard "
|
|
f"{datetime.fromtimestamp(started):%Y-%m-%d %H:%M}")))
|
|
console.print(f"[green]{told}[/green]")
|
|
except OSError as exc:
|
|
console.print(f"[red]cannot write {told}: {exc}[/red]")
|
|
if args.map is not None:
|
|
_draw_flights(tracks, args.map or (log.path.with_suffix(".gif")
|
|
if log is not None
|
|
else Path("aircraft.gif")),
|
|
book if args.lookup else None)
|
|
elif log is not None:
|
|
console.print(f"[grey62]draw it: bandsaunter flights {log.path}"
|
|
"[/grey62]")
|
|
return 0
|
|
|
|
|
|
def _near(text: str) -> tuple[float, float]:
|
|
"""Read a LAT,LON pair, falling back to the default sky."""
|
|
try:
|
|
lat, lon = (float(x) for x in str(text).split(",", 1))
|
|
return lat, lon
|
|
except (TypeError, ValueError):
|
|
console.print(f"[yellow]cannot read {text!r} as a latitude and "
|
|
"longitude; flying somewhere else instead[/yellow]")
|
|
return 47.55, -122.30
|
|
|
|
|
|
def _tracks_from(registry):
|
|
"""Tracks from a registry, for a session that wrote no log.
|
|
|
|
One position each: what is on the screen is all there is, because nothing
|
|
kept the ones before it.
|
|
"""
|
|
from .flightlog import Fix, Track
|
|
|
|
out = []
|
|
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
|
|
track = Track(icao=craft.icao, callsign=craft.callsign,
|
|
frames=craft.messages, first_seen=craft.first_seen,
|
|
last_seen=craft.last_seen)
|
|
if craft.located:
|
|
track.fixes.append(Fix(at=craft.last_seen, latitude=craft.latitude,
|
|
longitude=craft.longitude,
|
|
altitude_ft=craft.altitude_ft,
|
|
ground_speed_kt=craft.ground_speed_kt,
|
|
track_deg=craft.track_deg,
|
|
vertical_rate_fpm=craft.vertical_rate_fpm))
|
|
out.append(track)
|
|
return out
|
|
|
|
|
|
def _aircraft_table(registry, book, total: int) -> None:
|
|
"""What was heard, as it was heard: no register, only the air."""
|
|
t = Table(title=f"{len(registry)} aircraft, {total} frames", box=None,
|
|
header_style="bold")
|
|
for column in ("ICAO", "callsign", "altitude", "position", "speed",
|
|
"frames"):
|
|
t.add_column(column)
|
|
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
|
|
t.add_row(craft.icao, craft.callsign or "",
|
|
f"{craft.altitude_ft:,} ft" if craft.altitude_ft else "",
|
|
(f"{craft.latitude:.4f}, {craft.longitude:.4f}"
|
|
if craft.located else ""),
|
|
(f"{craft.ground_speed_kt:.0f} kt {craft.track_deg:.0f}°"
|
|
if craft.ground_speed_kt else ""),
|
|
str(craft.messages))
|
|
console.print(t)
|
|
|
|
|
|
def _lookup_table(registry, book) -> None:
|
|
"""And what the registers say about them, kept separate on purpose."""
|
|
rows = []
|
|
for craft in sorted(registry.aircraft.values(), key=lambda a: a.icao):
|
|
entry = book.get(craft.icao, craft.callsign)
|
|
told = entry.summary()
|
|
if told or entry.country:
|
|
rows.append((craft.icao, craft.callsign or "", entry.country,
|
|
told or "—"))
|
|
if not rows:
|
|
return
|
|
t = Table(title="what the registers say", box=None, header_style="bold")
|
|
for column in ("ICAO", "callsign", "registered", "aircraft, operator, route"):
|
|
t.add_column(column, overflow="fold")
|
|
for row in rows:
|
|
t.add_row(*row)
|
|
console.print(t)
|
|
|
|
|
|
def _draw_flights(tracks, out_path, book, **over) -> bool:
|
|
"""Draw the animation, saying what it is drawing and what came out."""
|
|
from .flightmap import animate, ffmpeg_available
|
|
|
|
out_path = Path(out_path).expanduser()
|
|
if out_path.suffix.lower() in (".mp4", ".mov", ".m4v") and \
|
|
not ffmpeg_available():
|
|
console.print("[yellow]ffmpeg is not installed; writing a GIF "
|
|
"instead[/yellow]")
|
|
out_path = out_path.with_suffix(".gif")
|
|
console.print(f"[grey62]drawing {out_path.name}…[/grey62]")
|
|
try:
|
|
drawn = animate(tracks, out_path, book=book, **over)
|
|
except (OSError, RuntimeError, ValueError) as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
return False
|
|
if drawn is None:
|
|
console.print("[yellow]nothing was placed on the map: no aircraft "
|
|
"reported a position[/yellow]")
|
|
return False
|
|
size = drawn.path.stat().st_size / 1e6
|
|
console.print(f"[green]{drawn.path}[/green] "
|
|
f"[grey62]{drawn.summary()}, {size:.1f} MB[/grey62]")
|
|
return True
|
|
|
|
|
|
def cmd_flights(args) -> int:
|
|
"""Turn a log of ADS-B frames into something worth looking at.
|
|
|
|
The log is a list of times and places; this is the tool that reads it
|
|
back, asks who the aircraft were, prints what it found and draws the
|
|
whole evening as a map with the clock running.
|
|
"""
|
|
from .flightlog import read_logs, report, write_kml
|
|
from .flights import FlightBook
|
|
|
|
cfg, _ = load_default()
|
|
paths = [Path(p).expanduser() for p in args.path] if args.path \
|
|
else _newest_log(Path(cfg.output_dir).expanduser())
|
|
if not paths:
|
|
console.print("[yellow]no ADS-B logs found. Record one with "
|
|
"`bandsaunter adsb`.[/yellow]")
|
|
return 1
|
|
for path in paths:
|
|
if not path.exists():
|
|
console.print(f"[red]no such file: {path}[/red]")
|
|
return 1
|
|
|
|
tracks = read_logs(paths)
|
|
if not tracks:
|
|
console.print(f"[yellow]{paths[0].name} holds no frames[/yellow]")
|
|
return 1
|
|
book = FlightBook(online=args.lookup)
|
|
if args.lookup:
|
|
for track in tracks:
|
|
book.get(track.icao, track.callsign)
|
|
book.wait(20.0)
|
|
book.save()
|
|
|
|
title = f"bandsaunter — {paths[0].name}"
|
|
lines = report(tracks, book if args.lookup else None, title=title)
|
|
console.print(escape("\n".join(lines)), highlight=False)
|
|
if args.report is not None:
|
|
where = Path(args.report).expanduser() if args.report \
|
|
else paths[0].with_suffix(".txt")
|
|
try:
|
|
where.write_text("\n".join(lines))
|
|
console.print(f"[green]{where}[/green]")
|
|
except OSError as exc:
|
|
console.print(f"[red]cannot write {where}: {exc}[/red]")
|
|
if args.kml is not None:
|
|
where = Path(args.kml).expanduser() if args.kml \
|
|
else paths[0].with_suffix(".kml")
|
|
written = write_kml(where, tracks, book if args.lookup else None)
|
|
console.print(f"[green]{written}[/green]" if written
|
|
else "[yellow]nothing was placed on the map[/yellow]")
|
|
if not args.draw:
|
|
return 0
|
|
|
|
out = Path(args.out).expanduser() if args.out else \
|
|
paths[0].with_suffix(".gif")
|
|
drawn = _draw_flights(tracks, out, book if args.lookup else None,
|
|
fps=args.fps, seconds=args.seconds, speed=args.speed,
|
|
width=args.width, trail_seconds=args.trail,
|
|
stale=args.stale, labels=args.labels)
|
|
return 0 if drawn else 1
|
|
|
|
|
|
def _newest_log(directory: Path) -> list[Path]:
|
|
"""The last ADS-B log written, which is nearly always the one wanted."""
|
|
try:
|
|
logs = sorted(directory.glob("adsb_*.jsonl"),
|
|
key=lambda p: p.stat().st_mtime)
|
|
except OSError:
|
|
return []
|
|
return logs[-1:]
|
|
|
|
|
|
def cmd_analyze(args) -> int:
|
|
import numpy as np
|
|
from .classify import classify
|
|
from .morse import decode_morse
|
|
|
|
path = Path(args.path)
|
|
if not path.exists():
|
|
console.print(f"[red]no such file: {path}[/red]")
|
|
return 1
|
|
|
|
rate = args.rate
|
|
freq = args.freq
|
|
# A recording directory carries its own metadata; use it when present.
|
|
meta_file = path.parent / "meta.json"
|
|
if meta_file.exists():
|
|
try:
|
|
meta = json.loads(meta_file.read_text())
|
|
freq = freq or meta.get("frequency_hz", 0.0)
|
|
if rate is None:
|
|
rate = (meta.get("iq_rate") if path.suffix != ".wav"
|
|
else meta.get("audio_rate"))
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
if path.suffix == ".wav":
|
|
import wave
|
|
with wave.open(str(path)) as w:
|
|
rate = rate or w.getframerate()
|
|
raw = w.readframes(w.getnframes())
|
|
audio = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
|
|
console.print(f"[grey62]{path.name}: {audio.size/rate:.1f} s of audio "
|
|
f"at {rate:g} Hz[/grey62]")
|
|
from .pictures import find_image
|
|
picture = find_image(audio, rate, frequency=freq)
|
|
if picture is not None and picture.ok:
|
|
out = path.with_suffix(".png")
|
|
picture.save(out)
|
|
console.print(Panel(
|
|
Text.from_markup(
|
|
f"[bold]{escape(picture.summary())}[/bold]\n\n"
|
|
f"[green]{escape(str(out))}[/green]"),
|
|
title="picture", border_style="magenta"))
|
|
for name, pixels in picture.channels.items():
|
|
from .images import ImageDecode
|
|
extra = ImageDecode(ok=True, pixels=pixels)
|
|
where = path.with_name(path.stem + f"_{name}.png")
|
|
extra.save(where)
|
|
console.print(f"[grey62]{where}[/grey62]", highlight=False)
|
|
return 0
|
|
m = decode_morse(audio, rate)
|
|
if m.is_morse or args.morse:
|
|
console.print(Panel(
|
|
Text.from_markup(
|
|
f'[bold]{m.text.strip() or "(nothing decoded)"}[/bold]\n\n'
|
|
f"[grey62]{m.wpm:.0f} WPM, tone {m.tone_hz:.0f} Hz, "
|
|
f"confidence {m.confidence}[/grey62]"),
|
|
title="CW / Morse", border_style="green"))
|
|
else:
|
|
console.print("[yellow]no Morse found in this audio "
|
|
f"({'; '.join(m.notes) or 'no keyed tone'})[/yellow]")
|
|
return 0
|
|
|
|
if rate is None:
|
|
console.print("[red]--rate is required for raw IQ files[/red]")
|
|
return 2
|
|
if path.suffix == ".cs16":
|
|
raw = np.fromfile(path, dtype="<i2").astype(np.float32) / 32768.0
|
|
iq = raw[0::2] + 1j * raw[1::2]
|
|
else:
|
|
iq = np.fromfile(path, dtype=np.complex64)
|
|
console.print(f"[grey62]{path.name}: {iq.size} samples, "
|
|
f"{iq.size/rate:.2f} s at {rate:g} Hz[/grey62]")
|
|
|
|
cls = classify(iq, rate, freq_hz=freq or 0.0, snr_db=20.0)
|
|
body = [f"[bold]{cls.label}[/bold] ({cls.confidence*100:.0f}% confident)"]
|
|
for r in cls.reasons:
|
|
body.append(f"[grey62]- {r}[/grey62]")
|
|
if cls.alternatives:
|
|
body.append("[grey62]other candidates: " +
|
|
", ".join(f"{n} ({c*100:.0f}%)" for n, c in cls.alternatives) +
|
|
"[/grey62]")
|
|
console.print(Panel(Text.from_markup("\n".join(body)),
|
|
title="identification", border_style="green"))
|
|
|
|
# Whatever it is, try to read it: the whole point of pointing this at a
|
|
# file is to find out what is in it.
|
|
from .decode import decode_data
|
|
got = decode_data(iq, rate, family=cls.family,
|
|
baud_hint=cls.features.baud if cls.features else 0.0)
|
|
if got.ok:
|
|
# Printed as plain text, not markup: a decoded packet is arbitrary
|
|
# bytes from the air, and square brackets in it are common.
|
|
lines = got.report()
|
|
body = Text(lines[0], style="bold")
|
|
for line in lines[1:]:
|
|
body.append("\n" + line)
|
|
body.append(f"\n{got.confidence * 100:.0f}% confident",
|
|
style="not bold grey62")
|
|
console.print(Panel(body, title="decoded data", border_style="cyan"))
|
|
elif cls.family in ("ook", "fsk", "psk", "digital", "control"):
|
|
console.print(f"[yellow]nothing decoded: {got.note}[/yellow]")
|
|
|
|
f = cls.features
|
|
if f:
|
|
t = Table(box=None, header_style="bold")
|
|
t.add_column("measurement")
|
|
t.add_column("value", justify="right")
|
|
rows = [("occupied bandwidth", fmt_hz(f.bandwidth)),
|
|
("spectral flatness", f"{f.flatness:.3f}"),
|
|
("envelope variation", f"{f.env_cv:.3f}"),
|
|
("rms deviation", fmt_hz(f.fdev_rms)),
|
|
("discriminator levels", str(f.freq_modes)),
|
|
("symbol rate", f"{f.baud:.0f} baud" if f.baud else "-"),
|
|
("CTCSS tone", f"{f.ctcss_hz:.1f} Hz" if f.ctcss_hz else "-"),
|
|
("on/off contrast", f"{f.ook_contrast_db:.1f} dB")]
|
|
for k, v in rows:
|
|
t.add_row(k, v)
|
|
console.print(t)
|
|
return 0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main(argv=None) -> int:
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.command is None:
|
|
cfg, source = load_default()
|
|
if is_first_run() and sys.stdin.isatty() and sys.stdout.isatty():
|
|
try:
|
|
first_run_setup(console, cfg)
|
|
except TUIAbort:
|
|
console.print()
|
|
return 0
|
|
cfg = run_tui(console, cfg, source)
|
|
if cfg is None:
|
|
return 0
|
|
try:
|
|
device = _make_device(cfg, False)
|
|
except RtlSdrError as exc:
|
|
console.print(Panel(Text(str(exc)),
|
|
title="[red]cannot open the receiver",
|
|
border_style="red"))
|
|
return 1
|
|
scanner = Scanner(cfg, device=device, callbacks=ScannerCallbacks())
|
|
try:
|
|
scanner.prepare()
|
|
except (ValueError, RangeError, RtlSdrError) as exc:
|
|
console.print(f"[red]{exc}[/red]")
|
|
return 2
|
|
signal.signal(signal.SIGINT, lambda *a: scanner.stop())
|
|
_run_live(scanner, cfg)
|
|
_print_summary(scanner)
|
|
return 0
|
|
|
|
handlers = {
|
|
"scan": cmd_scan, "bands": cmd_bands, "devices": cmd_devices,
|
|
"config": cmd_config, "transcribe": cmd_transcribe,
|
|
"profiles": cmd_profiles, "analyze": cmd_analyze, "analyse": cmd_analyze,
|
|
"adsb": cmd_adsb, "waterfall": cmd_waterfall,
|
|
"flights": cmd_flights,
|
|
}
|
|
try:
|
|
return handlers[args.command](args)
|
|
except KeyboardInterrupt:
|
|
console.print("\n[grey62]interrupted[/grey62]")
|
|
return 130
|
|
except BrokenPipeError:
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|