bandsaunter/bandsaunter/cli.py
The Dust Council 93120b80a6 Listen to FT8: fifteen seconds of everybody at once
A new section, alongside the aircraft, the weather sensors and APRS.

FT8 is the odd one out among the things this program listens to, and the
reason is worth stating because it shapes everything below.  Every station on
the band transmits in the same quarter-minute slots, on the same dial
frequency, fifty hertz wide each, stacked across three kilohertz of audio.
One receiver parked on one frequency therefore hears the whole band's worth of
stations at once -- and hears most of them well below the noise, because half
of what is sent is error-correcting code.  That is the entire trick: a rate of
about one half buys a mode that decodes twenty-odd decibels under what an
operator can hear.  A receiver that took the loudest tone of each symbol and
hoped would decode almost nothing, which is why the tone detector reports how
confident it is bit by bit rather than what it thinks it heard.

Written from first principles except for two tables.  The checksum, the
belief propagation over the sparse graph, the Costas sync search, the
waterfall, the soft-bit metric, and the seventy-seven bits that hold two
callsigns and a grid square are all here.  The generator and the parity-check
matrix are not: they cannot be derived, being the code itself rather than
consequences of anything, so they are taken from ft8_lib under its MIT licence
with the attribution it asks for, and said so in the readme, the manual and
the file.  No decoding logic came with them.  That the two agree -- and they
are not derivable from one another, the generator's parity half running to
fifty-odd bits a row against the sparse matrix's six or seven -- is a test
rather than an assumption.

Tested against the air, not against itself.  Eleven off-air recordings with
published decodes: ninety-seven of a hundred and fifty messages, no false
decodes, timing within a hundredth of a second, frequency within a hertz,
signal reports within half a decibel on average.  The third not decoded are
the weakest in each slot; a mature decoder subtracts what it has decoded and
looks again in the remainder, and does ordered-statistics decoding where
belief propagation fails, and neither is built here.  What is here decodes
nothing that other receivers did not also hear, which is the property that
matters in a log.  Ten whole codewords lifted off the air are in the tests as
a permanent fixture, so the recordings can go missing and the regression
cannot.

Three things that looked like bugs and were not, and three that were.  The
half-second timing discrepancy was the convention: a transmission is 12.64
seconds in a slot of fifteen and everybody starts half a second in, so
lateness is reported against that.  Synthetic signals at known offsets proved
the clock self-consistent before anything was changed.  The signal reports
were twenty-one decibels optimistic because those recordings have a receiver
passband above three kilohertz, putting a whole-band median twelve to sixteen
decibels below the real noise floor -- so noise is now measured beside the
signal, and in the tone that was actually sent rather than the loudest of
eight, the largest of eight noisy numbers being well above their mean even
with no signal at all.  And the test transmitter was thirteen decibels
pessimistic, scaling its noise into a fifty-hertz reference instead of the
sampled bandwidth, which made the decoder look deaf when it was the test
signal that had been quietly attenuated.

The real bug the simulator caught was a one-block timestamp error: samples
were dated a block earlier than they were taken, which slid every slot slice a
second late and cut the first half-second -- three symbols, part of the
opening Costas array -- off every transmission on the band.  One decode a slot
became six.

Reachable both ways, as everything here is.  Twenty-one options, every one of
them a command-line flag and a line in the menu, both built from one table so
they cannot disagree -- and a test that says so, since an option in no group
would be settable from the command line and invisible in the menu.  The band
list says which channels a plain dongle can reach and which need an
upconverter, because almost all the activity is on shortwave and finding that
out by listening to silence for ten minutes is the wrong way to learn it.  The
default is two metres, which a plain dongle can hear.

--grid turns decodes into geography: every CQ says where it is, so each gets a
distance and a bearing and the furthest is named.  --adif writes the log in
the form every amateur logging program imports, marked as heard rather than
worked, because nothing here transmits and an ADIF that let a logging program
treat these as contacts would put claims into somebody's log that they cannot
make.

One bug shipped and found by being used rather than by being tested: the
line that opens the receiver called a function this program has never had.
Every test reached it through the simulator, which takes the other branch, so
the one line that matters to somebody with an aerial was the one line never
run.  There is now a check that every name these modules import actually
exists -- it names the missing one rather than failing somewhere downstream --
and two that say a receiver which cannot be opened is reported rather than
raised, and that nothing claims to be listening before there is one.  It had
been announcing the frequency first, so a dongle that would not open read as
listening that had gone wrong.

Ninety-six new tests.  Full suite 2781 passed.  Built as 2026-09-21_04.

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

2272 lines
105 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 .ax25 import APRS_CHANNELS as _APRS_CHANNELS
from .ft8 import BANDS as _FT8_BANDS
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 adsb --window live map of the aircraft
bandsaunter flights --out sky.gif animate what they did
bandsaunter flights --theme phosphor draw it as a vector display
bandsaunter weather read the 433 MHz weather sensors
bandsaunter weather --name 1A2B="back fence" name one while listening
bandsaunter readings --csv turn a weather log into a graph
bandsaunter sensors what is out there, and what it is called
bandsaunter aprs read the APRS channel on 144.39 MHz
bandsaunter aprs --region europe ...or 144.80 MHz, or wherever you are
bandsaunter aprs --window a live map of the stations heard
bandsaunter packets --kml turn an APRS log into a map
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("--lookup", dest="lookup",
action="store_true", default=None,
help="ask the public registers who each aircraft is")
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("--basemap", dest="basemap",
action="store_true", default=None,
help="draw a real map under the flight paths")
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("--rings", dest="rings", action="store_true",
default=None,
help="faint discs at a quarter, a half and three "
"quarters of the radius, labelled with the distance")
ad.add_argument("--no-rings", dest="rings", action="store_false",
default=None,
help="draw no range rings on the map")
ad.add_argument("--window-rings", dest="window_rings",
action="store_true", default=None,
help="range rings on the realtime window too")
ad.add_argument("--no-window-rings", dest="window_rings",
action="store_false", default=None,
help="no range rings on the realtime window")
ad.add_argument("--box-opacity", type=int, default=None,
metavar="PERCENT",
help="how solid the card behind each information box is "
"(0-100; 0 puts the words straight on the map)")
ad.add_argument("--at", default=None, metavar="LAT,LON",
help="where the receiver is: the middle of the window "
"and of any map drawn afterwards")
ad.add_argument("--radius", type=float, default=None, metavar="MILES",
help="how far around the receiver the window and the map "
"reach, in the same unit as the speeds")
ad.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long an aircraft stays on the display after "
"its last frame")
ad.add_argument("--tiles", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)")
ad.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT",
help="how bright the map under the aircraft is (10-100)")
ad.add_argument("--width", type=int, default=None,
help="how wide any map drawn afterwards is, in pixels")
ad.add_argument("--fps", type=float, default=None,
help="frames a second in any animation drawn afterwards")
ad.add_argument("--trail", type=float, default=None, metavar="SECONDS",
help="how much of the path to leave behind each aircraft")
ad.add_argument("--fade", type=float, default=None, metavar="SECONDS",
help="how long an aircraft takes to fade away once it "
"has gone quiet")
ad.add_argument("--stale", type=float, default=None, metavar="SECONDS",
help="stop drawing an aircraft this long after its last "
"report")
ad.add_argument("--airports", dest="airports", action="store_true",
default=None,
help="mark every aerodrome on the map")
ad.add_argument("--no-airports", dest="airports", action="store_false",
default=None,
help="do not mark the aerodromes")
ad.add_argument("--labels", dest="labels", action="store_true",
default=None,
help="write the callsign, height and speed beside each "
"aircraft")
ad.add_argument("--no-labels", dest="labels", action="store_false",
default=None,
help="draw the aircraft without labels beside them")
ad.add_argument("--pulse", dest="pulse", action="store_true",
default=None, help="swell each aircraft from bright to "
"dim and back")
ad.add_argument("--no-pulse", dest="pulse", action="store_false",
default=None, help="draw the aircraft at a steady "
"brightness")
ad.add_argument("--pulse-rate", type=float, default=None,
metavar="SECONDS",
help="how long one swell takes, in seconds of watching")
ad.add_argument("--echo", dest="echo", action="store_true",
default=None, help="rings travelling outward from each "
"aircraft")
ad.add_argument("--no-echo", dest="echo", action="store_false",
default=None, help="no rings")
ad.add_argument("--echo-every", type=float, default=None,
metavar="SECONDS",
help="how long between one ring and the next")
ad.add_argument("--echo-size", type=int, default=None, metavar="PIXELS",
help="how far a ring gets before it has faded away")
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)
# -- aprs -----------------------------------------------------------------
ap = sub.add_parser("aprs",
help="read the APRS channel: positions, weather, "
"messages, telemetry")
ap.add_argument("--seconds", type=float, default=None,
help="stop after this long (default: until interrupted)")
ap.add_argument("--rate", type=float, default=None,
help="sample rate in Hz; 96 kS/s is the least that holds "
"the channel")
ap.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
ap.add_argument("--device", type=int, default=None, help="which receiver")
ap.add_argument("--region", default=None,
choices=[key for key, _hz, _w in _APRS_CHANNELS],
help="which APRS channel to listen on")
ap.add_argument("--frequency", "--freq", dest="frequency", type=float,
default=None, metavar="HZ",
help="the exact frequency, if the region's channel is "
"not what you want")
ap.add_argument("--window", action="store_true",
help="open a window and show the stations on a map as "
"they are heard, instead of a table in the terminal")
ap.add_argument("--radius", type=float, default=None, metavar="KM",
help="how far around the aerial the window reaches")
ap.add_argument("--theme", default=None, metavar="NAME",
choices=("night", "digital", "phosphor", "amber", "red"),
help="how the window looks")
ap.add_argument("--map-brightness", type=int, default=None,
metavar="PERCENT",
help="how bright the ground under the stations is (10-100)")
ap.add_argument("--basemap", dest="basemap", action="store_true",
default=None, help="draw a real map under the stations")
ap.add_argument("--no-basemap", dest="basemap", action="store_false",
default=None, help="no map tiles")
ap.add_argument("--window-rings", dest="window_rings",
action="store_true", default=None,
help="faint range discs around the aerial")
ap.add_argument("--no-window-rings", dest="window_rings",
action="store_false", default=None, help="no range rings")
ap.add_argument("--box-opacity", type=int, default=None,
metavar="PERCENT",
help="how solid the card behind each information box is")
ap.add_argument("--trails", dest="trails", action="store_true",
default=None, help="draw the path behind what moved")
ap.add_argument("--no-trails", dest="trails", action="store_false",
default=None, help="no trails")
ap.add_argument("--tiles", dest="tile_url", default=None, metavar="URL",
help="where map tiles come from ({z}/{x}/{y}.png)")
ap.add_argument("--find-channel", nargs="?", type=float, const=20.0,
default=None, metavar="SECONDS",
help="listen on each region's channel in turn and say "
"which has traffic, instead of listening on one "
"(default: 20 seconds each)")
ap.add_argument("--simulate", dest="simulate", action="store_true",
default=None,
help="invent a channel full of stations, for a receiver "
"with no aerial")
ap.add_argument("--no-simulate", dest="simulate", action="store_false",
default=None, help="listen to real stations")
ap.add_argument("--log", default=None, metavar="FILE",
help="where to write the packet log "
"(default: aprs_<time>.jsonl in the output directory)")
ap.add_argument("--no-log", dest="log_packets", action="store_false",
default=None, help="listen without writing anything down")
ap.add_argument("--packets", dest="packets_seen", action="store_true",
default=None,
help="print every packet as it arrives, not a table")
ap.add_argument("--no-packets", dest="packets_seen", action="store_false",
default=None, help="show the table that updates in place")
ap.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a station stays on the display after its "
"last packet")
ap.add_argument("--at", dest="location", default=None, metavar="LAT,LON",
help="where the aerial is, so distances can be worked out")
ap.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in (the log is always metric)")
ap.add_argument("--unparsed", dest="unparsed", action="store_true",
default=None,
help="list packets whose format cannot be read")
ap.add_argument("--no-unparsed", dest="unparsed", action="store_false",
default=None, help="only packets that could be read")
ap.add_argument("--digipeated", dest="digipeated", action="store_true",
default=None, help="include packets that reached here "
"through a digipeater")
ap.add_argument("--direct-only", dest="digipeated", action="store_false",
default=None,
help="only what was heard without a relay in between")
ap.add_argument("--report", dest="report", action="store_true",
default=None, help="print what was heard at the end")
ap.add_argument("--no-report", dest="report", action="store_false",
default=None, help="no report when the listening stops")
ap.add_argument("--csv", dest="csv", action="store_true", default=None,
help="also write the packets as CSV beside the log")
ap.add_argument("--no-csv", dest="csv", action="store_false", default=None,
help="no spreadsheet")
ap.add_argument("--kml", dest="kml", action="store_true", default=None,
help="also write the stations and tracks for Google Earth")
ap.add_argument("--no-kml", dest="kml", action="store_false", default=None,
help="no map")
# -- packets --------------------------------------------------------------
pk = sub.add_parser("packets",
help="read an APRS log: report, spreadsheet, map")
pk.add_argument("path", nargs="*",
help="packet logs (default: the newest in the output "
"directory)")
pk.add_argument("--csv", nargs="?", const="", default=None, metavar="FILE",
help="write the packets as CSV (default: beside the log)")
pk.add_argument("--kml", nargs="?", const="", default=None, metavar="FILE",
help="write the stations and tracks for Google Earth")
pk.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in")
pk.add_argument("--station", default=None, metavar="CALL",
help="only this station, by callsign")
pk.add_argument("--at", dest="location", default=None, metavar="LAT,LON",
help="where the aerial was, so distances can be shown")
pk.add_argument("--no-report", dest="report", action="store_false",
default=True, help="write the files and say nothing")
# -- weather -------------------------------------------------------------
we = sub.add_parser("weather",
help="read the AcuRite weather sensors on 433 MHz")
we.add_argument("--seconds", type=float, default=None,
help="stop after this long (default: until interrupted)")
we.add_argument("--rate", type=float, default=None,
help="sample rate in Hz; a quarter of a megasample is the "
"minimum")
we.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
we.add_argument("--device", type=int, default=None, help="which receiver")
we.add_argument("--frequency", "--freq", dest="frequency", type=float,
default=None, metavar="HZ",
help="where the sensors are (default: 433.92 MHz)")
we.add_argument("--offset", type=float, default=None, metavar="HZ",
help="how far to one side of them to tune, to keep the "
"receiver's own spike off the signal")
we.add_argument("--simulate", dest="simulate", action="store_true",
default=None,
help="invent a garden of sensors, for a receiver with no "
"aerial")
we.add_argument("--no-simulate", dest="simulate", action="store_false",
default=None, help="listen to real sensors")
we.add_argument("--log", default=None, metavar="FILE",
help="where to write the message log "
"(default: weather_<time>.jsonl in the output "
"directory)")
we.add_argument("--no-log", dest="log_messages", action="store_false",
default=None, help="listen without writing anything down")
we.add_argument("--messages", dest="messages", action="store_true",
default=None,
help="print every message as it arrives, not a table")
we.add_argument("--no-messages", dest="messages", action="store_false",
default=None, help="show the table that updates in place")
we.add_argument("--diagnose", dest="diagnose", action="store_true",
default=None,
help="say what each second of band looked like at every "
"stage, for when sensors you know are there are not "
"appearing")
we.add_argument("--no-diagnose", dest="diagnose", action="store_false",
default=None, help="the ordinary display")
we.add_argument("--from-iq", default=None, metavar="FILE",
help="read a saved capture instead of the receiver, so a "
"recording made where the aerial is can be worked on "
"anywhere")
we.add_argument("--save-iq", default=None, metavar="FILE",
help="also write the raw samples, for working out why "
"something will not decode (2 MB a second at the "
"default rate, so bound it with --seconds)")
we.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a sensor stays on the display after its "
"last message")
we.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in (the log is always metric)")
we.add_argument("--only-named", dest="only_named", action="store_true",
default=None,
help="ignore sensors that have not been given a name")
we.add_argument("--all-sensors", dest="only_named", action="store_false",
default=None, help="show every sensor heard")
we.add_argument("--unknown", dest="unknown", action="store_true",
default=None,
help="list sensors whose model cannot be read")
we.add_argument("--no-unknown", dest="unknown", action="store_false",
default=None, help="only sensors that can be read")
we.add_argument("--report", dest="report", action="store_true",
default=None, help="print what each sensor said at the end")
we.add_argument("--no-report", dest="report", action="store_false",
default=None, help="no report when the listening stops")
we.add_argument("--csv", dest="csv", action="store_true", default=None,
help="also write the readings as CSV beside the log")
we.add_argument("--no-csv", dest="csv", action="store_false", default=None,
help="no spreadsheet")
we.add_argument("--name", action="append", default=[], metavar="ID=NAME",
help="give a sensor a friendly name before listening; "
"repeatable, and the same thing the n key does while "
"the display is running")
# -- readings -------------------------------------------------------------
rd = sub.add_parser("readings",
help="read a weather log: report, and a spreadsheet")
rd.add_argument("path", nargs="*",
help="weather logs (default: the newest in the output "
"directory)")
rd.add_argument("--csv", nargs="?", const="", default=None, metavar="FILE",
help="write the readings as CSV (default: beside the log)")
rd.add_argument("--units", default=None, choices=("metric", "imperial"),
help="what to show readings in")
rd.add_argument("--sensor", default=None, metavar="NAME",
help="only this sensor, by name or by identity")
rd.add_argument("--no-report", dest="report", action="store_false",
default=True, help="write the spreadsheet and say nothing")
# -- sensors --------------------------------------------------------------
se = sub.add_parser("sensors",
help="what has been heard, and what it is called")
se.add_argument("--name", action="append", default=[], metavar="ID=NAME",
help="give a sensor a friendly name; repeatable")
se.add_argument("--note", action="append", default=[], metavar="ID=TEXT",
help="anything else worth remembering about a sensor")
se.add_argument("--forget", action="append", default=[], metavar="ID",
help="remove a sensor from the list entirely")
# -- 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("--labels", dest="labels",
action="store_true", default=None,
help="write the callsign, height and speed beside each aircraft")
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("--lookup", dest="lookup",
action="store_true", default=None,
help="ask the public registers who each aircraft is")
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("--basemap", dest="basemap",
action="store_true", default=None,
help="draw a real map under the flight paths")
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("--rings", dest="rings", action="store_true",
default=None,
help="faint discs at a quarter, a half and three "
"quarters of the radius, labelled with the distance")
fl.add_argument("--no-rings", dest="rings", action="store_false",
default=None,
help="draw no range rings on the map")
fl.add_argument("--box-opacity", type=int, default=None,
metavar="PERCENT",
help="how solid the card behind each information box is "
"(0-100; 0 puts the words straight on the map)")
fl.add_argument("--pulse", dest="pulse", action="store_true",
default=None, help="swell each aircraft from bright to "
"dim and back")
fl.add_argument("--no-pulse", dest="pulse", action="store_false",
default=None, help="draw the aircraft at a steady "
"brightness")
fl.add_argument("--pulse-rate", type=float, default=None,
metavar="SECONDS",
help="how long one swell takes, in seconds of watching")
fl.add_argument("--echo", dest="echo", action="store_true",
default=None, help="rings travelling outward from each "
"aircraft")
fl.add_argument("--no-echo", dest="echo", action="store_false",
default=None, help="no rings")
fl.add_argument("--echo-every", type=float, default=None,
metavar="SECONDS",
help="how long between one ring and the next")
fl.add_argument("--echo-size", type=int, default=None, metavar="PIXELS",
help="how far a ring gets before it has faded away")
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("--airports", dest="airports",
action="store_true", default=None,
help="mark every aerodrome on the map")
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")
# -- ft8 ------------------------------------------------------------------
f8 = sub.add_parser("ft8",
help="listen to FT8: fifteen-second slots, forty "
"stations at once, most of them under the noise")
f8.add_argument("--device", type=int, default=None, help="which receiver")
f8.add_argument("--gain", default=None, help="tuner gain in dB, or auto")
f8.add_argument("--rate", type=float, default=None,
help="sample rate in Hz; 96 kS/s is the least that holds "
"three kilohertz of audio")
f8.add_argument("--band", default=None,
choices=[key for key, _hz, _n in _FT8_BANDS],
help="which FT8 channel to listen on; sets the frequency")
f8.add_argument("--frequency", "--freq", dest="frequency", type=float,
default=None, metavar="HZ",
help="the dial frequency, for an upconverter or a "
"channel the band list does not have")
f8.add_argument("--simulate", dest="simulate", action="store_true",
default=None, help="invent a band instead of using a "
"receiver")
f8.add_argument("--no-simulate", dest="simulate", action="store_false",
help="use the receiver (the default)")
f8.add_argument("--seconds", type=float, default=None,
help="stop after this long (default: until interrupted)")
f8.add_argument("--slots", type=int, default=None, metavar="N",
help="stop after this many fifteen-second slots")
f8.add_argument("--log", dest="log", action="store_true", default=None,
help="write a log of every decode (the default)")
f8.add_argument("--no-log", dest="log", action="store_false",
help="do not write a log")
f8.add_argument("--decodes-seen", dest="decodes_seen",
action="store_true", default=None,
help="print a line per decode instead of a live table")
f8.add_argument("--no-decodes-seen", dest="decodes_seen",
action="store_false", help="show the live table")
f8.add_argument("--hold", type=float, default=None, metavar="SECONDS",
help="how long a station stays on the display")
f8.add_argument("--lowest", type=float, default=None, metavar="HZ",
help="the lowest audio frequency to search")
f8.add_argument("--highest", type=float, default=None, metavar="HZ",
help="the highest audio frequency to search")
f8.add_argument("--most", type=int, default=None, metavar="N",
help="how many candidate transmissions to try per slot")
f8.add_argument("--rounds", type=int, default=None, metavar="N",
help="how many error-correction passes before giving up")
f8.add_argument("--grid", default=None, metavar="SQUARE",
help="your own grid square, so distances can be worked "
"out (four characters, like IO91)")
f8.add_argument("--units", default=None, choices=("metric", "imperial"),
help="which units to show distances in")
f8.add_argument("--calls-only", dest="calls_only", action="store_true",
default=None, help="leave out free text and telemetry")
f8.add_argument("--no-calls-only", dest="calls_only",
action="store_false", help="show everything decoded")
f8.add_argument("--report", dest="report", action="store_true",
default=None, help="print the tables at the end")
f8.add_argument("--no-report", dest="report", action="store_false",
help="do not print the tables")
f8.add_argument("--csv", dest="csv", action="store_true", default=None,
help="also write a spreadsheet of every decode")
f8.add_argument("--no-csv", dest="csv", action="store_false",
help="do not write a spreadsheet")
f8.add_argument("--adif", dest="adif", action="store_true", default=None,
help="also write an ADIF of stations heard, for a "
"logging program")
f8.add_argument("--no-adif", dest="adif", action="store_false",
help="do not write an ADIF")
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 _warn_about_sensor_bands(cfg: ScanConfig) -> None:
"""The same courtesy for 433 MHz, which is in the band plan too.
A sweep of it is a fair thing to want -- there is a great deal there
besides weather -- so the sweep is not stopped, only told about.
"""
from . import weather as wx
warning = wx.scanning_sensor_band(cfg.ranges)
if not warning:
return
console.print(Panel(
Text.from_markup(
f"{escape(warning)}\n\n"
"[bold]bandsaunter weather[/bold] decodes them properly: "
"temperature, humidity, wind, rain and lightning, from each "
"sensor by name.\n"
"[bold]bandsaunter readings[/bold] then turns the log into a "
"spreadsheet.\n\n"
"[grey62]Both are in the menus as well, under Weather sensors "
"(433 MHz). Scanning it anyway is fine if what you want is the "
"raw spectrum \u2014 there are doorbells, car keys and tyre "
"sensors there too.[/grey62]"),
title="[yellow]this band needs the weather 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)
_warn_about_sensor_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 getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity
for flag in ("pulse", "pulse_rate", "echo", "echo_every", "echo_size"):
value = getattr(args, flag, None)
if value is not None:
setattr(options, flag, value)
if getattr(args, "rings", None) is not None:
options.rings = args.rings
if getattr(args, "window_rings", None) is not None:
options.window_rings = args.window_rings
for flag, key in (("at", "location"), ("radius", "radius"),
("hold", "hold"), ("tiles", "tile_url"),
("map_brightness", "map_brightness"),
("width", "width"), ("fps", "fps"), ("trail", "trail"),
("fade", "fade"), ("stale", "stale"),
("airports", "airports"), ("labels", "labels"),
("basemap", "basemap"), ("lookup", "lookup")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
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 _weather_options(args, options):
"""Fold whatever was given on the command line into the saved options.
Every one of these defaults to None rather than to the option's default,
so that a flag left off means "whatever was saved" rather than "the
factory setting". A person who has set the gain in the menu and then
runs `bandsaunter weather --seconds 60` should get their gain.
"""
for flag, key in (("seconds", "seconds"), ("rate", "rate"),
("gain", "gain"), ("device", "device"),
("frequency", "frequency"), ("offset", "offset"),
("simulate", "simulate"), ("log_messages", "log"),
("messages", "messages"), ("diagnose", "diagnose"),
("hold", "hold"),
("units", "units"), ("only_named", "only_named"),
("unknown", "unknown"), ("report", "report"),
("csv", "csv")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
return options
def _name_sensors(book, given, quiet: bool = False) -> int:
"""Apply every --name or --note on the command line. Returns how many.
The identity may be given as it appears on the display -- 1A2B -- or as
the whole key, tower/1A2B, which is what to use on the vanishingly rare
occasion that two models have drawn the same identity out of the hat.
"""
done = 0
for pair in given or ():
ident, _, name = str(pair).partition("=")
ident, name = ident.strip(), name.strip()
if not ident or not name:
console.print(f"[yellow]--name wants ID=NAME, not {pair!r}"
"[/yellow]")
continue
found = book.find(ident)
if not found:
# Nothing of that identity has been heard yet. Named anyway,
# under the key as typed: the sensor on the shed is on the shed
# whether or not it has been received in the last five minutes,
# and a name waiting for it is better than a name refused.
from .sensors import UNHEARD
key = ident if "/" in ident else f"{UNHEARD}/{ident.upper()}"
book.tag(key, name)
if not quiet:
console.print(f" [green]{ident} is now {name}[/green] "
f"[grey62](not heard yet)[/grey62]")
done += 1
continue
if len(found) > 1:
console.print(f"[yellow]{ident!r} matches "
f"{', '.join(s.key for s in found)} — "
"use the whole key[/yellow]")
continue
book.tag(found[0].key, name)
if not quiet:
console.print(f" [green]{found[0].sensor} is now {name}[/green]")
done += 1
return done
def cmd_aprs(args) -> int:
"""Park on the APRS channel and write down everything that passes.
A command of its own because a scan cannot do this: APRS is a two-second
transmission every few minutes from a hundred stations sharing one
frequency, and a sweep catches whichever one happened to key up while the
sweep was pointed there.
"""
from . import aprs as ap
cfg, _ = load_default()
options = ap.load_options()
for flag, key in (("seconds", "seconds"), ("rate", "rate"),
("gain", "gain"), ("device", "device"),
("region", "region"), ("frequency", "frequency"),
("simulate", "simulate"), ("log_packets", "log"),
("packets_seen", "packets_seen"), ("hold", "hold"),
("location", "location"), ("units", "units"),
("unparsed", "unparsed"), ("digipeated", "digipeated"),
("report", "report"), ("csv", "csv"), ("kml", "kml"),
("radius", "radius"), ("theme", "theme"),
("map_brightness", "map_brightness"),
("basemap", "basemap"),
("window_rings", "window_rings"),
("box_opacity", "box_opacity"), ("trails", "trails"),
("tile_url", "tile_url")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
# The region picks the frequency unless the frequency was given outright,
# which is the one order that lets both flags mean what they say.
if getattr(args, "region", None) and getattr(args, "frequency", None) is None:
ap.use_region(options, options.region)
errs = options.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
if args.find_channel is not None:
seconds = max(2.0, float(args.find_channel))
console.print(f"[grey62]each of {len(_APRS_CHANNELS)} channels for "
f"{seconds:g} s \u2014 about "
f"{seconds * len(_APRS_CHANNELS):.0f} s altogether"
f"[/grey62]")
best = ap.report_channels(console,
ap.find_channel(console, options, seconds),
seconds)
if best is None:
return 1
console.print(f"[grey62]listen on it with `bandsaunter aprs --region "
f"{best.region}`, or set it once in the menu under "
f"APRS \u2192 Channel[/grey62]")
return 0
run = ap.watch if args.window else ap.listen
heard = run(console, options, cfg.output_dir, log_path=args.log)
if not heard.stations:
console.print("[grey62]nothing decoded — `bandsaunter aprs "
"--find-channel` listens on every region's channel and "
"says which has traffic, which is the fault that looks "
"most like a dead aerial and is not[/grey62]")
return 0 if heard.stations else 1
def cmd_packets(args) -> int:
"""Read an APRS log back: what was heard, and where it was."""
from . import aprs as ap
from .aprslog import logs_in, read_logs, write_csv, write_kml
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else logs_in(cfg.output_dir)[:1]
if not paths:
console.print("[yellow]no APRS logs found. Record one with "
"`bandsaunter aprs`.[/yellow]")
return 1
for path in paths:
if not path.exists():
console.print(f"[red]no such file: {path}[/red]")
return 1
heard = read_logs(paths)
if not heard:
console.print(f"[yellow]{paths[0].name} holds no packets[/yellow]")
return 1
options = ap.load_options()
for flag, key in (("units", "units"), ("location", "location")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
if args.station:
wanted = args.station.strip().upper()
heard = [p for p in heard
if wanted in (p.source.upper(), p.station.upper())]
if not heard:
console.print(f"[yellow]nothing in the log from "
f"{args.station!r}[/yellow]")
return 1
if args.report:
ap.report(console, ap.Net.of(heard), options)
for flag, writer, suffix in ((args.csv, write_csv, ".csv"),
(args.kml, write_kml, ".kml")):
if flag is None:
continue
where = Path(flag).expanduser() if flag else paths[0].with_suffix(suffix)
try:
written = writer(where, heard, options.imperial)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return 1
if written is None:
console.print("[yellow]no station said where it was, so there "
"is no map to draw[/yellow]")
else:
console.print(f"[green]wrote {written}[/green] "
f"[grey62]{len(heard):,} packets[/grey62]")
return 0
def cmd_weather(args) -> int:
"""Park the receiver on 433.92 MHz and read the weather sensors.
A command of its own for the same reason the aircraft mode is: this does
not fit through the scanner. A sensor message is a burst of on-off
keying, and the scan path is a squelch and a recorder -- it would record
the bursts as clicks in a WAV file and decode nothing.
"""
from . import weather as wx
from .sensors import SensorBook
cfg, _ = load_default()
options = _weather_options(args, wx.load_options())
errs = options.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
device = None
if args.from_iq:
try:
device = wx.Replay(args.from_iq)
except (OSError, ValueError) as exc:
console.print(f"[red]cannot read {args.from_iq}: {exc}[/red]")
return 1
if not len(device):
console.print(f"[red]{args.from_iq} holds no samples[/red]")
return 1
# The capture decides these, not the saved settings: read at the
# wrong rate every pulse in it is the wrong length.
options.rate, options.offset = device.rate, device.offset
options.frequency, options.simulate = device.frequency, False
console.print(f"[grey62]replaying {device.seconds:.1f} s from "
f"{args.from_iq} at {device.rate/1e6:g} MS/s, "
f"offset {device.offset/1e3:g} kHz"
f"{'' if device.settings else ' (no settings beside it '
'— assuming the defaults)'}[/grey62]")
book = SensorBook()
_name_sensors(book, args.name)
heard = wx.listen(console, options, cfg.output_dir, log_path=args.log,
book=book, save_iq=args.save_iq, device=device)
if not heard.sensors and not options.diagnose:
console.print("[grey62]nothing decoded — `bandsaunter weather "
"--diagnose` says which stage it stops at[/grey62]")
return 0 if heard.sensors else 1
def cmd_readings(args) -> int:
"""Read a weather log back: what was heard, and what it said."""
from . import weather as wx
from .sensors import SensorBook
from .weatherlog import logs_in, read_logs, write_csv
cfg, _ = load_default()
paths = [Path(p).expanduser() for p in args.path] if args.path \
else logs_in(cfg.output_dir)[:1]
if not paths:
console.print("[yellow]no weather logs found. Record one with "
"`bandsaunter weather`.[/yellow]")
return 1
for path in paths:
if not path.exists():
console.print(f"[red]no such file: {path}[/red]")
return 1
readings = read_logs(paths)
if not readings:
console.print(f"[yellow]{paths[0].name} holds no readings[/yellow]")
return 1
options = wx.load_options()
if args.units:
options.units = args.units
book = SensorBook()
if args.sensor:
wanted = {s.key for s in book.find(args.sensor)}
wanted |= {r.key for r in readings
if args.sensor.strip().lower() in r.key.lower()}
readings = [r for r in readings if r.key in wanted]
if not readings:
console.print(f"[yellow]nothing in the log matches "
f"{args.sensor!r}[/yellow]")
return 1
if args.report:
wx.report(console, wx.Garden.of(readings), book, options.imperial)
if args.csv is not None:
where = Path(args.csv).expanduser() if args.csv \
else paths[0].with_suffix(".csv")
try:
written = write_csv(where, readings, book, options.imperial)
except OSError as exc:
console.print(f"[red]cannot write {where}: {exc}[/red]")
return 1
console.print(f"[green]wrote {written}[/green] "
f"[grey62]{len(readings):,} readings[/grey62]")
return 0
def cmd_sensors(args) -> int:
"""List what has been heard, and give things names.
The list is everything ever heard rather than everything heard lately,
because a sensor with a flat battery is exactly the one somebody wants
to look up.
"""
from .sensors import SensorBook
book = SensorBook()
changed = _name_sensors(book, args.name)
for pair in args.note or ():
ident, _, note = str(pair).partition("=")
found = book.find(ident.strip())
if len(found) == 1:
book.tag(found[0].key, found[0].name, note.strip())
changed += 1
else:
console.print(f"[yellow]--note: nothing matches "
f"{ident.strip()!r}[/yellow]")
for ident in args.forget or ():
found = book.find(str(ident).strip())
if len(found) == 1:
book.forget(found[0].key)
console.print(f" [green]forgot {found[0].sensor}[/green]")
changed += 1
else:
console.print(f"[yellow]--forget: nothing matches "
f"{ident!r}[/yellow]")
if not len(book):
console.print("[yellow]no sensors heard yet. Listen with "
"`bandsaunter weather`.[/yellow]")
return 1
t = Table(box=None, header_style="bold", pad_edge=False,
title="[bold]sensors[/bold]", title_justify="left")
t.add_column("name", overflow="fold")
t.add_column("id", style="grey62", no_wrap=True)
t.add_column("decimal", style="grey62", no_wrap=True, justify="right")
t.add_column("key", style="grey62", no_wrap=True)
t.add_column("model", style="grey62", overflow="fold")
t.add_column("ch", style="grey62", justify="center")
t.add_column("msgs", justify="right", style="grey62")
t.add_column("last heard", style="grey62", no_wrap=True)
t.add_column("note", style="grey62", overflow="fold")
for sensor in book.ordered():
last = time.strftime("%Y-%m-%d %H:%M",
time.localtime(sensor.last_heard)) \
if sensor.last_heard else ""
t.add_row(sensor.name or "[yellow]unnamed[/yellow]", sensor.sensor,
sensor.number, sensor.key, sensor.model, sensor.channel,
f"{sensor.messages:,}", last, sensor.note)
console.print(t)
console.print(f"[grey62]kept in {book.path} — name one with "
f"`bandsaunter sensors --name ID=NAME`, in either column: "
f"the decimal is the same identity, and is what rtl_433 "
f"and anything built on it prints[/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
if getattr(args, "box_opacity", None) is not None:
options.box_opacity = args.box_opacity
for flag in ("pulse", "pulse_rate", "echo", "echo_every", "echo_size"):
value = getattr(args, flag, None)
if value is not None:
setattr(options, flag, value)
if getattr(args, "rings", None) is not None:
options.rings = args.rings
if getattr(args, "window_rings", None) is not None:
options.window_rings = args.window_rings
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, "weather": cmd_weather,
"readings": cmd_readings, "sensors": cmd_sensors,
"aprs": cmd_aprs, "packets": cmd_packets,
"ft8": cmd_ft8,
}
try:
return handlers[args.command](args)
except KeyboardInterrupt:
console.print("\n[grey62]interrupted[/grey62]")
return 130
except BrokenPipeError:
return 0
def cmd_ft8(args) -> int:
"""Park on an FT8 channel and decode every slot.
Fifteen seconds of everybody at once: one dial frequency carries the
whole band's worth of stations, fifty hertz apart across three
kilohertz of audio, and most of them arrive below the noise.
"""
from . import ft8
cfg, _ = load_default()
options = ft8.load_options()
for flag, key in (("device", "device"), ("gain", "gain"),
("rate", "rate"), ("band", "band"),
("frequency", "frequency"), ("simulate", "simulate"),
("seconds", "seconds"), ("slots", "slots"),
("log", "log"), ("decodes_seen", "decodes_seen"),
("hold", "hold"), ("lowest", "lowest"),
("highest", "highest"), ("most", "most"),
("rounds", "rounds"), ("grid", "grid"),
("units", "units"), ("calls_only", "calls_only"),
("report", "report"), ("csv", "csv"),
("adif", "adif")):
value = getattr(args, flag, None)
if value is not None:
setattr(options, key, value)
# The band picks the frequency unless the frequency was given outright,
# which is the one order that lets both flags mean what they say.
if getattr(args, "band", None) and getattr(args, "frequency", None) is None:
ft8.use_band(options, options.band)
errs = options.validate()
if errs:
for e in errs:
console.print(f"[red]{e}[/red]")
return 2
ft8.listen(console, options, cfg.output_dir)
return 0
if __name__ == "__main__":
sys.exit(main())