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>
147 lines
5.6 KiB
Python
147 lines
5.6 KiB
Python
"""The live display, and keeping the driver from writing over it."""
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from bandsaunter.config import ScanConfig
|
|
from bandsaunter.ranges import parse_range_list
|
|
from bandsaunter.recorder import HitRecord
|
|
from bandsaunter.scanner import Scanner
|
|
from bandsaunter.simulator import SimulatedDevice
|
|
from bandsaunter.ui import ScanDisplay, _sparkline
|
|
|
|
|
|
def _display(height: int, hits: int, recording: bool) -> ScanDisplay:
|
|
console = Console(width=100, height=height,
|
|
file=open(os.devnull, "w"))
|
|
cfg = ScanConfig(ranges=parse_range_list("144M-148M"),
|
|
output_dir=tempfile.mkdtemp())
|
|
scanner = Scanner(cfg, device=SimulatedDevice().open())
|
|
scanner.prepare()
|
|
display = ScanDisplay(scanner, console=console)
|
|
display.attach()
|
|
step = scanner.plan[0]
|
|
display.on_step(0, 6, step, np.full(1024, -70.0),
|
|
np.linspace(step.low, step.high, 1024))
|
|
for i in range(hits):
|
|
display.hits.append(HitRecord(
|
|
frequency=146e6 + i * 1e5, started_at=time.time(), duration=5.0,
|
|
snr_db=20.0, classification="FM broadcast station (stereo)"))
|
|
display._rec.active = recording
|
|
display._rec.frequency = 146.52e6
|
|
display._rec.mode = "nfm"
|
|
return display
|
|
|
|
|
|
def _rendered_height(display: ScanDisplay) -> int:
|
|
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
|
|
record=True)
|
|
probe.print(display.render())
|
|
return len(probe.export_text().rstrip("\n").split("\n"))
|
|
|
|
|
|
@pytest.mark.parametrize("height", [16, 20, 24, 30, 40, 60])
|
|
@pytest.mark.parametrize("hits,recording", [(0, False), (3, False),
|
|
(12, False), (12, True)])
|
|
def test_the_display_never_outgrows_the_terminal(height, hits, recording):
|
|
"""A frame taller than the terminal cannot be redrawn in place.
|
|
|
|
Every refresh then scrolls another copy into the scrollback, which is why
|
|
the header ends up on screen several times over.
|
|
"""
|
|
display = _display(height, hits, recording)
|
|
assert _rendered_height(display) <= height, \
|
|
f"{hits} hits, rec={recording}: overflowed a {height}-line terminal"
|
|
|
|
|
|
def test_older_hits_are_dropped_not_the_layout():
|
|
"""When space runs short the list shortens; the panels stay."""
|
|
display = _display(20, 12, True)
|
|
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
|
|
record=True)
|
|
probe.print(display.render())
|
|
text = probe.export_text()
|
|
assert "receiver" in text and "sweep" in text
|
|
assert "more above" in text, "no sign that hits were trimmed"
|
|
|
|
|
|
def test_a_tall_terminal_shows_every_hit():
|
|
display = _display(60, 12, False)
|
|
probe = Console(width=100, height=200, file=open(os.devnull, "w"),
|
|
record=True)
|
|
probe.print(display.render())
|
|
assert "more above" not in probe.export_text()
|
|
|
|
|
|
def test_sparkline_keeps_narrow_carriers_visible():
|
|
values = np.full(200, -70.0)
|
|
values[100] = -20.0
|
|
assert "█" in _sparkline(values, 60)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The driver writes its own messages straight to file descriptor 2
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_quiet_driver_suppresses_writes_to_fd_2():
|
|
"""librtlsdr prints from C, so Python-level redirection cannot catch it."""
|
|
code = (
|
|
"import os, sys; sys.path.insert(0, %r)\n"
|
|
"from bandsaunter.device import quiet_driver\n"
|
|
"with quiet_driver():\n"
|
|
" os.write(2, b'CHATTER\\n')\n"
|
|
"os.write(2, b'AFTERWARDS\\n')\n"
|
|
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
|
text=True, timeout=30)
|
|
assert "CHATTER" not in out.stderr
|
|
assert "AFTERWARDS" in out.stderr, "stderr was not restored"
|
|
|
|
|
|
def test_quiet_driver_restores_on_an_exception():
|
|
code = (
|
|
"import os, sys; sys.path.insert(0, %r)\n"
|
|
"from bandsaunter.device import quiet_driver\n"
|
|
"try:\n"
|
|
" with quiet_driver():\n"
|
|
" raise ValueError('boom')\n"
|
|
"except ValueError:\n"
|
|
" pass\n"
|
|
"os.write(2, b'RESTORED\\n')\n"
|
|
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
|
text=True, timeout=30)
|
|
assert "RESTORED" in out.stderr
|
|
|
|
|
|
def test_driver_messages_can_be_turned_back_on_for_debugging():
|
|
code = (
|
|
"import os, sys; sys.path.insert(0, %r)\n"
|
|
"from bandsaunter.device import quiet_driver\n"
|
|
"with quiet_driver():\n"
|
|
" os.write(2, b'CHATTER\\n')\n"
|
|
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
env = dict(os.environ, BANDSAUNTER_DRIVER_MESSAGES="1")
|
|
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
|
text=True, timeout=30, env=env)
|
|
assert "CHATTER" in out.stderr
|
|
|
|
|
|
def test_python_errors_still_reach_stderr():
|
|
"""Silencing the driver must not swallow a traceback."""
|
|
code = (
|
|
"import sys; sys.path.insert(0, %r)\n"
|
|
"from bandsaunter.device import quiet_driver\n"
|
|
"with quiet_driver():\n"
|
|
" pass\n"
|
|
"raise RuntimeError('visible')\n"
|
|
) % os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
out = subprocess.run([sys.executable, "-c", code], capture_output=True,
|
|
text=True, timeout=30)
|
|
assert "visible" in out.stderr
|