"""Software-only tests for the fixed RF-001A RX capture operations.""" from __future__ import annotations import hashlib import json from pathlib import Path from typing import Sequence from tools.rf001_rx_capture import ( CAPTURE_FILE_NAME, FIXED_RX_SERIAL, MANIFEST_FILE_NAME, REPORT_FILE_NAME, RESULT_FILE_NAME, read_capture_diagnostics, read_capture_status, start_rx_capture, ) class FakeResult: def __init__(self, arguments: Sequence[str], stdout: str) -> None: self.arguments = list(arguments) self.returncode = 0 self.stdout = stdout self.stderr = "" def _write_repository(repository: Path) -> None: (repository / "configs" / "rf").mkdir(parents=True) (repository / "tools").mkdir() profile = { "test_id": "RF001A_COMMON_CLOCK_001", "center_frequency_hz": 1_400_000_000, "sample_rate_hz": 8_000_000, "symbol_rate_sps": 500_000, "modulation": "QPSK", "inner_fec": "2/3", "application_payload_bytes": 256, "packet_count": 100, "inter_packet_gap_ms": 5, "clock_mode": "COMMON_10_MHZ", "rx_lna_db": 24, "rx_vga_db": 24, "rf_amp_enabled": False, "antenna_bias_enabled": False, "hardware_format": "cs8", "rx_capture_seconds": 20, "rx_capture_samples": 160_000_000, } (repository / "configs" / "rf" / "rf001a_common_clock.json").write_text( json.dumps(profile), encoding="utf-8", ) def test_capture_start_uses_fixed_receive_only_command(tmp_path: Path) -> None: repository = tmp_path / "repo" repository.mkdir() _write_repository(repository) tool_dir = tmp_path / "hackrf" tool_dir.mkdir() info = tool_dir / "hackrf_info.exe" clock = tool_dir / "hackrf_clock.exe" transfer = tool_dir / "hackrf_transfer.exe" for path in (info, clock, transfer): path.write_bytes(b"") commands: list[list[str]] = [] def runner(arguments: Sequence[str], timeout: float) -> FakeResult: del timeout command = list(arguments) commands.append(command) if command == [str(info)]: return FakeResult(command, f"Serial number: {FIXED_RX_SERIAL}") if command == [str(clock), "-d", FIXED_RX_SERIAL, "-i"]: return FakeResult(command, "CLKIN status: clock signal detected") raise AssertionError(command) launched: list[list[str]] = [] capture_path: Path | None = None def launcher(arguments: Sequence[str]) -> int: nonlocal capture_path command = list(arguments) launched.append(command) capture_path = Path(command[command.index("--capture-path") + 1]) started_path = Path(command[command.index("--started-status") + 1]) started_path.write_text( json.dumps( { "state": "RUNNING", "pid": 4321, "started_at": "2026-09-23T15:00:00+00:00", "capture_path": str(capture_path), } ), encoding="utf-8", ) capture_path.write_bytes(b"12") return 1234 def grow(_: float) -> None: assert capture_path is not None with capture_path.open("ab") as stream: stream.write(b"34") result, errors = start_rx_capture( repository, tmp_path / "runtime", "RF001A_20260923T150000Z_1234ABCD", FIXED_RX_SERIAL, hackrf_info_path=info, hackrf_clock_path=clock, hackrf_transfer_path=transfer, command_runner=runner, process_gate=lambda: (True, {"state": "CLEAR"}), background_launcher=launcher, process_stopper=lambda pid: None, sleep=grow, ) assert errors == [] assert result["state"] == "READY" assert result["rf_rx"] == 1 assert result["rf_tx"] == 0 assert result["automatic_retry"] is False assert result["common_clock_verified"] is True assert len(launched) == 1 arguments_path = Path( launched[0][launched[0].index("--arguments-json") + 1] ) transfer_arguments = json.loads(arguments_path.read_text(encoding="utf-8")) assert transfer_arguments == [ "-d", FIXED_RX_SERIAL, "-r", str(result["capture_path"]), "-f", "1400000000", "-s", "8000000", "-n", "2400000000", "-l", "24", "-g", "24", "-a", "0", "-p", "0", "-B", ] assert "-t" not in transfer_arguments assert commands == [ [str(info)], [str(clock), "-d", FIXED_RX_SERIAL, "-i"], ] def test_capture_start_rejects_explicit_no_clock_status(tmp_path: Path) -> None: repository = tmp_path / "repo" repository.mkdir() _write_repository(repository) tool_dir = tmp_path / "hackrf" tool_dir.mkdir() info = tool_dir / "hackrf_info.exe" clock = tool_dir / "hackrf_clock.exe" transfer = tool_dir / "hackrf_transfer.exe" for path in (info, clock, transfer): path.write_bytes(b"") def runner(arguments: Sequence[str], timeout: float) -> FakeResult: del timeout command = list(arguments) if command == [str(info)]: return FakeResult(command, f"Serial number: {FIXED_RX_SERIAL}") if command == [str(clock), "-d", FIXED_RX_SERIAL, "-i"]: return FakeResult(command, "CLKIN status: no clock signal detected") raise AssertionError(command) launched: list[list[str]] = [] result, errors = start_rx_capture( repository, tmp_path / "runtime", "RF001A_20260925T120000Z_1234ABCD", FIXED_RX_SERIAL, hackrf_info_path=info, hackrf_clock_path=clock, hackrf_transfer_path=transfer, command_runner=runner, process_gate=lambda: (True, {"state": "CLEAR"}), background_launcher=lambda arguments: launched.append(list(arguments)) or 1234, process_stopper=lambda pid: None, ) assert result["hardware_accessed"] is True assert result["rf_rx"] == 0 assert result["rf_tx"] == 0 assert result["common_clock_verified"] is False assert errors == ["RuntimeError: CLKIN clock signal was not detected"] assert launched == [] def test_capture_status_requires_exact_complete_file(tmp_path: Path) -> None: session_id = "RF001A_20260923T150000Z_1234ABCD" session = tmp_path / "rx_capture" / session_id session.mkdir(parents=True) capture = session / CAPTURE_FILE_NAME capture.write_bytes(b"1234") result_path = session / RESULT_FILE_NAME result_path.write_text( json.dumps( { "exit_code": 0, "runner_error": None, "capture_size": 4, "sha256": "fixture", } ), encoding="utf-8", ) (session / MANIFEST_FILE_NAME).write_text( json.dumps( { "session_id": session_id, "capture_path": str(capture), "capture_started_at": "2026-09-23T15:00:00+00:00", "expected_capture_end": "2026-09-23T15:00:20+00:00", "expected_capture_bytes": 4, "result_status_path": str(result_path), "common_clock_verified": True, "rf_tx": 0, } ), encoding="utf-8", ) result, errors = read_capture_status(tmp_path, session_id) assert errors == [] assert result["state"] == "CAPTURE_COMPLETE" assert result["capture_bytes"] == 4 assert result["hardware_accessed"] is False assert result["rf_tx"] == 0 def test_capture_diagnostics_returns_report_and_signal_power(tmp_path: Path) -> None: session_id = "RF001A_20260925T104504Z_5FA2C83B" session = tmp_path / "rx_capture" / session_id session.mkdir(parents=True) capture = session / CAPTURE_FILE_NAME samples = bytearray() for index in range(200): code = 10 if 80 <= index < 120 else 1 samples.extend((code, code)) capture.write_bytes(samples) capture_sha256 = hashlib.sha256(samples).hexdigest() result_path = session / RESULT_FILE_NAME result_path.write_text( json.dumps( { "exit_code": 0, "runner_error": None, "capture_size": len(samples), "sha256": capture_sha256, } ), encoding="utf-8", ) (session / MANIFEST_FILE_NAME).write_text( json.dumps( { "session_id": session_id, "capture_path": str(capture), "capture_started_at": "2026-09-25T10:00:00+00:00", "expected_capture_end": "2026-09-25T10:00:01+00:00", "expected_capture_bytes": len(samples), "result_status_path": str(result_path), "common_clock_verified": True, "rf_tx": 0, } ), encoding="utf-8", ) report = { "rf_tx": 0, "iq_sha256": capture_sha256, "detected_preambles": 0, "rejected_candidates": 0, "crc_valid_packets": 0, "exact_packets": 0, "rx_clipping_rail_codes": 0, "analysis_windows": [{"start_sample": 64, "stop_sample": 136}], } (session / REPORT_FILE_NAME).write_text( json.dumps(report), encoding="utf-8", ) diagnostics, errors = read_capture_diagnostics( tmp_path, session_id, "2026-09-25T10:00:00.000010+00:00", "2026-09-25T10:00:00.000015+00:00", ) assert errors == [] assert diagnostics["hardware_accessed"] is False assert diagnostics["rf_tx"] == 0 assert diagnostics["report"] == report signal = diagnostics["signal_metrics"] assert signal["tx_window"]["start_sample"] == 80 assert signal["tx_window"]["stop_sample"] == 120 assert signal["tx_to_baseline_power_ratio"] == 100.0 assert signal["tx_to_baseline_db"] == 20.0 def test_missing_process_gate_dependency_reports_failure_without_hardware( tmp_path: Path, ) -> None: repository = tmp_path / "repo" repository.mkdir() _write_repository(repository) tool_dir = tmp_path / "hackrf" tool_dir.mkdir() info = tool_dir / "hackrf_info.exe" clock = tool_dir / "hackrf_clock.exe" transfer = tool_dir / "hackrf_transfer.exe" for path in (info, clock, transfer): path.write_bytes(b"") commands: list[list[str]] = [] def runner(arguments: Sequence[str], timeout: float) -> FakeResult: del timeout commands.append(list(arguments)) return FakeResult(arguments, "unexpected") def missing_gate() -> tuple[bool, dict[str, object]]: raise ModuleNotFoundError("tools.hackrf_process_check") result, errors = start_rx_capture( repository, tmp_path / "runtime", "RF001A_20260924T090807Z_B8E6794D", FIXED_RX_SERIAL, hackrf_info_path=info, hackrf_clock_path=clock, hackrf_transfer_path=transfer, command_runner=runner, process_gate=missing_gate, process_stopper=lambda pid: None, ) assert result["hardware_accessed"] is False assert result["rf_rx"] == 0 assert result["rf_tx"] == 0 assert errors == [ "ModuleNotFoundError: tools.hackrf_process_check" ] assert commands == []