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
206
tests/test_tui.py
Normal file
206
tests/test_tui.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""The in-application menus, driven by scripted answers."""
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from bandsaunter import settings as st, tui
|
||||
from bandsaunter.config import ScanConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def console():
|
||||
return Console(width=100, file=open("/dev/null", "w"), force_terminal=False)
|
||||
|
||||
|
||||
def drive(monkeypatch, answers):
|
||||
"""Feed the menus a fixed list of answers, then stop."""
|
||||
script = list(answers)
|
||||
|
||||
def fake_ask(console, prompt, default=""):
|
||||
if not script:
|
||||
raise _Done()
|
||||
return script.pop(0)
|
||||
|
||||
monkeypatch.setattr(tui, "_ask", fake_ask)
|
||||
monkeypatch.setattr(tui.Confirm, "ask", lambda *a, **k: True)
|
||||
return script
|
||||
|
||||
|
||||
class _Done(Exception):
|
||||
"""Raised to break out when the script runs out."""
|
||||
|
||||
|
||||
def run(monkeypatch, console, func, answers, *args):
|
||||
drive(monkeypatch, answers)
|
||||
try:
|
||||
return func(console, *args)
|
||||
except _Done:
|
||||
return None
|
||||
|
||||
|
||||
def test_settings_menu_edits_a_value(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.settings_menu, ["1", "2", "6", "", ""], cfg)
|
||||
assert cfg.hang_seconds == 6.0
|
||||
|
||||
|
||||
def test_settings_menu_finds_a_setting_by_search(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.settings_menu, ["voice score", "0.3", ""], cfg)
|
||||
assert cfg.min_voice_score == 0.3
|
||||
|
||||
|
||||
def test_a_bad_value_is_refused_and_the_old_one_kept(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
before = cfg.hang_seconds
|
||||
run(monkeypatch, console, tui.settings_menu,
|
||||
["1", "2", "banana", "", "", ""], cfg)
|
||||
assert cfg.hang_seconds == before
|
||||
|
||||
|
||||
def test_a_value_failing_validation_is_rolled_back(monkeypatch, console):
|
||||
"""min_record longer than record would mean nothing is ever kept."""
|
||||
cfg = ScanConfig()
|
||||
cfg.record_seconds = 5.0
|
||||
run(monkeypatch, console, tui.settings_menu,
|
||||
["1", "4", "9", "", "", ""], cfg)
|
||||
assert cfg.min_record_seconds != 9.0
|
||||
assert not [e for e in cfg.validate() if "ranges" not in e]
|
||||
|
||||
|
||||
def test_every_group_can_be_opened_and_left(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
for i in range(1, len(st.GROUPS) + 1):
|
||||
run(monkeypatch, console, tui.settings_menu, [str(i), "", ""], cfg)
|
||||
|
||||
|
||||
def test_every_setting_renders_its_help(console):
|
||||
"""Built-in help must exist and render for every single setting."""
|
||||
cfg = ScanConfig()
|
||||
for s in st.SETTINGS:
|
||||
tui.setting_help(console, s, cfg)
|
||||
|
||||
|
||||
def test_reset_a_group_restores_defaults(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
cfg.hang_seconds = 99.0
|
||||
cfg.record_seconds = 99.0
|
||||
run(monkeypatch, console, tui.settings_menu, ["1", "d", "", ""], cfg)
|
||||
assert cfg.hang_seconds == ScanConfig().hang_seconds
|
||||
assert cfg.record_seconds == ScanConfig().record_seconds
|
||||
|
||||
|
||||
def test_help_screen_shows_every_topic(monkeypatch, console):
|
||||
for topic in tui._TOPICS:
|
||||
run(monkeypatch, console, tui.help_screen, [topic, ""])
|
||||
|
||||
|
||||
def test_help_screen_looks_up_settings(monkeypatch, console):
|
||||
run(monkeypatch, console, tui.help_screen, ["hang", ""])
|
||||
|
||||
|
||||
def test_ranges_can_be_added_from_the_band_plan(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.choose_presets, ["gmrs", "1", ""], cfg)
|
||||
assert cfg.ranges, "no range was added"
|
||||
|
||||
|
||||
def test_ranges_can_be_typed_in(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.add_manual_ranges,
|
||||
["144M", "148M", "nfm", ""], cfg)
|
||||
assert len(cfg.ranges) == 1
|
||||
assert cfg.ranges[0].start == 144e6 and cfg.ranges[0].stop == 148e6
|
||||
assert cfg.ranges[0].mode == "nfm"
|
||||
|
||||
|
||||
def test_a_bad_frequency_is_refused(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
run(monkeypatch, console, tui.add_manual_ranges, ["banana", ""], cfg)
|
||||
assert not cfg.ranges
|
||||
|
||||
|
||||
def test_main_menu_can_start_a_scan(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
from bandsaunter.ranges import parse_range_list
|
||||
cfg.ranges = parse_range_list("144M-148M")
|
||||
out = run(monkeypatch, console, tui.run_tui, ["s"], cfg)
|
||||
assert out is cfg
|
||||
|
||||
|
||||
def test_main_menu_refuses_to_start_without_ranges(monkeypatch, console):
|
||||
cfg = ScanConfig()
|
||||
out = run(monkeypatch, console, tui.run_tui, ["s", "q"], cfg)
|
||||
assert out is None, "started a scan with nothing to scan"
|
||||
|
||||
|
||||
def test_main_menu_quits(monkeypatch, console):
|
||||
assert run(monkeypatch, console, tui.run_tui, ["q"], ScanConfig()) is None
|
||||
|
||||
|
||||
def test_closed_input_unwinds_instead_of_looping(monkeypatch, console):
|
||||
"""With no input left, the menus must give up rather than spin forever."""
|
||||
def eof(*a, **k):
|
||||
raise EOFError
|
||||
monkeypatch.setattr(tui.Prompt, "ask", eof)
|
||||
assert tui.run_tui(console, ScanConfig()) is None
|
||||
with pytest.raises(tui.TUIAbort):
|
||||
tui.settings_menu(console, ScanConfig())
|
||||
|
||||
|
||||
def test_interrupt_unwinds_too(monkeypatch, console):
|
||||
def interrupt(*a, **k):
|
||||
raise KeyboardInterrupt
|
||||
monkeypatch.setattr(tui.Prompt, "ask", interrupt)
|
||||
assert tui.run_tui(console, ScanConfig()) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First run
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_first_run_is_detected_and_then_not(tmp_path):
|
||||
from bandsaunter.config import ScanConfig, is_first_run, save_default
|
||||
assert is_first_run(tmp_path)
|
||||
save_default(ScanConfig(), tmp_path)
|
||||
assert not is_first_run(tmp_path)
|
||||
|
||||
|
||||
def test_first_run_asks_where_to_save_and_remembers(monkeypatch, console,
|
||||
tmp_path):
|
||||
from bandsaunter import tui
|
||||
from bandsaunter.config import load_default
|
||||
wanted = tmp_path / "my recordings"
|
||||
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": str(wanted))
|
||||
monkeypatch.setattr(tui, "save_default",
|
||||
lambda cfg: __import__("bandsaunter.config",
|
||||
fromlist=["x"]).save_default(
|
||||
cfg, tmp_path))
|
||||
cfg = ScanConfig()
|
||||
assert tui.first_run_setup(console, cfg)
|
||||
assert cfg.output_dir == str(wanted)
|
||||
assert wanted.is_dir(), "the directory should be created"
|
||||
back, _ = load_default(tmp_path)
|
||||
assert back.output_dir == str(wanted)
|
||||
|
||||
|
||||
def test_first_run_accepts_the_suggested_default(monkeypatch, console,
|
||||
tmp_path):
|
||||
from bandsaunter import tui
|
||||
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": "")
|
||||
monkeypatch.setattr(tui, "save_default", lambda cfg: tmp_path / "x.yaml")
|
||||
monkeypatch.setattr(tui, "DEFAULT_OUTPUT_DIR", str(tmp_path / "default"))
|
||||
cfg = ScanConfig()
|
||||
tui.first_run_setup(console, cfg)
|
||||
assert cfg.output_dir == str(tmp_path / "default")
|
||||
|
||||
|
||||
def test_first_run_rejects_a_directory_it_cannot_write(monkeypatch, console,
|
||||
tmp_path):
|
||||
"""Better to say so now than to fail on the first recording."""
|
||||
from bandsaunter import tui
|
||||
answers = iter(["/proc/nonsense/cannot-create", str(tmp_path / "ok")])
|
||||
monkeypatch.setattr(tui, "_ask", lambda c, p, d="": next(answers))
|
||||
monkeypatch.setattr(tui, "save_default", lambda cfg: tmp_path / "x.yaml")
|
||||
cfg = ScanConfig()
|
||||
tui.first_run_setup(console, cfg)
|
||||
assert cfg.output_dir == str(tmp_path / "ok")
|
||||
Loading…
Add table
Add a link
Reference in a new issue