Initial commit: bandsaunter, an RTL-SDR signal scanner
Sweeps any set of frequency ranges, records what it finds, and works out what kind of signal it was. - Frequency ranges entered by hand or picked from a 135-entry US band plan, including whole-band and all-CW sweeps that resolve the demodulator per segment. - Detection calibrated against the peak-hold detector's own noise statistics, so the threshold means real margin over static rather than over the floor. - A content gate: captures are kept only if they carry voice, decodable CW, or an identified digital keying scheme. Speech is recognised by a pitch track that drifts, which static cannot imitate. - Identification of NFM/WFM/AM/SSB, CW with Morse decoded to text, P25, DMR, NXDN, D-STAR, POCSAG, FLEX, ACARS, AIS, APRS, n-FSK and n-PSK. - Gapless streaming capture, with the signal path fast enough to keep up in real time, so recordings play back at the right speed. - Optional one-file-per-frequency recording with spoken timestamps, and speech-to-text transcription. - Menus and command line generated from one settings table, so neither can offer something the other cannot; settings persist in ~/.config. 367 tests, run against synthetic signals, a built-in receiver simulator, and real hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
db3e0c79b9
39 changed files with 13473 additions and 0 deletions
697
bandsaunter/tui.py
Normal file
697
bandsaunter/tui.py
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
"""The in-application interface: configure everything without command-line flags.
|
||||
|
||||
Every setting the command line accepts is reachable here, because both are
|
||||
generated from the same table in :mod:`bandsaunter.settings`. Each one carries
|
||||
its own help, so nothing has to be looked up elsewhere.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rich.console import Console
|
||||
from rich.panel import Panel
|
||||
from rich.prompt import Confirm, Prompt
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
from . import bandplan, settings as st
|
||||
from .bandplan import CATEGORIES, PRESETS, BandPreset, fmt_hz, in_category, search
|
||||
from .config import (DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH,
|
||||
DEFAULT_OUTPUT_DIR, ScanConfig,
|
||||
delete_profile, list_profiles, load_config, save_config,
|
||||
save_default)
|
||||
from .ranges import RangeError, ScanRange, parse_frequency
|
||||
|
||||
__all__ = ["run_tui", "show_ranges", "settings_menu", "help_screen",
|
||||
"first_run_setup", "TUIAbort"]
|
||||
|
||||
_BACK = ("", "b", "back", "q", "quit", "x")
|
||||
|
||||
|
||||
def _rule(console: Console, text: str) -> None:
|
||||
console.print()
|
||||
console.rule(f"[bold]{text}[/bold]", style="blue")
|
||||
|
||||
|
||||
class TUIAbort(Exception):
|
||||
"""Input ended or the user interrupted; unwind out of the menus."""
|
||||
|
||||
|
||||
def _ask(console: Console, prompt: str, default: str = "") -> str:
|
||||
try:
|
||||
return Prompt.ask(prompt, default=default, show_default=bool(default))
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
# Returning an empty string here would send the menu round again and,
|
||||
# with no input left to read, round forever.
|
||||
raise TUIAbort() from None
|
||||
|
||||
|
||||
def _confirm(prompt: str, default: bool = False) -> bool:
|
||||
"""Yes/no that treats a closed input as "no" rather than crashing."""
|
||||
try:
|
||||
return Confirm.ask(prompt, default=default)
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ranges
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def show_ranges(console: Console, cfg: ScanConfig) -> None:
|
||||
if not cfg.ranges:
|
||||
console.print("[yellow]No frequency ranges selected yet.[/yellow] "
|
||||
"[grey62]Add some with 1 or 2.[/grey62]")
|
||||
return
|
||||
t = Table(box=None, header_style="bold", pad_edge=False)
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("label")
|
||||
t.add_column("from", justify="right")
|
||||
t.add_column("to", justify="right")
|
||||
t.add_column("span", justify="right", style="grey62")
|
||||
t.add_column("mode", justify="center")
|
||||
t.add_column("on", justify="center")
|
||||
total = 0.0
|
||||
for i, r in enumerate(cfg.ranges, 1):
|
||||
if r.enabled:
|
||||
total += r.span
|
||||
t.add_row(str(i), r.label, fmt_hz(r.start), fmt_hz(r.stop),
|
||||
fmt_hz(r.span), r.mode,
|
||||
"[green]yes[/green]" if r.enabled else "[red]no[/red]")
|
||||
console.print(t)
|
||||
console.print(f"[grey62]{len(cfg.ranges)} range(s), {fmt_hz(total)} of "
|
||||
f"spectrum enabled[/grey62]")
|
||||
|
||||
|
||||
def add_manual_ranges(console: Console, cfg: ScanConfig) -> None:
|
||||
_rule(console, "add frequency ranges")
|
||||
console.print(
|
||||
"Enter a start and end frequency for each range. Units may be "
|
||||
"written [cyan]144M[/cyan], [cyan]144 MHz[/cyan], "
|
||||
"[cyan]144000k[/cyan] or plain Hz; a bare number under 10000 is read "
|
||||
"as MHz.\nLeave the start blank when you are done. There is no limit "
|
||||
"on how many ranges you add.\n")
|
||||
added = 0
|
||||
while True:
|
||||
start_s = _ask(console, f" [bold]start[/bold] of range "
|
||||
f"{len(cfg.ranges) + 1}")
|
||||
if not start_s.strip():
|
||||
break
|
||||
try:
|
||||
start = parse_frequency(start_s)
|
||||
except RangeError as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
continue
|
||||
end_s = _ask(console, " [bold]end[/bold] of range")
|
||||
try:
|
||||
stop = parse_frequency(end_s) if end_s.strip() else start
|
||||
except RangeError as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
continue
|
||||
|
||||
r = ScanRange(start, stop)
|
||||
covering = bandplan.presets_covering(0.5 * (r.start + r.stop))
|
||||
if covering:
|
||||
best = min(covering, key=lambda p: p.span)
|
||||
r.label = f"{fmt_hz(r.start)}-{fmt_hz(r.stop)} ({best.name})"
|
||||
console.print(f" [grey62]that falls in: {best.name} — default "
|
||||
f"mode {best.mode}[/grey62]")
|
||||
mode = _ask(console, " mode [grey62](auto/nfm/wfm/am/usb/lsb/cw/raw)"
|
||||
"[/grey62]", "auto").lower()
|
||||
r.mode = mode if mode in ("auto", "nfm", "wfm", "am", "usb", "lsb",
|
||||
"cw", "raw") else "auto"
|
||||
cfg.ranges.append(r)
|
||||
added += 1
|
||||
console.print(f" [green]added[/green] {r.describe()}\n")
|
||||
if added:
|
||||
console.print(f"[green]{added} range(s) added.[/green]")
|
||||
|
||||
|
||||
def choose_presets(console: Console, cfg: ScanConfig) -> None:
|
||||
while True:
|
||||
_rule(console, "US band plan")
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("category")
|
||||
t.add_column("presets", justify="right", style="grey62")
|
||||
for i, cat in enumerate(CATEGORIES, 1):
|
||||
t.add_row(str(i), cat, str(len(in_category(cat))))
|
||||
console.print(t)
|
||||
console.print("[grey62]Enter a category number, a search term, or "
|
||||
"blank to go back.[/grey62]")
|
||||
answer = _ask(console, " category or search").strip()
|
||||
if not answer:
|
||||
return
|
||||
if answer.isdigit() and 1 <= int(answer) <= len(CATEGORIES):
|
||||
presets = in_category(CATEGORIES[int(answer) - 1])
|
||||
heading = CATEGORIES[int(answer) - 1]
|
||||
else:
|
||||
presets = search(answer)
|
||||
heading = f"search: {answer!r}"
|
||||
if not presets:
|
||||
console.print(f" [yellow]nothing matched {answer!r}[/yellow]")
|
||||
continue
|
||||
_pick_from(console, cfg, presets, heading)
|
||||
|
||||
|
||||
def _pick_from(console: Console, cfg: ScanConfig,
|
||||
presets: list[BandPreset], heading: str) -> None:
|
||||
_rule(console, heading)
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("name")
|
||||
t.add_column("range", justify="right")
|
||||
t.add_column("mode", justify="center")
|
||||
t.add_column("notes", style="grey62", overflow="fold", max_width=42)
|
||||
for i, p in enumerate(presets, 1):
|
||||
extent = (f"{len(p.expand())} ranges" if p.is_group
|
||||
else f"{fmt_hz(p.start)} - {fmt_hz(p.stop)}")
|
||||
t.add_row(str(i), p.name, extent, p.mode, p.note)
|
||||
console.print(t)
|
||||
console.print("[grey62]Numbers to add ([cyan]1,3,5[/cyan] or "
|
||||
"[cyan]1-4[/cyan]), [cyan]all[/cyan], or blank to go "
|
||||
"back.[/grey62]")
|
||||
answer = _ask(console, " add").strip().lower()
|
||||
if not answer:
|
||||
return
|
||||
chosen = list(presets) if answer == "all" else _expand(answer, presets)
|
||||
existing = {r.preset_key for r in cfg.ranges if r.preset_key}
|
||||
added = 0
|
||||
hf = False
|
||||
for picked in chosen:
|
||||
# A preset may stand for a set of others; add what it actually scans.
|
||||
for p in picked.expand():
|
||||
if p.key in existing:
|
||||
continue
|
||||
cfg.ranges.append(ScanRange.from_preset(p))
|
||||
existing.add(p.key)
|
||||
added += 1
|
||||
hf |= p.needs_direct_sampling
|
||||
if hf:
|
||||
console.print(" [yellow]some of these are below 24 MHz — they need "
|
||||
"direct sampling and an HF antenna.[/yellow]")
|
||||
console.print(f" [green]added {added} range(s)[/green]")
|
||||
|
||||
|
||||
def _expand(answer: str, items: list) -> list:
|
||||
out = []
|
||||
for tok in answer.replace(" ", "").split(","):
|
||||
if not tok:
|
||||
continue
|
||||
if "-" in tok:
|
||||
a, _, b = tok.partition("-")
|
||||
if a.isdigit() and b.isdigit():
|
||||
out += [items[i - 1] for i in range(int(a), int(b) + 1)
|
||||
if 1 <= i <= len(items)]
|
||||
elif tok.isdigit() and 1 <= int(tok) <= len(items):
|
||||
out.append(items[int(tok) - 1])
|
||||
return out
|
||||
|
||||
|
||||
def edit_ranges(console: Console, cfg: ScanConfig) -> None:
|
||||
while True:
|
||||
_rule(console, "ranges")
|
||||
show_ranges(console, cfg)
|
||||
console.print(
|
||||
"\n [cyan]a[/cyan] add by hand "
|
||||
"[cyan]p[/cyan] add from the band plan\n"
|
||||
" [cyan]r[/cyan] remove "
|
||||
"[cyan]t[/cyan] toggle on/off\n"
|
||||
" [cyan]m[/cyan] change mode "
|
||||
"[cyan]c[/cyan] clear all\n"
|
||||
" [cyan]b[/cyan] back\n")
|
||||
choice = _ask(console, " choice", "b").strip().lower()
|
||||
if choice in _BACK:
|
||||
return
|
||||
if choice == "a":
|
||||
add_manual_ranges(console, cfg)
|
||||
elif choice == "p":
|
||||
choose_presets(console, cfg)
|
||||
elif choice == "c":
|
||||
if cfg.ranges and _confirm(" remove every range"):
|
||||
cfg.ranges = []
|
||||
elif choice in ("r", "t", "m") and cfg.ranges:
|
||||
which = _ask(console, " which numbers").strip()
|
||||
picked = _expand(which, list(range(1, len(cfg.ranges) + 1)))
|
||||
if not picked:
|
||||
console.print(" [yellow]nothing selected[/yellow]")
|
||||
continue
|
||||
if choice == "r":
|
||||
cfg.ranges = [r for i, r in enumerate(cfg.ranges, 1)
|
||||
if i not in picked]
|
||||
elif choice == "t":
|
||||
for i in picked:
|
||||
cfg.ranges[i - 1].enabled = not cfg.ranges[i - 1].enabled
|
||||
else:
|
||||
mode = _ask(console, " mode", "auto").strip().lower()
|
||||
for i in picked:
|
||||
cfg.ranges[i - 1].mode = mode or "auto"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _setting_row(setting: st.Setting, cfg: ScanConfig, default: ScanConfig):
|
||||
value = getattr(cfg, setting.key)
|
||||
shown = st.format_value(setting, value)
|
||||
changed = value != getattr(default, setting.key)
|
||||
return Text(shown, style="bold cyan" if changed else "white"), changed
|
||||
|
||||
|
||||
def _settings_table(console: Console, group: str, cfg: ScanConfig) -> list[st.Setting]:
|
||||
items = st.in_group(group)
|
||||
default = ScanConfig()
|
||||
t = Table(box=None, header_style="bold", pad_edge=False)
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("setting", width=22)
|
||||
t.add_column("value", width=18)
|
||||
t.add_column("what it does", style="grey62", overflow="fold")
|
||||
for i, s in enumerate(items, 1):
|
||||
value, changed = _setting_row(s, cfg, default)
|
||||
t.add_row(str(i), s.label + (" *" if changed else ""), value, s.help)
|
||||
console.print(t)
|
||||
console.print("[grey62]* differs from the built-in default[/grey62]")
|
||||
return items
|
||||
|
||||
|
||||
def setting_help(console: Console, setting: st.Setting,
|
||||
cfg: ScanConfig) -> None:
|
||||
default = ScanConfig()
|
||||
body = [f"[bold]{setting.label}[/bold] [grey62]({setting.key})[/grey62]",
|
||||
"", setting.help.capitalize() + "."]
|
||||
if setting.detail:
|
||||
body += ["", setting.detail]
|
||||
body.append("")
|
||||
body.append(f"[grey62]now:[/grey62] "
|
||||
f"{st.format_value(setting, getattr(cfg, setting.key))}"
|
||||
f" [grey62]default:[/grey62] "
|
||||
f"{st.format_value(setting, getattr(default, setting.key))}")
|
||||
rng = setting.describe_range()
|
||||
if rng:
|
||||
body.append(f"[grey62]accepts:[/grey62] {rng}")
|
||||
if setting.flags:
|
||||
flags = " ".join(setting.flags)
|
||||
if setting.off_flags:
|
||||
flags += " / " + " ".join(setting.off_flags)
|
||||
body.append(f"[grey62]command line:[/grey62] {flags}")
|
||||
console.print(Panel(Text.from_markup("\n".join(body)),
|
||||
border_style="blue", padding=(0, 1)))
|
||||
|
||||
|
||||
def edit_setting(console: Console, setting: st.Setting, cfg: ScanConfig) -> bool:
|
||||
"""Prompt for one value. Returns True if it changed."""
|
||||
current = getattr(cfg, setting.key)
|
||||
setting_help(console, setting, cfg)
|
||||
hint = "yes/no" if setting.kind == "bool" else (
|
||||
"/".join(setting.choices) if setting.choices else
|
||||
(setting.example or setting.metavar or "value"))
|
||||
while True:
|
||||
raw = _ask(console, f" [bold]{setting.label}[/bold] [grey62]({hint})"
|
||||
f"[/grey62]", st.format_value(setting, current)
|
||||
if setting.kind not in ("freq_list", "accept_list")
|
||||
else "")
|
||||
if raw.strip() == "" or raw == st.format_value(setting, current):
|
||||
return False
|
||||
if raw.strip().lower() in ("d", "default"):
|
||||
value = getattr(ScanConfig(), setting.key)
|
||||
else:
|
||||
try:
|
||||
value = st.parse_value(setting, raw)
|
||||
except st.SettingError as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
continue
|
||||
setattr(cfg, setting.key, value)
|
||||
errs = [e for e in cfg.validate() if "frequency ranges" not in e]
|
||||
if errs:
|
||||
console.print(f" [red]{errs[0]}[/red]")
|
||||
setattr(cfg, setting.key, current)
|
||||
continue
|
||||
console.print(f" [green]{setting.label} = "
|
||||
f"{st.format_value(setting, value)}[/green]")
|
||||
return True
|
||||
|
||||
|
||||
def settings_menu(console: Console, cfg: ScanConfig) -> None:
|
||||
while True:
|
||||
_rule(console, "settings")
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("group")
|
||||
t.add_column("settings", justify="right", style="grey62")
|
||||
for i, g in enumerate(st.GROUPS, 1):
|
||||
t.add_row(str(i), g, str(len(st.in_group(g))))
|
||||
console.print(t)
|
||||
console.print("[grey62]Enter a group number, a search term "
|
||||
"(e.g. [cyan]hang[/cyan]), or blank to go back."
|
||||
"[/grey62]")
|
||||
answer = _ask(console, " group or search").strip()
|
||||
if not answer:
|
||||
return
|
||||
if answer.isdigit() and 1 <= int(answer) <= len(st.GROUPS):
|
||||
_group_menu(console, cfg, st.GROUPS[int(answer) - 1])
|
||||
else:
|
||||
hits = st.search(answer)
|
||||
if not hits:
|
||||
console.print(f" [yellow]no setting matches "
|
||||
f"{answer!r}[/yellow]")
|
||||
continue
|
||||
if len(hits) == 1:
|
||||
edit_setting(console, hits[0], cfg)
|
||||
else:
|
||||
_list_menu(console, cfg, hits, f"matching {answer!r}")
|
||||
|
||||
|
||||
def _list_menu(console: Console, cfg: ScanConfig, items: list[st.Setting],
|
||||
heading: str) -> None:
|
||||
while True:
|
||||
_rule(console, heading)
|
||||
default = ScanConfig()
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("setting", width=22)
|
||||
t.add_column("value", width=18)
|
||||
t.add_column("group", style="grey62")
|
||||
for i, s in enumerate(items, 1):
|
||||
value, _ = _setting_row(s, cfg, default)
|
||||
t.add_row(str(i), s.label, value, s.group)
|
||||
console.print(t)
|
||||
answer = _ask(console, " number to edit, or blank to go back").strip()
|
||||
if not answer or not answer.isdigit():
|
||||
return
|
||||
idx = int(answer)
|
||||
if 1 <= idx <= len(items):
|
||||
edit_setting(console, items[idx - 1], cfg)
|
||||
|
||||
|
||||
def _group_menu(console: Console, cfg: ScanConfig, group: str) -> None:
|
||||
while True:
|
||||
_rule(console, group.lower())
|
||||
items = _settings_table(console, group, cfg)
|
||||
console.print("[grey62]Number to change it, [cyan]?N[/cyan] for help "
|
||||
"on one, [cyan]d[/cyan] to reset the group, blank to go "
|
||||
"back.[/grey62]")
|
||||
answer = _ask(console, " choice").strip().lower()
|
||||
if answer in _BACK:
|
||||
return
|
||||
if answer == "d":
|
||||
if _confirm(f" reset every setting in {group}"):
|
||||
default = ScanConfig()
|
||||
for s in items:
|
||||
setattr(cfg, s.key, getattr(default, s.key))
|
||||
console.print(" [green]reset[/green]")
|
||||
continue
|
||||
want_help = answer.startswith("?")
|
||||
token = answer.lstrip("?").strip()
|
||||
if not token.isdigit():
|
||||
console.print(" [yellow]enter a number from the list[/yellow]")
|
||||
continue
|
||||
idx = int(token)
|
||||
if not (1 <= idx <= len(items)):
|
||||
console.print(" [yellow]no such number[/yellow]")
|
||||
continue
|
||||
if want_help:
|
||||
setting_help(console, items[idx - 1], cfg)
|
||||
else:
|
||||
edit_setting(console, items[idx - 1], cfg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Profiles and saved settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def profiles_menu(console: Console, cfg: ScanConfig) -> ScanConfig:
|
||||
while True:
|
||||
_rule(console, "saved settings")
|
||||
console.print(f"[grey62]Settings live in {DEFAULT_CONFIG_DIR}[/grey62]")
|
||||
exists = DEFAULT_CONFIG_PATH.exists()
|
||||
console.print(" default settings file: "
|
||||
+ (f"[green]{DEFAULT_CONFIG_PATH}[/green]" if exists
|
||||
else "[yellow]not saved yet[/yellow]"))
|
||||
profiles = list_profiles()
|
||||
if profiles:
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("profile")
|
||||
t.add_column("ranges", justify="right")
|
||||
t.add_column("record", justify="right")
|
||||
t.add_column("hang", justify="right")
|
||||
for i, p in enumerate(profiles, 1):
|
||||
try:
|
||||
other = load_config(str(p))
|
||||
t.add_row(str(i), p.stem, str(len(other.ranges)),
|
||||
f"{other.record_seconds:g}s",
|
||||
f"{other.hang_seconds:g}s")
|
||||
except Exception:
|
||||
t.add_row(str(i), p.stem, "[red]unreadable[/red]", "", "")
|
||||
console.print(t)
|
||||
else:
|
||||
console.print(" [grey62]no named profiles yet[/grey62]")
|
||||
|
||||
console.print(
|
||||
"\n [cyan]s[/cyan] save as the default settings\n"
|
||||
" [cyan]n[/cyan] save as a named profile\n"
|
||||
" [cyan]l[/cyan] load a profile\n"
|
||||
" [cyan]d[/cyan] delete a profile\n"
|
||||
" [cyan]b[/cyan] back\n")
|
||||
choice = _ask(console, " choice", "b").strip().lower()
|
||||
if choice in _BACK:
|
||||
return cfg
|
||||
try:
|
||||
if choice == "s":
|
||||
path = save_default(cfg)
|
||||
console.print(f" [green]saved — every run will start from "
|
||||
f"{path}[/green]")
|
||||
elif choice == "n":
|
||||
name = _ask(console, " profile name", "myscan").strip()
|
||||
if name:
|
||||
console.print(f" [green]saved to "
|
||||
f"{save_config(cfg, name)}[/green]")
|
||||
elif choice == "l" and profiles:
|
||||
name = _ask(console, " profile name or number").strip()
|
||||
if name.isdigit() and 1 <= int(name) <= len(profiles):
|
||||
name = profiles[int(name) - 1].stem
|
||||
cfg = load_config(name)
|
||||
console.print(f" [green]loaded {name}[/green]")
|
||||
elif choice == "d" and profiles:
|
||||
name = _ask(console, " profile name or number").strip()
|
||||
if name.isdigit() and 1 <= int(name) <= len(profiles):
|
||||
name = profiles[int(name) - 1].stem
|
||||
if name and _confirm(f" delete {name}"):
|
||||
console.print(" [green]deleted[/green]"
|
||||
if delete_profile(name)
|
||||
else " [yellow]no such profile[/yellow]")
|
||||
except (OSError, FileNotFoundError, ValueError) as exc:
|
||||
console.print(f" [red]{exc}[/red]")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Help
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TOPICS: dict[str, tuple[str, str]] = {
|
||||
"1": ("Getting started", """
|
||||
Pick what to scan, then start. Ranges come from two places: type start and
|
||||
end frequencies by hand, or choose from the built-in US band plan, which
|
||||
carries each band's usual channel spacing and demodulator so you do not have
|
||||
to set them.
|
||||
|
||||
Everything on this menu can also be given on the command line, and every
|
||||
command-line option can be set here. Settings you save become the starting
|
||||
point for every later run."""),
|
||||
"2": ("How the scan works", """
|
||||
The scanner sweeps each range in steps, listening at each tuner position for
|
||||
the dwell time. Anything standing far enough above the noise counts as a
|
||||
detection.
|
||||
|
||||
It then drops onto that frequency, looks at the signal briefly to choose the
|
||||
right demodulator, and records until either the record limit is reached or the
|
||||
channel goes quiet for the hang time. Gaps shorter than the hang time are
|
||||
recorded straight through, so a two-way exchange stays in one file."""),
|
||||
"3": ("Why nothing is being recorded", """
|
||||
Most often the squelch threshold is too high, or the content check is
|
||||
rejecting what it hears.
|
||||
|
||||
Try 'Squelch threshold' lower (8 dB is sensitive, 15 dB is conservative), and
|
||||
check the scan summary: it reports how many detections were discarded and
|
||||
why. To see everything the squelch opens on, set 'Check for content' to no —
|
||||
but expect static and interference to be recorded too."""),
|
||||
"4": ("Why static is being recorded", """
|
||||
Turn 'Check for content' back on. With it on, a capture is kept only if it
|
||||
carries speech, decodable Morse, or an identified digital keying scheme;
|
||||
static, hum, bare carriers and interference are deleted.
|
||||
|
||||
If real signals are being rejected, lower 'Minimum speech score'. Speech
|
||||
detection needs roughly a second of audio, so very short overs may be
|
||||
missed."""),
|
||||
"5": ("Recording conversations", """
|
||||
Set 'Record for' to 0 so a long exchange is not cut off, and 'Wait for quiet'
|
||||
longer than the pause between overs — five or six seconds suits most two-way
|
||||
traffic. 'Absolute limit' still stops a capture running away.
|
||||
|
||||
Silence, static and interference all count as quiet, so a burst of noise
|
||||
during a pause will not park the receiver on a finished conversation."""),
|
||||
"6": ("Files and where they go", """
|
||||
Recordings are written to the output directory, all in one flat folder, named
|
||||
yyyy-mm-dd_hh.mm.ss_frequency_modulation.wav. Beside each one is a .json with
|
||||
the identification and measurements, and with 'Save raw IQ' on, the raw
|
||||
samples plus a SigMF sidecar.
|
||||
|
||||
scan_log.csv lists every hit and opens in a spreadsheet."""),
|
||||
"7": ("HF and direct sampling", """
|
||||
Below about 24 MHz the tuner cannot reach, so the signal is fed straight into
|
||||
the digitiser. 'Direct sampling' set to auto switches this on and off as
|
||||
needed; most dongles use the Q branch.
|
||||
|
||||
There is no filtering or gain in front of the digitiser in this mode, so an HF
|
||||
antenna and a quiet location matter more than usual."""),
|
||||
"8": ("Keys during a scan", """
|
||||
q stop the scan
|
||||
p pause and resume
|
||||
s skip the signal being recorded and carry on sweeping
|
||||
l lock out this frequency for the rest of the run
|
||||
+/- raise or lower the squelch threshold on the fly"""),
|
||||
}
|
||||
|
||||
|
||||
def help_screen(console: Console) -> None:
|
||||
while True:
|
||||
_rule(console, "help")
|
||||
t = Table(box=None, header_style="bold")
|
||||
t.add_column("#", style="grey62", width=3, justify="right")
|
||||
t.add_column("topic")
|
||||
for k, (title, _) in _TOPICS.items():
|
||||
t.add_row(k, title)
|
||||
console.print(t)
|
||||
console.print("[grey62]Enter a topic number, a setting name to look "
|
||||
"up, or blank to go back.[/grey62]")
|
||||
answer = _ask(console, " topic").strip()
|
||||
if not answer:
|
||||
return
|
||||
if answer in _TOPICS:
|
||||
title, body = _TOPICS[answer]
|
||||
console.print(Panel(Text(body.strip()), title=title,
|
||||
border_style="blue", padding=(0, 1)))
|
||||
else:
|
||||
hits = st.search(answer)
|
||||
if not hits:
|
||||
console.print(f" [yellow]nothing matches {answer!r}[/yellow]")
|
||||
for s in hits[:4]:
|
||||
setting_help(console, s, ScanConfig())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def first_run_setup(console: Console, cfg: ScanConfig) -> bool:
|
||||
"""Ask where recordings should go, the first time the program is run.
|
||||
|
||||
Returns True if settings were saved. Only the one question: everything
|
||||
else has a working default and can be changed from the settings menu.
|
||||
"""
|
||||
console.print(Panel(Text.from_markup(
|
||||
"[bold]Welcome to bandsaunter[/bold]\n\n"
|
||||
"Recordings, transcripts and the scan log are all written to one "
|
||||
"directory. Where would you like them?\n\n"
|
||||
"[grey62]This is saved, so you are only asked once. Everything else "
|
||||
"can be changed later from Settings, or with "
|
||||
"[cyan]bandsaunter config[/cyan].[/grey62]"),
|
||||
border_style="blue", padding=(0, 1)))
|
||||
|
||||
while True:
|
||||
answer = _ask(console, " recordings directory",
|
||||
cfg.output_dir or DEFAULT_OUTPUT_DIR).strip()
|
||||
if not answer:
|
||||
answer = DEFAULT_OUTPUT_DIR
|
||||
path = Path(answer).expanduser()
|
||||
try:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
probe = path / ".bandsaunter-write-test"
|
||||
probe.touch()
|
||||
probe.unlink()
|
||||
except OSError as exc:
|
||||
console.print(f" [red]cannot use that directory: {exc}[/red]")
|
||||
continue
|
||||
cfg.output_dir = answer
|
||||
break
|
||||
|
||||
try:
|
||||
saved = save_default(cfg)
|
||||
console.print(f" [green]recordings will go to {path}[/green]")
|
||||
console.print(f" [grey62]settings saved to {saved}[/grey62]\n")
|
||||
return True
|
||||
except OSError as exc:
|
||||
console.print(f" [yellow]could not save settings: {exc}[/yellow]")
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main menu
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _summary(cfg: ScanConfig) -> str:
|
||||
record = ("no limit" if not cfg.record_seconds
|
||||
else f"{cfg.record_seconds:g}s")
|
||||
gate = ", ".join(cfg.accept) if cfg.require_signal else "everything"
|
||||
return (f"record {record}, hang {cfg.hang_seconds:g}s, "
|
||||
f"squelch +{cfg.threshold_db:g} dB, keep {gate}")
|
||||
|
||||
|
||||
def run_tui(console: Console, cfg: ScanConfig | None = None,
|
||||
source: Path | None = None) -> ScanConfig | None:
|
||||
"""The interactive front end. Returns a config to scan with, or None."""
|
||||
cfg = cfg or ScanConfig()
|
||||
console.print(Panel(Text.from_markup(
|
||||
"[bold]bandsaunter[/bold] — RTL-SDR signal scanner\n"
|
||||
"[grey62]Scan any set of frequencies, record what turns up, and "
|
||||
"identify it. Everything is configurable here; press "
|
||||
"[cyan]h[/cyan] for help at any point.[/grey62]"),
|
||||
border_style="blue"))
|
||||
if source:
|
||||
console.print(f"[grey62]settings loaded from {source}[/grey62]")
|
||||
|
||||
try:
|
||||
return _main_loop(console, cfg)
|
||||
except TUIAbort:
|
||||
console.print()
|
||||
return None
|
||||
|
||||
|
||||
def _main_loop(console: Console, cfg: ScanConfig) -> ScanConfig | None:
|
||||
while True:
|
||||
_rule(console, "main menu")
|
||||
show_ranges(console, cfg)
|
||||
console.print(
|
||||
f"\n [cyan]1[/cyan] Frequency ranges "
|
||||
f"[grey62]{len(cfg.ranges)} configured[/grey62]\n"
|
||||
f" [cyan]2[/cyan] Band plan "
|
||||
f"[grey62]{len(PRESETS)} US presets[/grey62]\n"
|
||||
f" [cyan]3[/cyan] Settings "
|
||||
f"[grey62]{_summary(cfg)}[/grey62]\n"
|
||||
f" [cyan]4[/cyan] Saved settings and profiles\n"
|
||||
f" [cyan]h[/cyan] Help\n"
|
||||
f" [cyan]s[/cyan] [bold green]Start scanning[/bold green]\n"
|
||||
f" [cyan]q[/cyan] Quit\n")
|
||||
choice = _ask(console, " choice", "s").strip().lower()
|
||||
|
||||
if choice == "1":
|
||||
edit_ranges(console, cfg)
|
||||
elif choice == "2":
|
||||
choose_presets(console, cfg)
|
||||
elif choice == "3":
|
||||
settings_menu(console, cfg)
|
||||
elif choice == "4":
|
||||
cfg = profiles_menu(console, cfg)
|
||||
elif choice in ("h", "?", "help"):
|
||||
help_screen(console)
|
||||
elif choice in ("s", "start", "go"):
|
||||
errs = cfg.validate()
|
||||
if errs:
|
||||
for e in errs:
|
||||
console.print(f" [red]{e}[/red]")
|
||||
continue
|
||||
return cfg
|
||||
elif choice in ("q", "quit", "exit"):
|
||||
return None
|
||||
Loading…
Add table
Add a link
Reference in a new issue