Add RFG UDP source to rph_radar
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
"""Whole-sweep RPH1 packets from the collector's PTY (little endian).
|
||||
"""RPH sweep sources, both little endian.
|
||||
|
||||
The 48-byte header carries payload size, source sweep/cycle IDs, monotonic capture
|
||||
window, input/output (65535 for untagged) and dirty flag. Payload points are
|
||||
8-byte ``marker, step, I, Q`` frames: A for main and A8 for reference.
|
||||
Only complete queued packets may be dropped; a packet already being written is
|
||||
always finished. Thus overflow cannot splice two physical sweeps together.
|
||||
E-502 collector PTY: whole-sweep RPH1 packets. The 48-byte header carries payload
|
||||
size, source sweep/cycle IDs, monotonic capture window, input/output (65535 for
|
||||
untagged) and dirty flag. Payload points are 8-byte ``marker, step, I, Q`` frames:
|
||||
A for main and A8 for reference. Only complete queued packets may be dropped; a
|
||||
packet already being written is always finished, so overflow cannot splice sweeps.
|
||||
|
||||
RFG board UDP (``SWEEP_AVG=1``): a ``u16 values_per_point, u16 q_shift`` header and
|
||||
28-byte points ``u32 (sweep << 24) | step`` plus six int32 Q(q_shift) step averages
|
||||
``[a1c0, a1c1, a1c2, a2c0, a2c1, a2c2]``. Channel k is ``a1ck + j*a2ck``; the main
|
||||
signal is the synchronous detector ch0 - ch1 and the reference is ch2.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -18,6 +23,8 @@ REFERENCE_MARKER = 0x00A8
|
||||
_HEADER = struct.Struct("<4sIQQQQHHI")
|
||||
_POINT = struct.Struct("<HHhh")
|
||||
_MAX_PAYLOAD_BYTES = 65534 * 2 * _POINT.size
|
||||
_RFG_HEADER = struct.Struct("<HH")
|
||||
_RFG_POINT = struct.Struct("<I6i")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -87,3 +94,68 @@ class RphRadarStreamParser:
|
||||
None if inp == 65535 else (inp, out), bool(flags & 1),
|
||||
))
|
||||
return sweeps
|
||||
|
||||
|
||||
class RfgSweepAssembler:
|
||||
"""Collect RFG points into sweeps of exactly the laser task's ``steps``.
|
||||
|
||||
A sweep closes when the next sweep id appears. Ids advance by one per sweep, so an
|
||||
id that is not ahead of the current one (mod 256) is a late point of an older
|
||||
sweep and is ignored — unless 128 sweep periods passed, when any id is legitimate. It is kept only with steps ``0..steps-1`` in order: the
|
||||
reference phase advances up to ~1.8 rad per step, so a lost, duplicate or
|
||||
reordered point would break the unwrapped frequency axis. Arrival times are
|
||||
kernel receive stamps (see the reader). Step k leaves the board
|
||||
``(k + 1) * step_ns`` after SWEEP_START and arrives no earlier, so the lower
|
||||
envelope of ``arrival - (k + 1) * step_ns`` is the sweep start.
|
||||
"""
|
||||
|
||||
def __init__(self, steps: int, step_ns: int, period_ns: int) -> None:
|
||||
self._steps, self._step_ns, self._period_ns = steps, step_ns, period_ns
|
||||
self._sweep_id: int | None = None # newest sweep; its points may still be arriving
|
||||
self._last_ns = 0
|
||||
self._points: list[tuple[int, complex, complex, int]] = []
|
||||
self._count = 0
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop the sweep in progress; its remaining points then form a dirty sweep."""
|
||||
self._sweep_id, self._points = None, []
|
||||
|
||||
def feed(self, datagram: bytes, received_ns: int) -> list[RawSweep]:
|
||||
if len(datagram) < _RFG_HEADER.size:
|
||||
raise ValueError("RFG protocol violation: truncated datagram")
|
||||
values, q_shift = _RFG_HEADER.unpack_from(datagram)
|
||||
if values != 6 or q_shift > 30 or (len(datagram) - _RFG_HEADER.size) % _RFG_POINT.size:
|
||||
raise ValueError("RFG protocol violation: unexpected datagram layout")
|
||||
scale = 1.0 / (1 << q_shift)
|
||||
sweeps = []
|
||||
for word, a1c0, a1c1, a1c2, a2c0, a2c1, a2c2 in _RFG_POINT.iter_unpack(datagram[_RFG_HEADER.size:]):
|
||||
if word >> 24 != self._sweep_id:
|
||||
if (self._sweep_id is not None and not 0 < ((word >> 24) - self._sweep_id) % 256 < 128
|
||||
and received_ns - self._last_ns < 128 * self._period_ns):
|
||||
continue
|
||||
sweeps.extend(self._close())
|
||||
self._sweep_id = word >> 24
|
||||
self._last_ns = received_ns
|
||||
if len(self._points) <= self._steps: # A stuck id cannot grow the buffer; the sweep is dirty.
|
||||
self._points.append((word & 0xFFFFFF, complex(a1c0 - a1c1, a2c0 - a2c1) * scale,
|
||||
complex(a1c2, a2c2) * scale, received_ns))
|
||||
return sweeps
|
||||
|
||||
def _close(self) -> list[RawSweep]:
|
||||
points, self._points = self._points, []
|
||||
if not points:
|
||||
return []
|
||||
steps = np.asarray([point[0] for point in points], dtype=np.int32)
|
||||
starts = [received - (step + 1) * self._step_ns for step, _, _, received in points]
|
||||
start = min(starts)
|
||||
# Every point of one sweep implies the same start within batching/latency jitter; an
|
||||
# 8-bit id reused after an outage (256 sweeps, ~5 s) would splice two sweeps otherwise.
|
||||
spliced = max(starts) - start > self._steps * self._step_ns
|
||||
self._count += 1
|
||||
return [RawSweep(
|
||||
steps,
|
||||
np.asarray([point[1] for point in points], dtype=np.complex64),
|
||||
np.asarray([point[2] for point in points], dtype=np.complex64),
|
||||
self._count, self._count, start, start + self._steps * self._step_ns,
|
||||
dirty=spliced or not np.array_equal(steps, np.arange(self._steps)),
|
||||
)]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Service that launches the RadioPhotonic radar (RPH) collector and serves processed sweeps.
|
||||
"""Service that serves processed RadioPhotonic radar (RPH) sweeps.
|
||||
|
||||
Owns the lifecycle of the external collector process and its TTY reader, and maps
|
||||
each raw (main, reference) sweep to an :class:`SweepResult` on the fixed
|
||||
processing grid via :class:`RphRadarSweepProcessor`.
|
||||
With ``source == "e502"`` it owns the external collector process and its TTY; with
|
||||
``"rfg_udp"`` it reads the RFG board's UDP stream directly. Each raw (main,
|
||||
reference) sweep maps to a :class:`SweepResult` on the fixed processing grid via
|
||||
:class:`RphRadarSweepProcessor`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,15 +23,13 @@ import time
|
||||
import numpy as np
|
||||
|
||||
from python_app.hardware_full.rph_radar.processing import RphRadarSweepProcessor
|
||||
from python_app.hardware_full.rph_radar.protocol import RawSweep
|
||||
from python_app.hardware_full.rph_radar.tty_reader import (
|
||||
RphRadarTtyReader,
|
||||
raise_if_process_exited,
|
||||
)
|
||||
from python_app.hardware_full.laser_control.conversions import variation_step_count
|
||||
from python_app.hardware_full.rph_radar.protocol import RawSweep, RfgSweepAssembler
|
||||
from python_app.hardware_full.rph_radar.stream_reader import RphRadarStreamReader, raise_if_process_exited
|
||||
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 RadarSweepModel, RunConfigModel
|
||||
from python_app.models.run_config_validation import validate_rph_radar_model
|
||||
from python_app.models.run_config_validation import validate_rfg_source, validate_rph_radar_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,12 +50,12 @@ _STOP_KILL_GRACE_S = 0.5
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RphRadarService:
|
||||
"""Launch the external RadioPhotonic radar (RPH) collector and serve its processed sweeps."""
|
||||
"""Serve processed RadioPhotonic radar (RPH) sweeps from the E-502 collector or the RFG stream."""
|
||||
|
||||
config: RunConfigModel
|
||||
collector_driven: bool = False
|
||||
_process: subprocess.Popen[bytes] | None = field(init=False, default=None, repr=False)
|
||||
_reader: RphRadarTtyReader | None = field(init=False, default=None, repr=False)
|
||||
_reader: RphRadarStreamReader | None = field(init=False, default=None, repr=False)
|
||||
_processor: RphRadarSweepProcessor | None = field(init=False, default=None, repr=False)
|
||||
_rejected_count: int = field(init=False, default=0, repr=False)
|
||||
_prepared_combo: ComboKey | None = field(init=False, default=None, repr=False)
|
||||
@@ -81,13 +80,25 @@ class RphRadarService:
|
||||
return cmd
|
||||
|
||||
def open(self, *, stop_event: threading.Event | None = None) -> None:
|
||||
"""Launch the collector and start the TTY reader thread.
|
||||
"""Bind the RFG UDP stream, or launch the E-502 collector and start its TTY reader.
|
||||
|
||||
An optional ``stop_event`` lets a caller abort the TTY-wait loop promptly
|
||||
(e.g. on shutdown) instead of blocking for the full startup timeout.
|
||||
"""
|
||||
if self._reader is not None:
|
||||
return
|
||||
adc = self.config.radar.rph_radar
|
||||
if adc.source == "rfg_udp":
|
||||
variation = self.config.radar.laser_control.variation
|
||||
steps = variation_step_count(variation.min_value, variation.max_value, variation.step)
|
||||
step_ns = variation.time_step * 1000
|
||||
# Lower bound of the sweep period: the firmware idles at least delay_time - 1 ms.
|
||||
period_ns = steps * step_ns + (variation.delay_time - 1) * 1_000_000
|
||||
reader = RphRadarStreamReader(adc.udp_bind, RfgSweepAssembler(steps, step_ns, period_ns))
|
||||
reader.open()
|
||||
self._reader = reader
|
||||
logger.info("RadioPhotonic radar (RPH) RFG stream opened on %s (%d steps per sweep)", adc.udp_bind, steps)
|
||||
return
|
||||
if self._process is not None and self._process.poll() is None:
|
||||
raise RuntimeError("Previous RPH collector did not stop; cannot start a fresh capture")
|
||||
self._stop_event = stop_event
|
||||
@@ -95,7 +106,7 @@ class RphRadarService:
|
||||
try:
|
||||
self._start_process()
|
||||
self._wait_for_tty(previous_tty_identity, stop_event=stop_event)
|
||||
reader = RphRadarTtyReader(self.config.radar.rph_radar.tty_path)
|
||||
reader = RphRadarStreamReader(self.config.radar.rph_radar.tty_path)
|
||||
reader.open()
|
||||
self._reader = reader
|
||||
logger.info("RadioPhotonic radar (RPH) service opened")
|
||||
@@ -105,7 +116,7 @@ class RphRadarService:
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the TTY reader and the external collector process."""
|
||||
"""Stop the reader and, for E-502, the external collector process."""
|
||||
logger.debug("Closing RadioPhotonic radar (RPH) service")
|
||||
self._prepared_combo = None
|
||||
if self._reader is not None:
|
||||
@@ -157,7 +168,7 @@ class RphRadarService:
|
||||
if self._reader is None:
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) service is not open")
|
||||
process = self._process
|
||||
if process is None or process.poll() is not None:
|
||||
if self.config.radar.rph_radar.source == "e502" and (process is None or process.poll() is not None):
|
||||
return_code = None if process is None else process.poll()
|
||||
raise RuntimeError(f"RadioPhotonic radar (RPH) collector is not running (code={return_code})")
|
||||
|
||||
@@ -334,6 +345,9 @@ class RphRadarService:
|
||||
|
||||
adc = self.config.radar.rph_radar
|
||||
validate_rph_radar_model(adc)
|
||||
if adc.source == "rfg_udp":
|
||||
validate_rfg_source(self.config)
|
||||
return
|
||||
if not adc.executable_path:
|
||||
raise ValueError("radar.rph_radar.executable_path is required")
|
||||
if not adc.tty_path:
|
||||
|
||||
+106
-34
@@ -1,8 +1,9 @@
|
||||
"""Background TTY reader publishing the latest completed RadioPhotonic radar (RPH) sweep.
|
||||
"""Background reader publishing the latest completed RadioPhotonic radar (RPH) sweep.
|
||||
|
||||
The collector emits sweeps continuously, faster than callers invoke
|
||||
:meth:`RphRadarService.acquire`. A daemon thread drains the device end of the
|
||||
TTY non-stop, feeds the bytes to a :class:`RphRadarStreamParser`, and stores the
|
||||
The source (E-502 collector TTY or RFG board UDP socket) emits sweeps continuously,
|
||||
faster than callers invoke :meth:`RphRadarService.acquire`. A daemon thread drains
|
||||
the descriptor non-stop, feeds it to :class:`RphRadarStreamParser` or
|
||||
:class:`RfgSweepAssembler`, and stores the
|
||||
most recent :class:`RawSweep` in a single-slot mailbox. :meth:`read_sweep`
|
||||
returns the freshest sweep; if a newer one arrives before the consumer reads, it
|
||||
overwrites the previous unread value — by design, since consumers always want the
|
||||
@@ -12,16 +13,20 @@ thread (fail-fast; the supervisor relaunches a clean collector).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import select
|
||||
import socket
|
||||
import struct
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from python_app.hardware_full.rph_radar.protocol import RphRadarStreamParser, RawSweep
|
||||
from python_app.hardware_full.rph_radar.protocol import RawSweep, RfgSweepAssembler, RphRadarStreamParser
|
||||
from python_app.models.run_config_validation import parse_udp_bind
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,6 +36,13 @@ _READ_CHUNK_BYTES = 65536
|
||||
# select() poll interval — short enough to react to close() promptly, long enough
|
||||
# that idle CPU stays near zero.
|
||||
_READ_POLL_INTERVAL_S = 0.1
|
||||
# Kernel receive stamps (Linux SO_TIMESTAMPNS == SCM_TIMESTAMPNS on x86_64 and aarch64,
|
||||
# a 64-bit timespec) are immune to the reader thread's scheduling stalls.
|
||||
_SO_TIMESTAMPNS = 35
|
||||
_TIMESPEC = struct.Struct("qq")
|
||||
# A REALTIME step between the kernel stamp and the clock read would mistranslate it;
|
||||
# NTP slewing moves the REALTIME-monotonic offset by microseconds only.
|
||||
_CLOCK_JUMP_NS = 1_000_000
|
||||
|
||||
|
||||
def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
|
||||
@@ -42,11 +54,18 @@ def raise_if_process_exited(process: subprocess.Popen[bytes] | None) -> None:
|
||||
raise RuntimeError(f"RadioPhotonic radar (RPH) collector exited with code {return_code}")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RphRadarTtyReader:
|
||||
"""Daemon-thread TTY reader publishing the latest completed sweep."""
|
||||
def _clock_offset_ns() -> int:
|
||||
return time.clock_gettime_ns(time.CLOCK_REALTIME) - time.monotonic_ns()
|
||||
|
||||
tty_path: str
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RphRadarStreamReader:
|
||||
"""Daemon-thread reader of the collector TTY, or of the RFG UDP stream when ``rfg`` is set."""
|
||||
|
||||
endpoint: str # TTY path, or HOST:PORT bind address for the RFG stream
|
||||
assembler: RfgSweepAssembler | None = None
|
||||
_socket: socket.socket | None = field(init=False, default=None, repr=False)
|
||||
_clock_offset_ns: int | None = field(init=False, default=None, repr=False)
|
||||
_fd: int | None = field(init=False, default=None, repr=False)
|
||||
_thread: threading.Thread | None = field(init=False, default=None, repr=False)
|
||||
_stop_event: threading.Event = field(init=False, default_factory=threading.Event, repr=False)
|
||||
@@ -58,12 +77,29 @@ class RphRadarTtyReader:
|
||||
_combo_slots: dict[tuple[int, int], RawSweep] = field(init=False, default_factory=dict, repr=False)
|
||||
_reader_error: Exception | None = field(init=False, default=None, repr=False)
|
||||
_published_count: int = field(init=False, default=0, repr=False)
|
||||
last_dirty_size: int | None = field(init=False, default=None)
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open the TTY and start the background reader thread."""
|
||||
"""Open the TTY or bind the UDP socket and start the background reader thread."""
|
||||
if self._fd is not None:
|
||||
return
|
||||
self._fd = os.open(self.tty_path, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
if self.assembler is None:
|
||||
self._fd = os.open(self.endpoint, os.O_RDONLY | os.O_NOCTTY | os.O_NONBLOCK)
|
||||
else:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
# Taken before bind, so a REALTIME step while the first datagrams queue is still seen.
|
||||
offset_ns = _clock_offset_ns()
|
||||
try:
|
||||
# ~10k datagrams/s: a deep buffer rides out receiver stalls; the kernel caps it at rmem_max.
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 << 20)
|
||||
sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMPNS, 1)
|
||||
sock.bind(parse_udp_bind(self.endpoint))
|
||||
sock.setblocking(False)
|
||||
except OSError:
|
||||
sock.close()
|
||||
raise
|
||||
self._socket, self._fd, self._clock_offset_ns = sock, sock.fileno(), offset_ns
|
||||
logger.info("RFG socket receive buffer: %d bytes", sock.getsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF))
|
||||
self._stop_event.clear()
|
||||
self._latest_sweep = None
|
||||
self._combo_slots = {}
|
||||
@@ -71,21 +107,20 @@ class RphRadarTtyReader:
|
||||
self._published_count = 0
|
||||
self._thread = threading.Thread(
|
||||
target=self._reader_loop,
|
||||
name=f"rph-radar-tty-reader[{self.tty_path}]",
|
||||
name=f"rph-radar-reader[{self.endpoint}]",
|
||||
daemon=True,
|
||||
)
|
||||
try:
|
||||
self._thread.start()
|
||||
except Exception:
|
||||
self._thread = None
|
||||
fd, self._fd = self._fd, None
|
||||
os.close(fd)
|
||||
self._close_descriptor()
|
||||
raise
|
||||
logger.info("RadioPhotonic radar (RPH) TTY reader started on %s", self.tty_path)
|
||||
logger.info("RadioPhotonic radar (RPH) reader started on %s", self.endpoint)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop the reader thread and close the TTY descriptor."""
|
||||
logger.debug("Stopping RadioPhotonic radar (RPH) TTY reader on %s", self.tty_path)
|
||||
"""Stop the reader thread and close the descriptor."""
|
||||
logger.debug("Stopping RadioPhotonic radar (RPH) reader on %s", self.endpoint)
|
||||
self._stop_event.set()
|
||||
with self._mailbox_cv:
|
||||
self._mailbox_cv.notify_all()
|
||||
@@ -94,15 +129,18 @@ class RphRadarTtyReader:
|
||||
if self._thread.is_alive():
|
||||
logger.warning("RadioPhotonic radar (RPH) reader thread did not stop within 1.0s")
|
||||
self._thread = None
|
||||
if self._fd is not None:
|
||||
try:
|
||||
os.close(self._fd)
|
||||
finally:
|
||||
self._fd = None
|
||||
self._close_descriptor()
|
||||
self._latest_sweep = None
|
||||
self._combo_slots = {}
|
||||
self._reader_error = None
|
||||
|
||||
def _close_descriptor(self) -> None:
|
||||
fd, sock, self._fd, self._socket = self._fd, self._socket, None, None
|
||||
if sock is not None:
|
||||
sock.close()
|
||||
elif fd is not None:
|
||||
os.close(fd)
|
||||
|
||||
@property
|
||||
def published_count(self) -> int:
|
||||
"""Total number of sweeps the reader thread has produced."""
|
||||
@@ -123,12 +161,12 @@ class RphRadarTtyReader:
|
||||
error caught by the reader thread.
|
||||
"""
|
||||
if self._thread is None:
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is not open")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) reader is not open")
|
||||
deadline = time.monotonic() + float(timeout_s)
|
||||
with self._mailbox_cv:
|
||||
while True:
|
||||
if self._stop_event.is_set():
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) TTY reader is closed")
|
||||
raise RuntimeError("RadioPhotonic radar (RPH) reader is closed")
|
||||
# Deliver a pending sweep first: if the reader both published a
|
||||
# sweep and then died, the consumer still sees the good data and
|
||||
# only meets the error on the next call.
|
||||
@@ -142,23 +180,27 @@ class RphRadarTtyReader:
|
||||
raise_if_process_exited(process)
|
||||
remaining_s = deadline - time.monotonic()
|
||||
if remaining_s <= 0.0:
|
||||
rejected = "" if self.last_dirty_size is None else f"; last rejected sweep had {self.last_dirty_size} points"
|
||||
raise TimeoutError(
|
||||
f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s"
|
||||
f"Timed out waiting for RadioPhotonic radar (RPH) sweep after {float(timeout_s):.3f}s{rejected}"
|
||||
)
|
||||
self._mailbox_cv.wait(timeout=min(_READ_POLL_INTERVAL_S, remaining_s))
|
||||
|
||||
def _reader_loop(self) -> None:
|
||||
"""Drain the TTY, parse sweeps, and publish each completed one until stop."""
|
||||
"""Drain the descriptor, parse sweeps, and publish each completed one until stop."""
|
||||
parser = RphRadarStreamParser()
|
||||
try:
|
||||
while not self._stop_event.is_set():
|
||||
chunk = self._read_available()
|
||||
if not chunk:
|
||||
continue
|
||||
for sweep in parser.feed(chunk):
|
||||
if self.assembler is None:
|
||||
chunk = self._read_available()
|
||||
sweeps = parser.feed(chunk) if chunk else []
|
||||
else:
|
||||
datagram = self._receive_datagram()
|
||||
sweeps = self.assembler.feed(*datagram) if datagram is not None else []
|
||||
for sweep in sweeps:
|
||||
self._publish_sweep(sweep)
|
||||
except Exception as exc: # noqa: BLE001 — surfaced to the consumer via read_sweep
|
||||
logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.tty_path)
|
||||
logger.exception("RadioPhotonic radar (RPH) reader thread failed on %s", self.endpoint)
|
||||
self._publish_error(exc)
|
||||
|
||||
def _read_available(self) -> bytes:
|
||||
@@ -183,15 +225,44 @@ class RphRadarTtyReader:
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.EAGAIN, errno.EWOULDBLOCK}:
|
||||
return b""
|
||||
raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) TTY `{self.tty_path}`: {exc}") from exc
|
||||
raise RuntimeError(f"Failed to read RadioPhotonic radar (RPH) source `{self.endpoint}`: {exc}") from exc
|
||||
if not chunk:
|
||||
raise RuntimeError(f"RadioPhotonic radar (RPH) TTY `{self.tty_path}` closed while reading")
|
||||
raise RuntimeError(f"RadioPhotonic radar (RPH) source `{self.endpoint}` closed while reading")
|
||||
return chunk
|
||||
|
||||
def _receive_datagram(self) -> tuple[bytes, int] | None:
|
||||
"""Return one RFG datagram with its kernel receive time on the monotonic clock."""
|
||||
sock = self._socket
|
||||
if sock is None or self._stop_event.is_set():
|
||||
return None
|
||||
try:
|
||||
readable, _, _ = select.select([sock], [], [], _READ_POLL_INTERVAL_S)
|
||||
if not readable:
|
||||
return None
|
||||
datagram, ancillary, _, _ = sock.recvmsg(_READ_CHUNK_BYTES, socket.CMSG_SPACE(_TIMESPEC.size))
|
||||
except (BlockingIOError, InterruptedError):
|
||||
return None
|
||||
offset_ns, now_ns = _clock_offset_ns(), time.monotonic_ns()
|
||||
stamps = [data for level, kind, data in ancillary if level == socket.SOL_SOCKET and kind == _SO_TIMESTAMPNS]
|
||||
if not stamps:
|
||||
raise RuntimeError("RFG datagram arrived without a kernel receive timestamp")
|
||||
seconds, nanoseconds = _TIMESPEC.unpack(stamps[0])
|
||||
received_ns = seconds * 1_000_000_000 + nanoseconds - offset_ns
|
||||
previous, self._clock_offset_ns = self._clock_offset_ns, offset_ns
|
||||
if previous is None or abs(offset_ns - previous) > _CLOCK_JUMP_NS or received_ns > now_ns:
|
||||
# Queued stamps may predate the step: drop them and the sweep in progress.
|
||||
with suppress(BlockingIOError):
|
||||
while True:
|
||||
sock.recv(_READ_CHUNK_BYTES)
|
||||
assert self.assembler is not None
|
||||
self.assembler.reset()
|
||||
return None
|
||||
return datagram, received_ns
|
||||
|
||||
def _publish_sweep(self, sweep: RawSweep) -> None:
|
||||
"""Publish a completed sweep to the mailbox(es), waking any waiter.
|
||||
|
||||
Dirty sweeps (those that straddled a switch transition) are counted but not
|
||||
Dirty sweeps (straddling a switch, or incomplete RFG sweeps) are counted but not
|
||||
delivered: the collector re-takes that combination on the next sweep. Clean
|
||||
tagged sweeps go to their per-combo slot; untagged sweeps (non-switch mode)
|
||||
only update the single newest-sweep mailbox that read_sweep() serves.
|
||||
@@ -199,6 +270,7 @@ class RphRadarTtyReader:
|
||||
with self._mailbox_cv:
|
||||
self._published_count += 1
|
||||
if sweep.dirty:
|
||||
self.last_dirty_size = sweep.size
|
||||
self._mailbox_cv.notify_all()
|
||||
return
|
||||
self._latest_sweep = sweep
|
||||
Reference in New Issue
Block a user