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
2272 lines
105 KiB
Python
Executable file
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())
|