bandsaunter/bandsaunter/cli.py
The Dust Council 2e20c48971 Mark the aerodromes in the window, and draw the lot on a vector display
The aerodromes were never on the window at all: only the animation drew
them, and the window's map had whatever airport glyphs the tiles happened to
carry.  They are drawn there now, in the same colour and the same square.

The first attempt at that had a bug worth naming, because it would have
looked like the feature simply not working.  They were fetched inside the
same pass of the fetching loop as a piece of map, so they queued behind a
hundred and twenty tiles coming off a network -- and once the map was in
hand there were no more passes, so they were never fetched again.  They are
their own question now, asked on the same thread but not behind the tiles,
and they arrive whether the tiles do or not.  An area that has been asked
about and has none in it is remembered as none, rather than asked about
again five times a second for the rest of the night.  And the thread now
starts if either the map or the aerodromes are wanted, so --no-basemap no
longer quietly takes the airports with it.

Then the themes, which change the window and the animated pictures together
because both read their colours out of the same palette.  night is what this
program has always drawn and is untouched.  digital, phosphor, amber and red
are the screens the phrase "air defence display" actually calls to mind: a
black tube, one phosphor, and thin bright vector lines with a halo round
them.

Three things follow from having one colour to spend, and they are
constraints rather than decoration.  Height becomes brightness, since hue is
no longer free -- low is dim and high burns, which is the trade those
displays made.  The map underneath drops to about a quarter of the
brightness asked for, because a tinted photograph of a county behind the
vectors is the one thing that stops a vector display looking like one.  And
a country is named in two letters rather than drawn as a flag, a flag being
half a dozen colours.

The glow is done twice, differently, because the two are different kinds of
picture.  The window lays each line down two or three times, wider and
fainter each pass, with the core last: trails, symbols, leader lines, box
borders and the aerodrome squares.  The animation cannot blend at all, a GIF
being indexed colour, so it dilates what it has drawn and fills the halo
with the dimmed copy of the colour underneath -- and the aircraft colours
already had dimmed copies, since those are the trail shades, so an aeroplane
glows into the colour its own trail is drawn in, which is the colour a
phosphor would have spread into.  The fixed colours get two rings each in
the palette for the purpose.  The halo goes over the map, the grid and the
background and over nothing else that was drawn, since a halo is what light
does to the dark around a line; where two rings meet the nearer wins.  It
costs about 55 ms a frame at 1400 by 1258 and the default theme skips the
pass entirely.

The palette is written over in place rather than replaced, because both
drawings and every one of their helpers hold a reference to that array and a
new one would leave half the program painting in the colours of the theme
before.  There is a test that every theme keeps the aerodrome colour more
than forty units of CIELAB from every altitude colour, stated as the
distance rather than as the colour, so that a new theme cannot quietly walk
an aircraft back into the airports.

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

1406 lines
59 KiB
Python
Executable file

"""Command line interface."""
from __future__ import annotations
import argparse
import json
import signal
import sys
import time
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_notice
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 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
""")
# The GNU form: the version, then who holds the copyright and what the
# licence is, because the licence tells the person running it what they
# are allowed to do with it and the program is the only thing in front
# of them.
p.add_argument("--version", action="version", version=version_notice())
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("--schedules", default=None, metavar="NAMES",
help="which schedule services to ask, comma separated: "
"flightaware, flightradar24, oag, cirium "
"(each needs a key in the environment)")
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.add_argument("--speed-unit", default=None,
choices=("knots", "mph", "kph"),
help="what to show speeds and distances in "
"(default: knots, which is what aircraft broadcast)")
ad.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None,
help="draw the map with no real map under it")
ad.add_argument("--window", action="store_true",
help="open a window and show the aircraft on a map as "
"they are heard, instead of a table in the terminal")
ad.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad",
"p1", "crimson"),
help="how the window and the map look: night (the "
"default), or the vector-display themes digital, "
"phosphor, amber and red")
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("--fade", type=float, default=None, metavar="SECONDS",
help="how long an aircraft takes to fade away once it "
"has gone quiet (0 to remove it at once)")
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("--schedules", default=None, metavar="NAMES",
help="which schedule services to ask, comma separated: "
"flightaware, flightradar24, oag, cirium "
"(each needs a key in the environment)")
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.add_argument("--speed-unit", default=None,
choices=("knots", "mph", "kph"),
help="what to show speeds and distances in "
"(default: knots, which is what aircraft broadcast)")
fl.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None,
help="draw the tracks on their own, with no map under them")
fl.add_argument("--tiles", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)")
fl.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT",
help="how bright the map under the aircraft is (10-100)")
fl.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red",
"blue", "green", "orange", "wargames", "norad",
"p1", "crimson"),
help="how the map looks: night (the default), or the "
"vector-display themes digital, phosphor, amber "
"and red")
fl.add_argument("--no-airports", dest="airports", action="store_false",
default=None,
help="do not mark the aerodromes under the flight paths")
fl.add_argument("--radius", type=float, default=None, metavar="MILES",
help="how far around the receiver the map reaches, in the "
"same unit as the speeds (0 = fit what was heard)")
fl.add_argument("--at", default=None, metavar="LAT,LON",
help="where the receiver is (default: worked out from "
"what it heard)")
fl.add_argument("--recheck", action="store_true",
help="throw out positions the aircraft could not have "
"been in, for logs recorded before the decoder "
"checked the age of a position pair")
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 _warn_about_aircraft_bands(cfg: ScanConfig) -> None:
"""Say so when a sweep is pointed at something it cannot decode.
The band plan lists 1090 MHz because that is where ADS-B is, so choosing
it from the band plan is the obvious thing to do and the wrong one. The
sweep is not stopped -- looking at the spectrum there is a fair thing to
want -- but it no longer happens silently.
"""
from . import aircraft as air
warning = air.scanning_aircraft_band(cfg.ranges)
if not warning:
return
console.print(Panel(
Text.from_markup(
f"{escape(warning)}\n\n"
"[bold]bandsaunter adsb[/bold] decodes it properly: aircraft, "
"positions, altitudes and speeds, written to a log.\n"
"[bold]bandsaunter flights[/bold] then draws where they went.\n\n"
"[grey62]Both are in the menus as well, under Aircraft "
"(ADS-B). Scanning it anyway is fine if what you want is the "
"raw spectrum \u2014 add --save-iq to keep the samples."
"[/grey62]"),
title="[yellow]this band needs the aircraft mode",
border_style="yellow", padding=(0, 1)))
def _make_device(cfg: ScanConfig, simulate: bool):
if simulate:
from .simulator import SimulatedDevice
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
_warn_about_aircraft_bands(cfg)
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.
The listening itself is in :mod:`bandsaunter.aircraft`, because the menus
do exactly the same thing and neither front end should own it.
"""
from .adsb import SAMPLE_RATE
from . import aircraft as air
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
options = air.load_options()
options.seconds = args.seconds
options.rate = args.rate
options.gain = args.gain
options.device = args.device
options.frames = args.frames
options.log = args.log_frames
options.lookup = args.lookup
options.simulate = args.simulate
options.kml = args.kml is not None
options.draw_after = args.map is not None
if args.near:
options.near = args.near
if args.speed_unit:
options.speed_unit = args.speed_unit
if args.basemap is not None:
options.basemap = args.basemap
if args.schedules is not None:
options.schedules = args.schedules
if getattr(args, "theme", None):
options.theme = args.theme
if args.map:
options.picture = Path(args.map).suffix.lstrip(".") or options.picture
run = air.watch if args.window else air.listen
heard = run(console, options, cfg.output_dir, log_path=args.log)
if not heard.aircraft:
return 1
if args.kml and heard.kml_path is None:
# An explicit path was given, so honour it rather than the one beside
# the log that `listen` writes by default.
from .flightlog import write_kml
write_kml(Path(args.kml).expanduser(), heard.tracks)
if heard.log_path is not None and not options.draw_after:
console.print(f"[grey62]draw it: bandsaunter flights {heard.log_path}"
"[/grey62]")
return 0
def 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 . import aircraft as air
from .flightlog import read_logs, report, write_kml
from .flights import FlightBook
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else air.logs_in(cfg.output_dir)[:1]
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
options = air.load_options()
if args.speed_unit:
options.speed_unit = args.speed_unit
if args.recheck:
options.recheck = True
if args.schedules is not None:
options.schedules = args.schedules
if getattr(args, "theme", None):
options.theme = args.theme
tracks = air.checked(console, options, tracks)
book = FlightBook(online=args.lookup,
schedules=air.schedule_names(options))
if args.lookup:
for track in tracks:
# The moment it was overhead, so a schedule service can say
# which leg was in the air then rather than which is now.
when = (track.fixes[len(track.fixes) // 2].at if track.located
else track.last_seen)
book.get(track.icao, track.callsign, when)
book.wait(20.0)
book.save()
title = f"bandsaunter — {paths[0].name}"
lines = report(tracks, book if args.lookup else None, title=title,
unit=options.speed_unit)
console.print(escape("\n".join(lines)), highlight=False)
if args.report is not None:
where = Path(args.report).expanduser() if args.report \
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,
unit=options.speed_unit)
console.print(f"[green]{written}[/green]" if written
else "[yellow]nothing was placed on the map[/yellow]")
if not args.draw:
return 0
options.fps = args.fps
options.length = args.seconds
options.speed = args.speed
options.width = args.width
options.trail = args.trail
options.stale = args.stale
options.labels = args.labels
if args.fade is not None:
options.fade = args.fade
if args.basemap is not None:
options.basemap = args.basemap
if args.tiles:
options.tile_url = args.tiles
if args.map_brightness is not None:
options.map_brightness = args.map_brightness
if getattr(args, "theme", None):
options.theme = args.theme
if args.airports is not None:
options.airports = args.airports
if args.radius is not None:
options.radius = args.radius
if args.at:
options.location = args.at
out = Path(args.out).expanduser() if args.out else \
paths[0].with_suffix("." + options.picture)
drawn = air.draw(console, options, tracks, out,
book if args.lookup else None)
return 0 if drawn else 1
def 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())