Harden RF node control and agent updates

This commit is contained in:
LittleSam129
2026-09-30 11:42:19 +03:00
parent a94f2db4ee
commit 0d6fff224e
34 changed files with 14010 additions and 0 deletions

View 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 == []