357 lines
11 KiB
Python
357 lines
11 KiB
Python
"""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 == []
|