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
1406 lines
59 KiB
Python
Executable file
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())
|