Harden RF node control and agent updates
This commit is contained in:
356
tests/test_rf001_rx_capture.py
Normal file
356
tests/test_rf001_rx_capture.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""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 == []
|
||||
Reference in New Issue
Block a user