"""PTY integration, source timing, fresh captures and owned collector lifecycle.""" import json import os from pathlib import Path import pty import subprocess import sys import tempfile import threading import time import tty import unittest from unittest import mock import numpy as np from python_app.hardware_full.rph_radar.service import RphRadarService from python_app.hardware_full.rph_radar.tty_reader import RphRadarTtyReader from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser from python_app.hardware_full.librevna_driver.models import SweepResult from python_app.models.dataset_model import ComboKey from python_app.models.run_config_schema import RunConfigModel from python_app.orchestration.process_supervisor import ProcessSupervisor from python_app.scripts import rph_radar_raw_producer as producer from python_app.tests.test_rph_radar_protocol import packet def config(): return RunConfigModel.from_dict({ "radar": {"model": "rph_radar", "driver_mode": "native", "rph_radar": { "executable_path": "/bin/sh", "tty_path": "/tmp/rph-test"}}, "switches": {"port1": {"positions": 1}, "port2": {"positions": 1}}, "run": {"continuous": False, "combos": [{"input": 0, "output": 0}]}, }) class RphRadarTtyReaderTest(unittest.TestCase): def test_pty_delivers_latest_complete_clean_sweeps_by_pair(self): master, slave = pty.openpty() tty.setraw(slave) reader = RphRadarTtyReader(os.ttyname(slave)) reader.open() try: os.write(master, packet(source_id=1, combo=(0, 0)) + packet(source_id=2, combo=(0, 1), dirty=1) + packet(source_id=3, combo=(0, 1))) deadline = time.monotonic() + 1 while reader.published_count < 3 and time.monotonic() < deadline: time.sleep(.005) self.assertEqual(reader.read_sweep(timeout_s=1).source_id, 3) self.assertEqual(reader.read_sweep(combo=(0, 0), timeout_s=1).source_id, 1) self.assertEqual(reader.read_sweep(combo=(0, 1), timeout_s=1).source_id, 3) with self.assertRaises(TimeoutError): reader.read_sweep(combo=(0, 1), timeout_s=.01) os.write(master, packet(start=0)) with self.assertRaisesRegex(ValueError, "protocol"): reader.read_sweep(timeout_s=1) finally: reader.close() os.close(master) os.close(slave) def test_new_source_session_rejects_backlog_despite_adc_clock_drift(self): for settling_ms in (0, 2): with self.subTest(settling_ms=settling_ms): cfg = config() cfg.runtime.settling_ms = settling_ms service = RphRadarService(cfg) # Physically this packet crossed the switch; a +50 ppm ADC clock # after 1000 s puts its timestamp 44 ms past the host switch. stale, fresh = RphRadarStreamParser().feed( packet(source_id=9, start=1_044_000_000, end=1_064_000_000) + packet(source_id=1, cycle_id=1, start=1_100_000_000, end=1_120_000_000, points=[(0xA, 1, 20, 0), (0xA8, 1, 100, 0)])) old_reader = mock.Mock(read_sweep=mock.Mock(return_value=stale)) new_reader = mock.Mock(read_sweep=mock.Mock(return_value=fresh)) service._reader = old_reader service._process = mock.Mock(poll=mock.Mock(return_value=None)) service._stop_event = threading.Event() processor = mock.Mock(grid_hz=np.array([1.]), _previous_anchor_rad=3.3, process=mock.Mock(side_effect=lambda main, reference: main)) service._processor = processor def reopen(_service, *, stop_event): old_reader.close.assert_called_once() self.assertIs(stop_event, service._stop_event) service._reader = new_reader service._process = mock.Mock(poll=mock.Mock(return_value=None)) with mock.patch.object(RphRadarService, "open", autospec=True, side_effect=reopen) as opened, \ mock.patch("python_app.hardware_full.rph_radar.service.time.monotonic_ns", return_value=1_000_000_000), \ mock.patch("python_app.hardware_full.rph_radar.service.time.sleep") as sleep: with mock.patch.object(RphRadarService, "_stop_process", side_effect=lambda: setattr(service._process, "poll", mock.Mock(return_value=0))): service.prepare_combo(ComboKey(0, 0)) result = service.acquire() np.testing.assert_array_equal(result.trace("s21"), [20+0j]) service.prepare_combo(ComboKey(0, 0)) opened.assert_called_once() self.assertEqual(sleep.call_count, int(settling_ms == 0)) self.assertEqual((result.capture_start_ns, result.capture_end_ns), (1_100_000_000, 1_120_000_000)) self.assertIs(service._processor, processor) self.assertEqual(processor._previous_anchor_rad, 3.3) old_reader.read_sweep.assert_not_called() def test_collector_output_is_captured_in_existing_log(self): import io service = RphRadarService(config()) process = mock.Mock(stdout=io.BytesIO(b"ADC disconnected\n"), poll=mock.Mock(return_value=1)) with mock.patch("python_app.hardware_full.rph_radar.service.subprocess.Popen", return_value=process) as launch: with self.assertLogs("python_app.hardware_full.rph_radar.service", "INFO") as logs: service._start_process() service.close() self.assertIn("ADC disconnected", " ".join(logs.output)) self.assertEqual(launch.call_args.kwargs["stdout"], subprocess.PIPE) def test_producer_drains_switch_and_never_mixes_source_cycles(self): for native in (False, True): with self.subTest(collector_driven=native): cfg = config() cfg.input_switch.driver_mode = "native" cfg.output_switch.driver_mode = "native" if native else "mock" cfg.combos = cfg.build_full_combos(1, 2) if native else cfg.combos radar, publisher = mock.Mock(), mock.Mock() windows = iter(((1, 1, 5), (2, 6, 10), (3, 11, 15), (3, 16, 20)) if native else ((1, 11, 20),)) def acquire(**_kwargs): radar.last_cycle_id, start, end = next(windows) return SweepResult(np.array([1., 2.]), {"s21": np.ones(2)}, capture_start_ns=start, capture_end_ns=end) radar.acquire.side_effect = acquire with mock.patch.object(producer, "load_producer_config", return_value=(Path("unused"), cfg, threading.Event())), \ mock.patch.object(producer, "RphRadarService", return_value=radar), \ mock.patch.object(producer, "RawPublisher", return_value=publisher), \ mock.patch.object(producer.SwitchService, "from_model"), \ mock.patch.object(producer, "_open_radar_with_retry", return_value=True), \ mock.patch.object(producer, "select_and_settle", return_value=True): self.assertEqual(producer.main(), 0) if not native: calls = radar.mock_calls self.assertLess(calls.index(mock.call.prepare_combo(ComboKey(0, 0))), calls.index(mock.call.acquire())) publisher.publish.assert_called_once() collection = publisher.publish.call_args.args[0] self.assertEqual(collection.collection_id, 3 if native else 1) self.assertEqual((collection.capture_start_ns, collection.capture_end_ns), (11, 20)) self.assertEqual(collection.traces[0].capture_start_ns, 11) class RphRadarConfigTest(unittest.TestCase): def test_collector_uses_current_settings_without_rereading_profile(self): cfg = config() cfg.runtime.settling_ms = 7 service = RphRadarService(cfg, collector_driven=True) payload = next(arg.removeprefix("switching:") for arg in service.command if arg.startswith("switching:")) switching = json.loads(payload) self.assertEqual(switching["settling_ms"], 7) self.assertEqual(switching["combos"], [[0, 0]]) self.assertEqual(switching["input"]["gpio_chip"], cfg.input_switch.gpio_chip) self.assertFalse(any(arg.startswith("config:") for arg in service.command)) def test_config_round_trip_preserves_rph_sections(self) -> None: payload = { "radar": { "model": "rph_radar", "serial": "rph_radar", "driver_mode": "native", "rph_radar": { "project_dir": "", "executable_path": "build/bin/rph_radar_collector", "tty_path": "/tmp/ttyADC_data", "args": ["profile:phase", "do8_freq_ref"], "env": {"ADC_ENV": "1"}, "startup_timeout_s": 7.0, "sweep_timeout_s": 8.0, "stop_timeout_s": 3.0, "phase_calibration": { "phase0_rad": 1.5, "freq0_hz": 2_046_000_000.0, "phase1_rad": 301.0, "freq1_hz": 5_612_000_000.0, }, "band": { "start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024, }, }, "laser_control": {"enabled": True, "port": "/dev/ttyUSB0", "mode": "manual"}, "sweep": {"start_hz": 1.0, "stop_hz": 2.0, "points": 2}, }, "switches": {"port1": {"positions": 1}, "port2": {"positions": 1}}, } encoded = RunConfigModel.from_dict(payload).to_dict() rph = encoded["radar"]["rph_radar"] self.assertEqual(encoded["radar"]["model"], "rph_radar") self.assertEqual(rph["executable_path"], "build/bin/rph_radar_collector") self.assertEqual(rph["args"], ["profile:phase", "do8_freq_ref"]) self.assertEqual(rph["phase_calibration"]["phase0_rad"], 1.5) self.assertEqual(rph["phase_calibration"]["freq1_hz"], 5_612_000_000.0) self.assertEqual(rph["band"], {"start_hz": 2_100_000_000.0, "stop_hz": 5_500_000_000.0, "points": 1024}) def test_unreaped_collector_blocks_prepare_and_retry_before_touching_tty(self): service = RphRadarService(config()) process = mock.Mock(spec=subprocess.Popen, pid=2_000_000_000) process.poll.return_value = None process.wait.side_effect = subprocess.TimeoutExpired("rph_radar_collector", timeout=.5) service._process = process with mock.patch("python_app.hardware_full.rph_radar.service.os.killpg"): with self.assertRaisesRegex(RuntimeError, "did not stop"): service.prepare_combo(ComboKey(0, 0)) service.close() # Producer retry closes again; still exception-safe. with mock.patch("python_app.hardware_full.rph_radar.service._prepare_tty_path_for_collector") as unlink, \ mock.patch.object(RphRadarService, "_start_process") as start, \ mock.patch.object(RphRadarService, "_wait_for_tty"), \ mock.patch("python_app.hardware_full.rph_radar.service.RphRadarTtyReader"): with self.assertRaisesRegex(RuntimeError, "did not stop"): service.open() unlink.assert_not_called() start.assert_not_called() self.assertIs(service._process, process) process.poll.return_value = 0 service.open() # Recovery becomes possible only after actual exit. start.assert_called_once() service.close() def test_supervisor_selects_rph_radar_producer(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: config_path = Path(tmp_dir) / "run_config.json" config_path.write_text(json.dumps({"radar": {"model": "rph_radar"}}), encoding="utf-8") command = ProcessSupervisor(Path("/repo"))._acquisition_command(config_path) self.assertEqual( command, [sys.executable, "-m", "python_app.scripts.rph_radar_raw_producer", "--config", str(config_path)], ) if __name__ == "__main__": unittest.main()