Files
intercept/tests/test_subghz.py
T
James Smith 96172ca593 style: apply ruff-format to entire codebase
First-time run of ruff-format via pre-commit hook normalises quote
style, trailing commas, and whitespace across 188 Python files.
No logic changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 14:48:11 +01:00

668 lines
25 KiB
Python

"""Tests for SubGhzManager utility module."""
from __future__ import annotations
import json
import os
from unittest.mock import MagicMock, patch
import pytest
from utils.subghz import SubGhzCapture, SubGhzManager
@pytest.fixture
def tmp_data_dir(tmp_path):
"""Create a temporary data directory for SubGhz captures."""
data_dir = tmp_path / "subghz"
data_dir.mkdir()
(data_dir / "captures").mkdir()
return data_dir
@pytest.fixture
def manager(tmp_data_dir):
"""Create a SubGhzManager with temp directory."""
return SubGhzManager(data_dir=tmp_data_dir)
class TestSubGhzManagerInit:
def test_creates_data_dirs(self, tmp_path):
data_dir = tmp_path / "new_subghz"
SubGhzManager(data_dir=data_dir)
assert (data_dir / "captures").is_dir()
def test_active_mode_idle(self, manager):
assert manager.active_mode == "idle"
def test_get_status_idle(self, manager):
status = manager.get_status()
assert status["mode"] == "idle"
class TestToolDetection:
def test_check_hackrf_found(self, manager):
with patch("shutil.which", return_value="/usr/bin/hackrf_transfer"):
assert manager.check_hackrf() is True
def test_check_hackrf_not_found(self, manager):
with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None):
manager._hackrf_available = None # reset cache
assert manager.check_hackrf() is False
def test_check_rtl433_found(self, manager):
with patch("shutil.which", return_value="/usr/bin/rtl_433"):
assert manager.check_rtl433() is True
def test_check_sweep_found(self, manager):
with patch("shutil.which", return_value="/usr/bin/hackrf_sweep"):
assert manager.check_sweep() is True
class TestReceive:
def test_start_receive_no_hackrf(self, manager):
with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None):
manager._hackrf_available = None
result = manager.start_receive(frequency_hz=433920000)
assert result["status"] == "error"
assert "not found" in result["message"]
def test_start_receive_success(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stderr = MagicMock()
mock_proc.stderr.readline = MagicMock(return_value=b"")
with (
patch("shutil.which", return_value="/usr/bin/hackrf_transfer"),
patch("subprocess.Popen", return_value=mock_proc),
patch.object(manager, "check_hackrf_device", return_value=True),
patch("utils.subghz.register_process"),
):
manager._hackrf_available = None
result = manager.start_receive(
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
)
assert result["status"] == "started"
assert result["frequency_hz"] == 433920000
assert manager.active_mode == "rx"
def test_start_receive_already_running(self, manager):
import time as _time
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
# Pre-lock device checks now run before active_mode guard
manager._hackrf_available = True
manager._hackrf_device_cache = True
manager._hackrf_device_cache_ts = _time.time()
result = manager.start_receive(frequency_hz=433920000)
assert result["status"] == "error"
assert "Already running" in result["message"]
def test_stop_receive_not_running(self, manager):
result = manager.stop_receive()
assert result["status"] == "not_running"
def test_stop_receive_creates_metadata(self, manager, tmp_data_dir):
# Create a fake IQ file
iq_file = tmp_data_dir / "captures" / "test.iq"
iq_file.write_bytes(b"\x00" * 1024)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
manager._rx_file = iq_file
manager._rx_frequency_hz = 433920000
manager._rx_sample_rate = 2000000
manager._rx_lna_gain = 32
manager._rx_vga_gain = 20
manager._rx_start_time = 1000.0
manager._rx_bursts = [{"start_seconds": 1.23, "duration_seconds": 0.15, "peak_level": 42}]
with patch("utils.subghz.safe_terminate"), patch("time.time", return_value=1005.0):
result = manager.stop_receive()
assert result["status"] == "stopped"
assert "capture" in result
assert result["capture"]["frequency_hz"] == 433920000
# Verify JSON sidecar was written
meta_path = iq_file.with_suffix(".json")
assert meta_path.exists()
meta = json.loads(meta_path.read_text())
assert meta["frequency_hz"] == 433920000
assert isinstance(meta.get("bursts"), list)
assert meta["bursts"][0]["peak_level"] == 42
class TestTxSafety:
def test_validate_tx_frequency_ism_433(self):
result = SubGhzManager.validate_tx_frequency(433920000)
assert result is None # Valid
def test_validate_tx_frequency_ism_315(self):
result = SubGhzManager.validate_tx_frequency(315000000)
assert result is None
def test_validate_tx_frequency_ism_915(self):
result = SubGhzManager.validate_tx_frequency(915000000)
assert result is None
def test_validate_tx_frequency_out_of_band(self):
result = SubGhzManager.validate_tx_frequency(100000000) # 100 MHz
assert result is not None
assert "outside allowed TX bands" in result
def test_validate_tx_frequency_between_bands(self):
result = SubGhzManager.validate_tx_frequency(500000000) # 500 MHz
assert result is not None
def test_transmit_no_hackrf(self, manager):
with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None):
manager._hackrf_available = None
result = manager.transmit(capture_id="abc123")
assert result["status"] == "error"
def test_transmit_capture_not_found(self, manager):
with (
patch("shutil.which", return_value="/usr/bin/hackrf_transfer"),
patch.object(manager, "check_hackrf_device", return_value=True),
):
manager._hackrf_available = None
result = manager.transmit(capture_id="nonexistent")
assert result["status"] == "error"
assert "not found" in result["message"]
def test_transmit_out_of_band_rejected(self, manager, tmp_data_dir):
# Create a capture with out-of-band frequency
meta = {
"id": "test123",
"filename": "test.iq",
"frequency_hz": 100000000, # 100 MHz - out of ISM
"sample_rate": 2000000,
"lna_gain": 32,
"vga_gain": 20,
"timestamp": "2026-01-01T00:00:00Z",
}
meta_path = tmp_data_dir / "captures" / "test.json"
meta_path.write_text(json.dumps(meta))
(tmp_data_dir / "captures" / "test.iq").write_bytes(b"\x00" * 100)
with (
patch("shutil.which", return_value="/usr/bin/hackrf_transfer"),
patch.object(manager, "check_hackrf_device", return_value=True),
):
manager._hackrf_available = None
result = manager.transmit(capture_id="test123")
assert result["status"] == "error"
assert "outside allowed TX bands" in result["message"]
def test_transmit_already_running(self, manager, tmp_data_dir):
import time as _time
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
# Pre-lock device checks now run before active_mode guard
manager._hackrf_available = True
manager._hackrf_device_cache = True
manager._hackrf_device_cache_ts = _time.time()
# Capture lookup also runs pre-lock now; provide a valid capture + IQ file
meta = {
"id": "test123",
"filename": "test.iq",
"frequency_hz": 433920000,
"sample_rate": 2000000,
"timestamp": "2025-01-01T00:00:00",
}
(tmp_data_dir / "captures" / "test.json").write_text(json.dumps(meta))
(tmp_data_dir / "captures" / "test.iq").write_bytes(b"\x00" * 64)
result = manager.transmit(capture_id="test123")
assert result["status"] == "error"
assert "Already running" in result["message"]
def test_transmit_segment_extracts_range(self, manager, tmp_data_dir):
meta = {
"id": "seg001",
"filename": "seg.iq",
"frequency_hz": 433920000,
"sample_rate": 1000,
"lna_gain": 24,
"vga_gain": 20,
"timestamp": "2026-01-01T00:00:00Z",
"duration_seconds": 1.0,
"size_bytes": 2000,
}
(tmp_data_dir / "captures" / "seg.json").write_text(json.dumps(meta))
(tmp_data_dir / "captures" / "seg.iq").write_bytes(bytes(range(200)) * 10)
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_timer = MagicMock()
mock_timer.start = MagicMock()
with (
patch("shutil.which", return_value="/usr/bin/hackrf_transfer"),
patch.object(manager, "check_hackrf_device", return_value=True),
patch("subprocess.Popen", return_value=mock_proc),
patch("utils.subghz.register_process"),
patch("threading.Timer", return_value=mock_timer),
patch("threading.Thread") as mock_thread_cls,
):
mock_thread = MagicMock()
mock_thread.start = MagicMock()
mock_thread_cls.return_value = mock_thread
manager._hackrf_available = None
result = manager.transmit(
capture_id="seg001",
start_seconds=0.2,
duration_seconds=0.3,
)
assert result["status"] == "transmitting"
assert result["segment"] is not None
assert result["segment"]["duration_seconds"] == pytest.approx(0.3, abs=0.01)
assert manager._tx_temp_file is not None
assert manager._tx_temp_file.exists()
class TestCaptureLibrary:
def test_list_captures_empty(self, manager):
captures = manager.list_captures()
assert captures == []
def test_list_captures_with_data(self, manager, tmp_data_dir):
meta = {
"id": "cap001",
"filename": "test.iq",
"frequency_hz": 433920000,
"sample_rate": 2000000,
"lna_gain": 32,
"vga_gain": 20,
"timestamp": "2026-01-01T00:00:00Z",
"duration_seconds": 5.0,
"size_bytes": 1024,
"label": "test capture",
}
(tmp_data_dir / "captures" / "test.json").write_text(json.dumps(meta))
captures = manager.list_captures()
assert len(captures) == 1
assert captures[0].capture_id == "cap001"
assert captures[0].label == "test capture"
def test_get_capture(self, manager, tmp_data_dir):
meta = {
"id": "cap002",
"filename": "test2.iq",
"frequency_hz": 315000000,
"sample_rate": 2000000,
"timestamp": "2026-01-01T00:00:00Z",
}
(tmp_data_dir / "captures" / "test2.json").write_text(json.dumps(meta))
cap = manager.get_capture("cap002")
assert cap is not None
assert cap.frequency_hz == 315000000
def test_get_capture_not_found(self, manager):
cap = manager.get_capture("nonexistent")
assert cap is None
def test_delete_capture(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / "captures"
iq_path = captures_dir / "delete_me.iq"
meta_path = captures_dir / "delete_me.json"
iq_path.write_bytes(b"\x00" * 100)
meta_path.write_text(
json.dumps(
{
"id": "del001",
"filename": "delete_me.iq",
"frequency_hz": 433920000,
"sample_rate": 2000000,
"timestamp": "2026-01-01T00:00:00Z",
}
)
)
assert manager.delete_capture("del001") is True
assert not iq_path.exists()
assert not meta_path.exists()
def test_delete_capture_not_found(self, manager):
assert manager.delete_capture("nonexistent") is False
def test_update_label(self, manager, tmp_data_dir):
meta = {
"id": "lbl001",
"filename": "label_test.iq",
"frequency_hz": 433920000,
"sample_rate": 2000000,
"timestamp": "2026-01-01T00:00:00Z",
"label": "",
}
meta_path = tmp_data_dir / "captures" / "label_test.json"
meta_path.write_text(json.dumps(meta))
assert manager.update_capture_label("lbl001", "Garage Remote") is True
updated = json.loads(meta_path.read_text())
assert updated["label"] == "Garage Remote"
assert updated["label_source"] == "manual"
def test_update_label_not_found(self, manager):
assert manager.update_capture_label("nonexistent", "test") is False
def test_get_capture_path(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / "captures"
iq_path = captures_dir / "path_test.iq"
iq_path.write_bytes(b"\x00" * 100)
(captures_dir / "path_test.json").write_text(
json.dumps(
{
"id": "pth001",
"filename": "path_test.iq",
"frequency_hz": 433920000,
"sample_rate": 2000000,
"timestamp": "2026-01-01T00:00:00Z",
}
)
)
path = manager.get_capture_path("pth001")
assert path is not None
assert path.name == "path_test.iq"
def test_get_capture_path_not_found(self, manager):
assert manager.get_capture_path("nonexistent") is None
def test_trim_capture_manual_segment(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / "captures"
iq_path = captures_dir / "trim_src.iq"
iq_path.write_bytes(bytes(range(200)) * 20) # 4000 bytes at 1000 sps => 2.0s
(captures_dir / "trim_src.json").write_text(
json.dumps(
{
"id": "trim001",
"filename": "trim_src.iq",
"frequency_hz": 433920000,
"sample_rate": 1000,
"lna_gain": 24,
"vga_gain": 20,
"timestamp": "2026-01-01T00:00:00Z",
"duration_seconds": 2.0,
"size_bytes": 4000,
"label": "Weather Burst",
"bursts": [
{
"start_seconds": 0.55,
"duration_seconds": 0.2,
"peak_level": 67,
"fingerprint": "abc123",
"modulation_hint": "OOK/ASK",
"modulation_confidence": 0.9,
}
],
}
)
)
result = manager.trim_capture(
capture_id="trim001",
start_seconds=0.5,
duration_seconds=0.4,
)
assert result["status"] == "ok"
assert result["capture"]["id"] != "trim001"
assert result["capture"]["size_bytes"] == 800
assert result["capture"]["label"].endswith("(Trim)")
trimmed_iq = captures_dir / result["capture"]["filename"]
assert trimmed_iq.exists()
trimmed_meta = trimmed_iq.with_suffix(".json")
assert trimmed_meta.exists()
def test_trim_capture_auto_burst(self, manager, tmp_data_dir):
captures_dir = tmp_data_dir / "captures"
iq_path = captures_dir / "auto_src.iq"
iq_path.write_bytes(bytes(range(100)) * 40) # 4000 bytes
(captures_dir / "auto_src.json").write_text(
json.dumps(
{
"id": "trim002",
"filename": "auto_src.iq",
"frequency_hz": 433920000,
"sample_rate": 1000,
"lna_gain": 24,
"vga_gain": 20,
"timestamp": "2026-01-01T00:00:00Z",
"duration_seconds": 2.0,
"size_bytes": 4000,
"bursts": [
{"start_seconds": 0.2, "duration_seconds": 0.1, "peak_level": 12},
{"start_seconds": 1.2, "duration_seconds": 0.25, "peak_level": 88},
],
}
)
)
result = manager.trim_capture(capture_id="trim002")
assert result["status"] == "ok"
assert result["segment"]["auto_selected"] is True
assert result["capture"]["duration_seconds"] > 0.25
def test_list_captures_groups_same_fingerprint(self, manager, tmp_data_dir):
cap_a = {
"id": "grp001",
"filename": "a.iq",
"frequency_hz": 433920000,
"sample_rate": 2000000,
"timestamp": "2026-01-01T00:00:00Z",
"dominant_fingerprint": "deadbeefcafebabe",
}
cap_b = {
"id": "grp002",
"filename": "b.iq",
"frequency_hz": 433920000,
"sample_rate": 2000000,
"timestamp": "2026-01-01T00:01:00Z",
"dominant_fingerprint": "deadbeefcafebabe",
}
(tmp_data_dir / "captures" / "a.json").write_text(json.dumps(cap_a))
(tmp_data_dir / "captures" / "b.json").write_text(json.dumps(cap_b))
captures = manager.list_captures()
assert len(captures) == 2
assert all(c.fingerprint_group.startswith("SIG-") for c in captures)
assert all(c.fingerprint_group_size == 2 for c in captures)
class TestSweep:
def test_start_sweep_no_tool(self, manager):
with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None):
manager._sweep_available = None
result = manager.start_sweep()
assert result["status"] == "error"
def test_start_sweep_success(self, manager):
import time as _time
mock_proc = MagicMock()
mock_proc.poll.return_value = None
mock_proc.stdout = MagicMock()
with (
patch("shutil.which", return_value="/usr/bin/hackrf_sweep"),
patch("subprocess.Popen", return_value=mock_proc),
patch("utils.subghz.register_process"),
):
manager._sweep_available = None
manager._hackrf_device_cache = True
manager._hackrf_device_cache_ts = _time.time()
result = manager.start_sweep(freq_start_mhz=300, freq_end_mhz=928)
assert result["status"] == "started"
# Signal daemon threads to stop so they don't outlive the test
manager._sweep_running = False
def test_stop_sweep_not_running(self, manager):
result = manager.stop_sweep()
assert result["status"] == "not_running"
class TestDecode:
def test_start_decode_no_hackrf(self, manager):
with patch("shutil.which", return_value=None), patch("utils.subghz.get_tool_path", return_value=None):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result["status"] == "error"
assert "hackrf_transfer" in result["message"]
def test_start_decode_no_rtl433(self, manager):
def which_side_effect(name):
if name == "hackrf_transfer":
return "/usr/bin/hackrf_transfer"
return None
with (
patch("shutil.which", side_effect=which_side_effect),
patch("utils.subghz.get_tool_path", return_value=None),
):
manager._hackrf_available = None
manager._rtl433_available = None
result = manager.start_decode(frequency_hz=433920000)
assert result["status"] == "error"
assert "rtl_433" in result["message"]
def test_start_decode_success(self, manager):
mock_hackrf_proc = MagicMock()
mock_hackrf_proc.poll.return_value = None
mock_hackrf_proc.stdout = MagicMock()
mock_hackrf_proc.stderr = MagicMock()
mock_hackrf_proc.stderr.readline = MagicMock(return_value=b"")
mock_rtl433_proc = MagicMock()
mock_rtl433_proc.poll.return_value = None
mock_rtl433_proc.stdout = MagicMock()
mock_rtl433_proc.stderr = MagicMock()
mock_rtl433_proc.stderr.readline = MagicMock(return_value=b"")
call_count = [0]
def popen_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
return mock_hackrf_proc
return mock_rtl433_proc
def which_side_effect(name):
return f"/usr/bin/{name}"
with (
patch("shutil.which", side_effect=which_side_effect),
patch("subprocess.Popen", side_effect=popen_side_effect) as mock_popen,
patch("utils.subghz.register_process"),
):
import time as _time
manager._hackrf_available = None
manager._rtl433_available = None
manager._hackrf_device_cache = True
manager._hackrf_device_cache_ts = _time.time()
result = manager.start_decode(
frequency_hz=433920000,
sample_rate=2000000,
)
assert result["status"] == "started"
assert result["frequency_hz"] == 433920000
assert manager.active_mode == "decode"
# Two processes: hackrf_transfer + rtl_433
assert mock_popen.call_count == 2
# Verify hackrf_transfer command
hackrf_cmd = mock_popen.call_args_list[0][0][0]
assert os.path.basename(hackrf_cmd[0]) == "hackrf_transfer"
assert "-r" in hackrf_cmd
# Verify rtl_433 command
rtl433_cmd = mock_popen.call_args_list[1][0][0]
assert os.path.basename(rtl433_cmd[0]) == "rtl_433"
assert "-r" in rtl433_cmd
assert "cs8:-" in rtl433_cmd
# Both processes tracked
assert manager._decode_hackrf_process is mock_hackrf_proc
assert manager._decode_process is mock_rtl433_proc
# Signal daemon threads to stop so they don't outlive the test
manager._decode_stop = True
def test_stop_decode_not_running(self, manager):
result = manager.stop_decode()
assert result["status"] == "not_running"
def test_stop_decode_terminates_both(self, manager):
mock_hackrf = MagicMock()
mock_hackrf.poll.return_value = None
mock_rtl433 = MagicMock()
mock_rtl433.poll.return_value = None
manager._decode_hackrf_process = mock_hackrf
manager._decode_process = mock_rtl433
manager._decode_frequency_hz = 433920000
with patch("utils.subghz.safe_terminate") as mock_term, patch("utils.subghz.unregister_process"):
result = manager.stop_decode()
assert result["status"] == "stopped"
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert mock_term.call_count == 2
class TestStopAll:
def test_stop_all_clears_processes(self, manager):
mock_proc = MagicMock()
mock_proc.poll.return_value = None
manager._rx_process = mock_proc
with patch("utils.subghz.safe_terminate"):
manager.stop_all()
assert manager._rx_process is None
assert manager._decode_hackrf_process is None
assert manager._decode_process is None
assert manager._tx_process is None
assert manager._sweep_process is None
class TestSubGhzCapture:
def test_to_dict(self):
cap = SubGhzCapture(
capture_id="abc123",
filename="test.iq",
frequency_hz=433920000,
sample_rate=2000000,
lna_gain=32,
vga_gain=20,
timestamp="2026-01-01T00:00:00Z",
duration_seconds=5.0,
size_bytes=1024,
label="Test",
)
d = cap.to_dict()
assert d["id"] == "abc123"
assert d["frequency_hz"] == 433920000
assert d["label"] == "Test"