some fixes
This commit is contained in:
@@ -12,13 +12,15 @@ import html
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import QMainWindow, QMessageBox
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow, QMessageBox
|
||||
|
||||
from python_app.gui.controllers.app_window_config_mixin import AppWindowConfigMixin
|
||||
from python_app.gui.controllers.app_window_control_button_mixin import AppWindowControlButtonMixin
|
||||
from python_app.gui.controllers.app_window_pipeline_mixin import AppWindowPipelineMixin
|
||||
from python_app.gui.controllers.app_window_plot_mixin import AppWindowPlotMixin
|
||||
from python_app.gui.controllers.app_window_preprocess_mixin import AppWindowPreprocessMixin
|
||||
@@ -47,6 +49,7 @@ class AppWindow(
|
||||
AppWindowPlotMixin,
|
||||
AppWindowPipelineMixin,
|
||||
AppWindowSnapshotMixin,
|
||||
AppWindowControlButtonMixin,
|
||||
QMainWindow,
|
||||
):
|
||||
"""Top-level window coordinating GUI state and acquisition runtime."""
|
||||
@@ -64,6 +67,7 @@ class AppWindow(
|
||||
self._init_history_state()
|
||||
self._init_runtime_limits()
|
||||
self._init_polling_timer()
|
||||
self._init_control_button_state()
|
||||
self._bootstrap_ui_runtime()
|
||||
|
||||
def _init_paths(self, project_root: Path) -> None:
|
||||
@@ -234,6 +238,9 @@ class AppWindow(
|
||||
self._pipeline_metrics.set_log_sink(self._log)
|
||||
self._timer.start()
|
||||
self._maybe_auto_start_pipeline()
|
||||
self._start_control_button_watcher()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS"):
|
||||
self._install_headless_watchdog()
|
||||
|
||||
def _resolve_startup_profile_path(self) -> Path:
|
||||
"""Resolve active profile path from session-state or root fallback path."""
|
||||
@@ -278,7 +285,7 @@ class AppWindow(
|
||||
if self._is_truthy_env("RADAR_SYSTEM_AUTO_APPLY_RADAR"):
|
||||
QTimer.singleShot(500, self._auto_apply_radar_then_start)
|
||||
else:
|
||||
QTimer.singleShot(500, self._start_run)
|
||||
QTimer.singleShot(500, self._auto_start_pipeline_step)
|
||||
|
||||
def _auto_apply_radar_then_start(self) -> None:
|
||||
"""Apply current radar settings then start the pipeline (headless boot)."""
|
||||
@@ -287,8 +294,59 @@ class AppWindow(
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Auto apply-radar failed", exc, level="WARN")
|
||||
# Hand control back to the event loop so widget updates from
|
||||
# _apply_radar_settings can flush before _start_run takes over.
|
||||
QTimer.singleShot(100, self._start_run)
|
||||
# _apply_radar_settings can flush, then wait one second before the
|
||||
# start takes over so the device settles after apply-radar.
|
||||
QTimer.singleShot(1000, self._auto_start_pipeline_step)
|
||||
|
||||
def _auto_start_pipeline_step(self) -> None:
|
||||
"""Run the launcher-requested pipeline start.
|
||||
|
||||
In headless mode a start that does not bring the pipeline up is fatal: we
|
||||
exit non-zero so `systemd Restart=on-failure` restarts the unit instead of
|
||||
leaving an idle daemon producing nothing. (The producer itself waits for
|
||||
the device forever, so a live-but-deviceless producer counts as running.)
|
||||
"""
|
||||
self._start_run()
|
||||
if self._is_truthy_env("RADAR_SYSTEM_HEADLESS") and not self._supervisor.is_running():
|
||||
self._headless_fatal("Headless auto-start did not bring the pipeline up")
|
||||
|
||||
def _install_headless_watchdog(self) -> None:
|
||||
"""Self-heal a headless daemon: if a managed pipeline process crashes (exits
|
||||
without us stopping it), exit non-zero so the service restarts clean.
|
||||
|
||||
Intentional stops drop processes from the supervisor first, so a normal
|
||||
stop/start or tmp-reference transition never trips this.
|
||||
"""
|
||||
self._headless_watchdog = QTimer(self)
|
||||
self._headless_watchdog.setInterval(2000)
|
||||
self._headless_watchdog.timeout.connect(self._headless_watchdog_tick)
|
||||
self._headless_watchdog.start()
|
||||
|
||||
def _headless_watchdog_tick(self) -> None:
|
||||
"""Escalate any unexpected managed-process exit to a fatal headless restart."""
|
||||
crashed = [
|
||||
report
|
||||
for report in self._supervisor.collect_exit_reports()
|
||||
if not report.expected_clean_exit
|
||||
]
|
||||
if crashed:
|
||||
names = ", ".join(report.name for report in crashed)
|
||||
details = "\n\n".join(report.format() for report in crashed)
|
||||
self._headless_fatal(f"Pipeline process exited unexpectedly: {names}", details=details)
|
||||
|
||||
def _headless_fatal(self, reason: str, *, details: str | None = None) -> None:
|
||||
"""Log loudly to stderr and exit non-zero so systemd restarts the service.
|
||||
|
||||
Headless deployments have no operator and the in-app log only reaches an
|
||||
offscreen widget, so a dead pipeline would otherwise go unnoticed.
|
||||
"""
|
||||
self._log_error(reason, details=details)
|
||||
print(f"[radar] FATAL (headless): {reason}", file=sys.stderr, flush=True)
|
||||
if details:
|
||||
print(details, file=sys.stderr, flush=True)
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.exit(1)
|
||||
|
||||
@staticmethod
|
||||
def _is_truthy_env(name: str) -> bool:
|
||||
@@ -507,6 +565,8 @@ class AppWindow(
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
"""Ensure workers and dialogs are closed before window destruction."""
|
||||
try:
|
||||
# 0) Stop the GPIO button watcher so a late press cannot start work.
|
||||
self._stop_control_button_watcher()
|
||||
self._resume_pipeline_after_capture = False
|
||||
# 1) Abort active capture first (releases exclusive hardware resources).
|
||||
self._abort_capture_sequence(resume_pipeline=False)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Background watcher that turns a physical GPIO button press into a Qt signal.
|
||||
|
||||
A single :class:`GpioLineEventWatcher` is polled on a dedicated thread via
|
||||
:func:`select.select`, woken either by a GPIO edge or by a self-pipe used for
|
||||
clean shutdown. Because the watcher is a ``QObject``, its ``pressed`` signal is
|
||||
delivered through the event loop on the thread that owns it (the GUI thread),
|
||||
so connected slots may touch widgets exactly as a button click would.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import select
|
||||
import threading
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
|
||||
from python_app.hardware_full.switch_drivers.gpio_uapi import (
|
||||
GPIO_V2_LINE_EVENT_FALLING_EDGE,
|
||||
GPIO_V2_LINE_EVENT_RISING_EDGE,
|
||||
GPIO_V2_LINE_FLAG_BIAS_DISABLED,
|
||||
GPIO_V2_LINE_FLAG_BIAS_PULL_DOWN,
|
||||
GPIO_V2_LINE_FLAG_BIAS_PULL_UP,
|
||||
GPIO_V2_LINE_FLAG_EDGE_FALLING,
|
||||
GPIO_V2_LINE_FLAG_EDGE_RISING,
|
||||
GpioLineEventWatcher,
|
||||
)
|
||||
|
||||
_BIAS_FLAGS = {
|
||||
"pull_up": GPIO_V2_LINE_FLAG_BIAS_PULL_UP,
|
||||
"pull_down": GPIO_V2_LINE_FLAG_BIAS_PULL_DOWN,
|
||||
"disabled": GPIO_V2_LINE_FLAG_BIAS_DISABLED,
|
||||
}
|
||||
|
||||
|
||||
class ControlButtonWatcher(QObject):
|
||||
"""Monitor a GPIO push-button on a background thread and emit ``pressed``.
|
||||
|
||||
The press is detected on a single edge (falling for active-low wiring,
|
||||
rising otherwise), so a normal push produces exactly one ``pressed`` signal.
|
||||
``failed`` reports an unrecoverable watcher error as a human-readable string.
|
||||
"""
|
||||
|
||||
pressed = pyqtSignal()
|
||||
failed = pyqtSignal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
chip: str,
|
||||
pin: int,
|
||||
active_low: bool = True,
|
||||
bias: str = "",
|
||||
debounce_ms: int = 50,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
"""Configure the watcher; the GPIO line stays closed until :meth:`start`."""
|
||||
super().__init__(parent)
|
||||
active_low = bool(active_low)
|
||||
# Active-low wiring idles high and falls on press; active-high is the mirror.
|
||||
self._press_edge = (
|
||||
GPIO_V2_LINE_EVENT_FALLING_EDGE if active_low else GPIO_V2_LINE_EVENT_RISING_EDGE
|
||||
)
|
||||
edge_flag = (
|
||||
GPIO_V2_LINE_FLAG_EDGE_FALLING if active_low else GPIO_V2_LINE_FLAG_EDGE_RISING
|
||||
)
|
||||
self._line = GpioLineEventWatcher(
|
||||
chip,
|
||||
int(pin),
|
||||
edge_flags=edge_flag,
|
||||
bias_flags=self._resolve_bias_flags(bias, active_low),
|
||||
debounce_us=max(0, int(debounce_ms)) * 1000,
|
||||
consumer="radar_control_button",
|
||||
)
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_read_fd = -1
|
||||
self._stop_write_fd = -1
|
||||
|
||||
@staticmethod
|
||||
def _resolve_bias_flags(bias: str, active_low: bool) -> int:
|
||||
"""Return GPIO bias flags, defaulting to the bias that matches the wiring."""
|
||||
name = (bias or "").strip().lower()
|
||||
if name in _BIAS_FLAGS:
|
||||
return _BIAS_FLAGS[name]
|
||||
return GPIO_V2_LINE_FLAG_BIAS_PULL_UP if active_low else GPIO_V2_LINE_FLAG_BIAS_PULL_DOWN
|
||||
|
||||
def start(self) -> None:
|
||||
"""Open the GPIO line and begin watching for presses on a background thread."""
|
||||
self._line.open()
|
||||
self._stop_read_fd, self._stop_write_fd = os.pipe()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run, name="control-button-watcher", daemon=True
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal the watcher thread to exit and release the GPIO line and pipe."""
|
||||
if self._stop_write_fd >= 0:
|
||||
try:
|
||||
os.write(self._stop_write_fd, b"\x00")
|
||||
except OSError:
|
||||
pass
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
self._thread = None
|
||||
self._close_stop_pipe()
|
||||
self._line.close()
|
||||
|
||||
def _run(self) -> None:
|
||||
"""Block on the GPIO line until an edge fires or shutdown is requested."""
|
||||
line_fd = self._line.fileno()
|
||||
try:
|
||||
while True:
|
||||
readable, _, _ = select.select([line_fd, self._stop_read_fd], [], [])
|
||||
if self._stop_read_fd in readable:
|
||||
return
|
||||
if line_fd in readable and self._line.read_event() == self._press_edge:
|
||||
self.pressed.emit()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
def _close_stop_pipe(self) -> None:
|
||||
"""Close both ends of the self-pipe used to wake the watcher thread."""
|
||||
for attr in ("_stop_read_fd", "_stop_write_fd"):
|
||||
fd = getattr(self, attr)
|
||||
if fd >= 0:
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
setattr(self, attr, -1)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Mixin wiring a physical GPIO control button to a runtime action.
|
||||
|
||||
Both GUI and headless launches construct :class:`AppWindow`, so attaching the
|
||||
watcher here makes the button behave identically in both modes. The button
|
||||
reuses the existing "Capture Tmp Reference" flow (stop the pipeline, capture a
|
||||
fresh tmp reference, then restart the pipeline if it had been running), so no
|
||||
acquisition logic is duplicated for the hardware trigger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from python_app.gui.control_button import ControlButtonWatcher
|
||||
from python_app.models.run_config_schema import ControlButtonModel
|
||||
|
||||
|
||||
class AppWindowControlButtonMixin:
|
||||
"""Start and stop a background GPIO button watcher bound to a runtime action."""
|
||||
|
||||
def _init_control_button_state(self) -> None:
|
||||
"""Initialize the watcher handle before the watcher is started."""
|
||||
self._control_button_watcher: ControlButtonWatcher | None = None
|
||||
|
||||
def _start_control_button_watcher(self) -> None:
|
||||
"""Open the configured GPIO button line and begin watching for presses.
|
||||
|
||||
Any failure is logged and swallowed: the same build runs on developer
|
||||
machines and non-Pi hosts where the GPIO chip is absent, and a missing
|
||||
button must never abort startup.
|
||||
"""
|
||||
config = getattr(self._defaults_config, "control_button", None)
|
||||
if config is None or not config.enabled:
|
||||
return
|
||||
if config.pin < 0 or not config.gpio_chip:
|
||||
self._log_warning(
|
||||
"Control button enabled but gpio_chip/pin are unset; watcher not started."
|
||||
)
|
||||
return
|
||||
if config.action != ControlButtonModel.ACTION_CAPTURE_TMP_REFERENCE:
|
||||
self._log_warning(
|
||||
f"Control button action '{config.action}' is not supported; watcher not started."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
watcher = ControlButtonWatcher(
|
||||
chip=config.gpio_chip,
|
||||
pin=config.pin,
|
||||
active_low=config.active_low,
|
||||
bias=config.bias,
|
||||
debounce_ms=config.debounce_ms,
|
||||
parent=self,
|
||||
)
|
||||
watcher.pressed.connect(self._on_control_button_pressed)
|
||||
watcher.failed.connect(self._on_control_button_failed)
|
||||
watcher.start()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_exception("Failed to start GPIO control button watcher", exc, level="WARN")
|
||||
return
|
||||
|
||||
self._control_button_watcher = watcher
|
||||
self._log(
|
||||
"GPIO control button watcher started: "
|
||||
f"chip={config.gpio_chip}, pin={config.pin}, active_low={config.active_low}, "
|
||||
f"debounce_ms={config.debounce_ms}, action={config.action}"
|
||||
)
|
||||
|
||||
def _on_control_button_pressed(self) -> None:
|
||||
"""Run the configured action for a physical press on the Qt main thread.
|
||||
|
||||
Delivered as a queued signal from the watcher thread, so this executes
|
||||
on the GUI thread exactly like a click on "Capture Tmp Reference".
|
||||
"""
|
||||
self._log("GPIO control button pressed: capturing tmp reference.")
|
||||
self._capture_tmp_reference()
|
||||
|
||||
def _on_control_button_failed(self, message: str) -> None:
|
||||
"""Log an unrecoverable watcher error reported from the background thread."""
|
||||
self._log_warning("GPIO control button watcher stopped", details=message)
|
||||
|
||||
def _stop_control_button_watcher(self) -> None:
|
||||
"""Stop the watcher and release its GPIO line during shutdown."""
|
||||
watcher = getattr(self, "_control_button_watcher", None)
|
||||
if watcher is None:
|
||||
return
|
||||
try:
|
||||
watcher.stop()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._log_warning("Error stopping GPIO control button watcher", details=str(exc))
|
||||
finally:
|
||||
self._control_button_watcher = None
|
||||
@@ -6,7 +6,11 @@ import time
|
||||
|
||||
|
||||
from python_app.gui.runtime.constraints import validate_processing_mode_constraints
|
||||
from python_app.gui.runtime.history import build_run_history_signature, record_result_history
|
||||
from python_app.gui.runtime.history import (
|
||||
build_processor_run_signature,
|
||||
build_run_history_signature,
|
||||
record_result_history,
|
||||
)
|
||||
from python_app.hardware_full.kamil_adc_service import apply_kamil_adc_laser_control
|
||||
from python_app.hardware_full.single_radar_service import create_single_radar_service
|
||||
from python_app.models.dataset_model import ComboKey, ResultCollection, SweepCollection
|
||||
@@ -57,7 +61,8 @@ class AppWindowPipelineMixin:
|
||||
processor_was_running = self._supervisor.is_processor_running()
|
||||
config = self._build_config()
|
||||
run_signature = self._build_run_history_signature(config)
|
||||
if self._processor_requires_restart(run_signature):
|
||||
processor_signature = self._build_processor_run_signature(config)
|
||||
if self._processor_requires_restart(processor_signature):
|
||||
self._log("Restarting data_processor because stable run settings changed")
|
||||
self._stop_all_processes()
|
||||
processor_was_running = False
|
||||
@@ -122,7 +127,7 @@ class AppWindowPipelineMixin:
|
||||
self._raw_reader = ShmRingReader(config.rings.raw_tap.name)
|
||||
self._pre_reader = ShmRingReader(config.rings.preprocessed_tap.name)
|
||||
self._result_reader = ShmRingReader(config.rings.results.name)
|
||||
self._processor_run_signature = run_signature
|
||||
self._processor_run_signature = processor_signature
|
||||
self._single_capture_active = single_capture
|
||||
self._single_capture_start_ns = None
|
||||
self._single_capture_seen_raw = False
|
||||
@@ -167,8 +172,8 @@ class AppWindowPipelineMixin:
|
||||
config = self._build_config()
|
||||
if config.radar.driver_mode == "native":
|
||||
self._refresh_radar_limits_from_device()
|
||||
run_signature = self._build_run_history_signature(config)
|
||||
if processor_only_running and self._processor_requires_restart(run_signature):
|
||||
processor_signature = self._build_processor_run_signature(config)
|
||||
if processor_only_running and self._processor_requires_restart(processor_signature):
|
||||
self._stop_all_processes()
|
||||
self._reset_runtime_history()
|
||||
self._history_run_signature = None
|
||||
@@ -492,9 +497,13 @@ class AppWindowPipelineMixin:
|
||||
self._result_history.extend(result_tail)
|
||||
|
||||
def _build_run_history_signature(self, config: RunConfigModel) -> tuple[object, ...]:
|
||||
"""Build signature used to decide when history should be reset."""
|
||||
"""Build signature used to decide when display history should be reset."""
|
||||
return build_run_history_signature(config)
|
||||
|
||||
def _build_processor_run_signature(self, config: RunConfigModel) -> tuple[object, ...]:
|
||||
"""Build signature used to decide when the data_processor must be restarted."""
|
||||
return build_processor_run_signature(config)
|
||||
|
||||
def _validate_processing_mode_constraints(self, config: RunConfigModel) -> None:
|
||||
"""Validate processing-mode constraints for run start."""
|
||||
validate_processing_mode_constraints(
|
||||
|
||||
@@ -142,11 +142,19 @@ class AppWindowPreprocessMixin:
|
||||
)
|
||||
self._selected_preprocess_sets["s21_reference"] = TMP_REFERENCE_SET_NAME
|
||||
self._selected_preprocess_radar_key = radar_key
|
||||
self._processor_run_signature = None
|
||||
# Do NOT restart data_processor for a reference change: it does not consume
|
||||
# the S21 reference (the data_preprocessor does), and restarting it would tear
|
||||
# down the locator server it hosts and drop every connected client. The
|
||||
# preprocessor reloads the new reference when acquisition restarts below; the
|
||||
# processor keeps running. We only re-baseline the on-screen display history.
|
||||
self._history_run_signature = None
|
||||
if self._supervisor.is_processor_running():
|
||||
self._stop_all_processes()
|
||||
self._reset_runtime_history()
|
||||
# Tell the still-running data_processor to drop its accumulated background/
|
||||
# history so the new reference takes effect cleanly (no old/new-reference
|
||||
# blend) without restarting the process. Reuses the live-config clear_all
|
||||
# command, applied on the processor's next poll.
|
||||
if self._supervisor.is_processor_running():
|
||||
self._write_live_processing_config(history_command="clear_all", bump_history_seq=True)
|
||||
self._refresh_preprocess_summary_labels()
|
||||
if collection.traces:
|
||||
self._draw_single_trace(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
@@ -63,7 +64,12 @@ def main() -> int:
|
||||
_install_unix_signal_handlers(app, window)
|
||||
else:
|
||||
window.showMaximized()
|
||||
return app.exec()
|
||||
exit_code = app.exec()
|
||||
# Release hardware, SHM readers and child processes before exiting so that a
|
||||
# systemd restart (after a headless fatal exit) starts from a clean slate.
|
||||
with suppress(Exception):
|
||||
window.close()
|
||||
return exit_code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -59,12 +59,18 @@ def remove_last_aligned_histories(
|
||||
return retained_raw, retained_preprocessed, retained_results
|
||||
|
||||
|
||||
def build_run_history_signature(
|
||||
def build_processor_run_signature(
|
||||
config: RunConfigModel,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build deterministic signature to detect run-settings changes (excluding live processing params)."""
|
||||
"""Build signature of settings that change the data SHAPE the data_processor parses.
|
||||
|
||||
Excludes the preprocess set names on purpose: the data_processor does not consume
|
||||
calibration/reference sets (those are applied upstream by the data_preprocessor), so
|
||||
changing a set must NOT restart the processor. Restarting it would also tear down the
|
||||
locator TCP server it hosts and drop every connected client. Only genuine shape changes
|
||||
(radar model, sweep, switch layout, combos) require a processor restart.
|
||||
"""
|
||||
combos_signature = tuple((int(combo.input), int(combo.output)) for combo in config.combos)
|
||||
preprocess_signature = tuple(preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_KEYS)
|
||||
sweep_points_signature: object = "adc" if config.is_kamil_adc else int(config.radar.sweep.points)
|
||||
return (
|
||||
str(config.radar.model),
|
||||
@@ -85,11 +91,23 @@ def build_run_history_signature(
|
||||
str(config.output_switch.driver),
|
||||
int(config.output_switch.positions),
|
||||
bool(config.output_switch.invert_logic),
|
||||
preprocess_signature,
|
||||
combos_signature,
|
||||
)
|
||||
|
||||
|
||||
def build_run_history_signature(
|
||||
config: RunConfigModel,
|
||||
) -> tuple[object, ...]:
|
||||
"""Build full signature for GUI display-history reset (processor shape + preprocess sets).
|
||||
|
||||
Display history still resets when the reference/calibration set changes (the on-screen
|
||||
B-scan would otherwise mix old- and new-reference frames), even though the processor
|
||||
process itself is intentionally kept alive across that change.
|
||||
"""
|
||||
preprocess_signature = tuple(preprocess_asset_model(config, key).set_name for key in PREPROCESS_ASSET_KEYS)
|
||||
return build_processor_run_signature(config) + (preprocess_signature,)
|
||||
|
||||
|
||||
def _tail_occurrence_key(history: list[THistoryCollection], index: int) -> tuple[int, int]:
|
||||
"""Return `(collection_id, occurrence_from_tail)` for the item at `index`."""
|
||||
collection_id = int(history[index].collection_id)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator, Sequence
|
||||
from contextlib import suppress
|
||||
from dataclasses import replace
|
||||
from typing import Optional
|
||||
import threading
|
||||
@@ -46,12 +47,15 @@ class MultiDeviceVnaController:
|
||||
self._is_closed = False
|
||||
|
||||
try:
|
||||
# Register each device the moment it opens so a partial open (e.g. a
|
||||
# slave that fails after the master is up) is fully released by close().
|
||||
# Otherwise the master USB handle and its RX thread leak on every retry.
|
||||
self._master_device = LibreVnaUsbBulkConnection(master_serial_number)
|
||||
self._slave_devices = [
|
||||
LibreVnaUsbBulkConnection(slave_serial_number)
|
||||
for slave_serial_number in slave_serial_numbers
|
||||
]
|
||||
self._all_devices = [self._master_device, *self._slave_devices]
|
||||
self._all_devices.append(self._master_device)
|
||||
for slave_serial_number in slave_serial_numbers:
|
||||
connection = LibreVnaUsbBulkConnection(slave_serial_number)
|
||||
self._slave_devices.append(connection)
|
||||
self._all_devices.append(connection)
|
||||
except Exception:
|
||||
self.close()
|
||||
raise
|
||||
@@ -65,14 +69,20 @@ class MultiDeviceVnaController:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop sweeping and close every opened device transport."""
|
||||
"""Stop sweeping and close every opened device transport.
|
||||
|
||||
Resilient to a half-open or already-broken controller: failing to idle or
|
||||
close one device must not prevent the others from being released.
|
||||
"""
|
||||
if self._is_closed:
|
||||
return
|
||||
|
||||
self._is_closed = True
|
||||
self._send_idle_to_all_devices()
|
||||
with suppress(Exception):
|
||||
self._send_idle_to_all_devices()
|
||||
for device_connection in self._all_devices:
|
||||
device_connection.close()
|
||||
with suppress(Exception):
|
||||
device_connection.close()
|
||||
|
||||
def stop_continuous_sweep(self) -> None:
|
||||
"""Stop the currently running sweep without closing device transports."""
|
||||
|
||||
@@ -76,11 +76,14 @@ class MultiDeviceLibreVnaService:
|
||||
slave_serial_numbers=self.slave_serials,
|
||||
force_external_reference=self.force_external_reference,
|
||||
)
|
||||
except Exception:
|
||||
if self.backend_mode == "native":
|
||||
raise
|
||||
self._using_mock_backend = True
|
||||
self._controller = None
|
||||
except Exception as exc:
|
||||
# Never silently latch to synthetic data: a deployed appliance must wait
|
||||
# for the real device, not record fakes. Synthetic data requires an
|
||||
# explicit backend_mode='mock' (selected in __post_init__); both 'auto'
|
||||
# and 'native' re-raise so the producer's wait-for-device retry keeps
|
||||
# trying until the hardware appears.
|
||||
logger.warning("Multi-device open failed (backend_mode=%s): %s", self.backend_mode, exc)
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close native device transports; never raises.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Minimal Linux GPIO v2 UAPI wrapper for output-only line control."""
|
||||
"""Minimal Linux GPIO v2 UAPI wrapper for output lines and input edge events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -12,7 +12,18 @@ from typing import Sequence
|
||||
GPIO_MAX_NAME_SIZE = 32
|
||||
GPIO_V2_LINES_MAX = 64
|
||||
GPIO_V2_LINE_NUM_ATTRS_MAX = 10
|
||||
GPIO_V2_LINE_FLAG_INPUT = 1 << 2
|
||||
GPIO_V2_LINE_FLAG_OUTPUT = 1 << 3
|
||||
GPIO_V2_LINE_FLAG_EDGE_RISING = 1 << 4
|
||||
GPIO_V2_LINE_FLAG_EDGE_FALLING = 1 << 5
|
||||
GPIO_V2_LINE_FLAG_BIAS_PULL_UP = 1 << 8
|
||||
GPIO_V2_LINE_FLAG_BIAS_PULL_DOWN = 1 << 9
|
||||
GPIO_V2_LINE_FLAG_BIAS_DISABLED = 1 << 10
|
||||
|
||||
GPIO_V2_LINE_ATTR_ID_DEBOUNCE = 3
|
||||
|
||||
GPIO_V2_LINE_EVENT_RISING_EDGE = 1
|
||||
GPIO_V2_LINE_EVENT_FALLING_EDGE = 2
|
||||
|
||||
_IOC_NRBITS = 8
|
||||
_IOC_TYPEBITS = 8
|
||||
@@ -96,6 +107,19 @@ class GpioV2LineValues(ctypes.Structure):
|
||||
]
|
||||
|
||||
|
||||
class GpioV2LineEvent(ctypes.Structure):
|
||||
"""ctypes mapping of `gpio_v2_line_event`."""
|
||||
|
||||
_fields_ = [
|
||||
("timestamp_ns", ctypes.c_uint64),
|
||||
("id", ctypes.c_uint32),
|
||||
("offset", ctypes.c_uint32),
|
||||
("seqno", ctypes.c_uint32),
|
||||
("line_seqno", ctypes.c_uint32),
|
||||
("padding", ctypes.c_uint32 * 6),
|
||||
]
|
||||
|
||||
|
||||
GPIO_V2_GET_LINE_IOCTL = _iowr(0xB4, 0x07, GpioV2LineRequest)
|
||||
GPIO_V2_LINE_SET_VALUES_IOCTL = _iowr(0xB4, 0x0F, GpioV2LineValues)
|
||||
|
||||
@@ -198,3 +222,112 @@ class GpioOutputLines:
|
||||
if self._chip_fd >= 0:
|
||||
os.close(self._chip_fd)
|
||||
self._chip_fd = -1
|
||||
|
||||
|
||||
class GpioLineEventWatcher:
|
||||
"""Watch a single GPIO input line for edge events via Linux GPIO v2 UAPI.
|
||||
|
||||
The line file descriptor returned by the kernel becomes readable whenever a
|
||||
requested edge occurs; each read yields exactly one ``gpio_v2_line_event``.
|
||||
Callers drive the wait loop themselves (e.g. with :func:`select.select`)
|
||||
using :meth:`fileno`, which keeps this wrapper free of any threading or
|
||||
polling policy.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
chip: str,
|
||||
offset: int,
|
||||
*,
|
||||
edge_flags: int,
|
||||
bias_flags: int = 0,
|
||||
debounce_us: int = 0,
|
||||
consumer: str = "radar_input",
|
||||
) -> None:
|
||||
"""Build an input line request descriptor for edge detection."""
|
||||
if not chip:
|
||||
raise ValueError("gpio chip path must not be empty")
|
||||
if offset < 0:
|
||||
raise ValueError("GPIO offset must be non-negative")
|
||||
if not edge_flags:
|
||||
raise ValueError("at least one edge flag is required")
|
||||
|
||||
self._chip = chip
|
||||
self._offset = int(offset)
|
||||
self._flags = GPIO_V2_LINE_FLAG_INPUT | int(edge_flags) | int(bias_flags)
|
||||
self._debounce_us = max(0, int(debounce_us))
|
||||
self._consumer = (consumer or "radar_input").encode("ascii", errors="ignore")[: GPIO_MAX_NAME_SIZE - 1]
|
||||
|
||||
self._chip_fd = -1
|
||||
self._line_fd = -1
|
||||
|
||||
def open(self) -> None:
|
||||
"""Open GPIO chip and request the configured input line with edge events."""
|
||||
if self._line_fd >= 0:
|
||||
return
|
||||
|
||||
try:
|
||||
self._chip_fd = os.open(self._chip, os.O_RDONLY | os.O_CLOEXEC)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to open GPIO chip '{self._chip}': {exc}") from exc
|
||||
|
||||
request = GpioV2LineRequest()
|
||||
request.offsets[0] = ctypes.c_uint32(self._offset).value
|
||||
request.num_lines = ctypes.c_uint32(1).value
|
||||
request.config.flags = ctypes.c_uint64(self._flags).value
|
||||
request.consumer = self._consumer
|
||||
|
||||
if self._debounce_us > 0:
|
||||
config_attr = request.config.attrs[0]
|
||||
config_attr.attr.id = ctypes.c_uint32(GPIO_V2_LINE_ATTR_ID_DEBOUNCE).value
|
||||
config_attr.attr.value = ctypes.c_uint64(self._debounce_us).value
|
||||
config_attr.mask = ctypes.c_uint64(1).value # applies to line index 0
|
||||
request.config.num_attrs = ctypes.c_uint32(1).value
|
||||
|
||||
try:
|
||||
fcntl.ioctl(self._chip_fd, GPIO_V2_GET_LINE_IOCTL, request)
|
||||
except OSError as exc:
|
||||
self._close_chip_fd()
|
||||
raise RuntimeError(f"Failed to request GPIO line on '{self._chip}': {exc}") from exc
|
||||
|
||||
if request.fd < 0:
|
||||
self._close_chip_fd()
|
||||
raise RuntimeError(f"GPIO line request returned invalid fd for '{self._chip}'")
|
||||
|
||||
self._line_fd = int(request.fd)
|
||||
|
||||
def fileno(self) -> int:
|
||||
"""Return the line file descriptor for use with poll/select."""
|
||||
if self._line_fd < 0:
|
||||
raise RuntimeError("GPIO line request is not open")
|
||||
return self._line_fd
|
||||
|
||||
def read_event(self) -> int:
|
||||
"""Read one queued edge event and return its event id (rising/falling)."""
|
||||
if self._line_fd < 0:
|
||||
raise RuntimeError("GPIO line request is not open")
|
||||
|
||||
size = ctypes.sizeof(GpioV2LineEvent)
|
||||
data = os.read(self._line_fd, size)
|
||||
if len(data) < size:
|
||||
raise RuntimeError(f"Short GPIO event read: expected {size} bytes, got {len(data)}")
|
||||
|
||||
event = GpioV2LineEvent.from_buffer_copy(data)
|
||||
return int(event.id)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close line request and chip file descriptors."""
|
||||
self._close_line_fd()
|
||||
self._close_chip_fd()
|
||||
|
||||
def _close_line_fd(self) -> None:
|
||||
"""Close line file descriptor if currently open."""
|
||||
if self._line_fd >= 0:
|
||||
os.close(self._line_fd)
|
||||
self._line_fd = -1
|
||||
|
||||
def _close_chip_fd(self) -> None:
|
||||
"""Close chip file descriptor if currently open."""
|
||||
if self._chip_fd >= 0:
|
||||
os.close(self._chip_fd)
|
||||
self._chip_fd = -1
|
||||
|
||||
@@ -12,7 +12,12 @@ from python_app.models.run_config_schema import (
|
||||
PreprocessNotchModel,
|
||||
RunConfigModel,
|
||||
)
|
||||
from python_app.models.run_config_validation import load_ring_payload, load_switch_payload, validate_gpr_model
|
||||
from python_app.models.run_config_validation import (
|
||||
load_control_button_payload,
|
||||
load_ring_payload,
|
||||
load_switch_payload,
|
||||
validate_gpr_model,
|
||||
)
|
||||
|
||||
|
||||
def _as_dict(value: Any, context: str) -> dict[str, Any]:
|
||||
@@ -82,6 +87,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
switches_payload = _as_dict(payload.get("switches"), "switches")
|
||||
port1_payload = _as_dict(switches_payload.get("port1"), "switches.port1")
|
||||
port2_payload = _as_dict(switches_payload.get("port2"), "switches.port2")
|
||||
control_button_payload = _as_dict(payload.get("control_button"), "control_button")
|
||||
run_payload = _as_dict(payload.get("run"), "run")
|
||||
preprocess_payload = _as_dict(payload.get("preprocess"), "preprocess")
|
||||
gpr_payload = _as_dict(payload.get("gpr"), "gpr")
|
||||
@@ -245,6 +251,7 @@ def run_config_from_dict(payload: dict[str, Any]) -> RunConfigModel:
|
||||
|
||||
load_switch_payload(port1_payload, model.output_switch)
|
||||
load_switch_payload(port2_payload, model.input_switch)
|
||||
load_control_button_payload(control_button_payload, model.control_button)
|
||||
model.apply_device_model_constraints()
|
||||
|
||||
model.runtime.settling_ms = int(run_payload.get("settling_ms", model.runtime.settling_ms))
|
||||
@@ -477,6 +484,15 @@ def run_config_to_dict(model: RunConfigModel) -> dict[str, Any]:
|
||||
"invert_logic": model.input_switch.invert_logic,
|
||||
},
|
||||
},
|
||||
"control_button": {
|
||||
"enabled": model.control_button.enabled,
|
||||
"gpio_chip": model.control_button.gpio_chip,
|
||||
"pin": model.control_button.pin,
|
||||
"active_low": model.control_button.active_low,
|
||||
"bias": model.control_button.bias,
|
||||
"debounce_ms": model.control_button.debounce_ms,
|
||||
"action": model.control_button.action,
|
||||
},
|
||||
"run": {
|
||||
"settling_ms": model.runtime.settling_ms,
|
||||
"idle_sleep_ms": model.runtime.idle_sleep_ms,
|
||||
|
||||
@@ -126,6 +126,27 @@ class SwitchModel:
|
||||
invert_logic: bool = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ControlButtonModel:
|
||||
"""Physical GPIO push-button that triggers a runtime action on press.
|
||||
|
||||
Default wiring: the button sits between the GPIO line and GND with the
|
||||
internal pull-up enabled, so the line idles high and a press drives it low
|
||||
(``active_low``). The watcher reacts to the press edge only, so one push
|
||||
yields one action. Disabled by default so non-Pi hosts never touch GPIO.
|
||||
"""
|
||||
|
||||
ACTION_CAPTURE_TMP_REFERENCE = "capture_tmp_reference"
|
||||
|
||||
enabled: bool = False
|
||||
gpio_chip: str = "/dev/gpiochip0"
|
||||
pin: int = -1
|
||||
active_low: bool = True
|
||||
bias: str = ""
|
||||
debounce_ms: int = 50
|
||||
action: str = ACTION_CAPTURE_TMP_REFERENCE
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RingEndpointModel:
|
||||
"""Shared-memory ring endpoint description."""
|
||||
@@ -266,6 +287,7 @@ class RunConfigModel:
|
||||
preprocess: PreprocessModel = field(default_factory=PreprocessModel)
|
||||
gpr: GprModel = field(default_factory=GprModel)
|
||||
combos: list[ComboModel] = field(default_factory=list)
|
||||
control_button: ControlButtonModel = field(default_factory=ControlButtonModel)
|
||||
|
||||
LIBREVNA_MODEL = "librevna"
|
||||
LIBREVNA_MULTI_MODEL = "librevna_multi"
|
||||
|
||||
@@ -4,7 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from python_app.models.run_config_schema import ComboModel, GprModel, RingEndpointModel, SwitchModel
|
||||
from python_app.models.run_config_schema import (
|
||||
ComboModel,
|
||||
ControlButtonModel,
|
||||
GprModel,
|
||||
RingEndpointModel,
|
||||
SwitchModel,
|
||||
)
|
||||
|
||||
def load_switch_payload(
|
||||
payload: dict[str, Any],
|
||||
@@ -23,6 +29,20 @@ def load_switch_payload(
|
||||
target.invert_logic = bool(payload.get("invert_logic", target.invert_logic))
|
||||
|
||||
|
||||
def load_control_button_payload(
|
||||
payload: dict[str, Any],
|
||||
target: ControlButtonModel,
|
||||
) -> None:
|
||||
"""Populate control-button model from payload preserving defaults."""
|
||||
target.enabled = bool(payload.get("enabled", target.enabled))
|
||||
target.gpio_chip = str(payload.get("gpio_chip", target.gpio_chip))
|
||||
target.pin = int(payload.get("pin", target.pin))
|
||||
target.active_low = bool(payload.get("active_low", target.active_low))
|
||||
target.bias = str(payload.get("bias", target.bias))
|
||||
target.debounce_ms = int(payload.get("debounce_ms", target.debounce_ms))
|
||||
target.action = str(payload.get("action", target.action))
|
||||
|
||||
|
||||
def load_ring_payload(payload: dict[str, Any], target: RingEndpointModel) -> None:
|
||||
"""Populate ring endpoint model from payload preserving defaults."""
|
||||
target.name = str(payload.get("name", target.name))
|
||||
|
||||
@@ -43,7 +43,10 @@ class ConfigWriter:
|
||||
def write(self, config: RunConfigModel, output_path: Path) -> Path:
|
||||
"""Write run configuration JSON file."""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps(config.to_dict(), indent=2), encoding="utf-8")
|
||||
# allow_nan=False: a stray NaN/Infinity must fail loudly here in Python
|
||||
# rather than serialize to a non-standard token that aborts every C++
|
||||
# consumer at startup with an opaque JSON parse error.
|
||||
output_path.write_text(json.dumps(config.to_dict(), indent=2, allow_nan=False), encoding="utf-8")
|
||||
return output_path
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,9 @@ class ManagedProcess:
|
||||
name: str
|
||||
command: list[str]
|
||||
allow_clean_exit: bool
|
||||
handle: subprocess.Popen[str]
|
||||
handle: subprocess.Popen[bytes]
|
||||
stdout_path: Path
|
||||
stderr_path: Path
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -141,30 +143,52 @@ class ProcessSupervisor:
|
||||
self._stop_processes(["sweep_orchestrator", "data_preprocessor", "data_processor"])
|
||||
|
||||
def _spawn(self, name: str, command: list[str], *, allow_clean_exit: bool) -> None:
|
||||
"""Spawn one process unless same process is already alive."""
|
||||
"""Spawn one process unless same process is already alive.
|
||||
|
||||
Child stdout/stderr are redirected to per-process log files rather than
|
||||
captured pipes: a long-running child (e.g. an acquisition producer waiting
|
||||
for its device) would otherwise fill the OS pipe buffer once nobody drains
|
||||
it and block on write. Files never back-pressure the child, and they keep
|
||||
a persistent log we can read for exit reports and tail for diagnostics.
|
||||
"""
|
||||
existing = self._processes.get(name)
|
||||
if existing is not None and existing.handle.poll() is None:
|
||||
return
|
||||
|
||||
logs_dir = self._project_root / "python_app/runtime/logs"
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
stdout_path = logs_dir / f"{name}.out.log"
|
||||
stderr_path = logs_dir / f"{name}.err.log"
|
||||
|
||||
stdout_file = open(stdout_path, "wb")
|
||||
stderr_file = open(stderr_path, "wb")
|
||||
try:
|
||||
handle = subprocess.Popen(
|
||||
command,
|
||||
cwd=self._project_root,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
stdout=stdout_file,
|
||||
stderr=stderr_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
stdout_file.close()
|
||||
stderr_file.close()
|
||||
command_text = shlex.join(command)
|
||||
raise RuntimeError(
|
||||
f"Failed to spawn {name} with command `{command_text}` from `{self._project_root}`: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
# The child holds its own dup'd fds; the parent's copies are not needed.
|
||||
stdout_file.close()
|
||||
stderr_file.close()
|
||||
|
||||
self._processes[name] = ManagedProcess(
|
||||
name=name,
|
||||
command=command,
|
||||
allow_clean_exit=allow_clean_exit,
|
||||
handle=handle,
|
||||
stdout_path=stdout_path,
|
||||
stderr_path=stderr_path,
|
||||
)
|
||||
|
||||
def _acquisition_command(self, config_path: Path) -> list[str]:
|
||||
@@ -236,6 +260,25 @@ class ProcessSupervisor:
|
||||
for name in exited_names:
|
||||
self._processes.pop(name, None)
|
||||
|
||||
@staticmethod
|
||||
def _read_log_tail(path: Path, max_bytes: int = 16384) -> str:
|
||||
"""Return the trailing `max_bytes` of a child log file, decoded best-effort.
|
||||
|
||||
Bounded so a large/long-lived log never produces an enormous exit report.
|
||||
"""
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
handle.seek(0, 2)
|
||||
size = handle.tell()
|
||||
if size > max_bytes:
|
||||
handle.seek(-max_bytes, 2)
|
||||
else:
|
||||
handle.seek(0)
|
||||
data = handle.read()
|
||||
except OSError:
|
||||
return ""
|
||||
return data.decode("utf-8", errors="replace").strip()
|
||||
|
||||
def _is_alive(self, name: str) -> bool:
|
||||
"""Return `True` when named process handle exists and is running."""
|
||||
process = self._processes.get(name)
|
||||
@@ -253,12 +296,8 @@ class ProcessSupervisor:
|
||||
if return_code is None:
|
||||
continue
|
||||
|
||||
stderr = ""
|
||||
stdout = ""
|
||||
if process.handle.stdout is not None:
|
||||
stdout = process.handle.stdout.read().strip()
|
||||
if process.handle.stderr is not None:
|
||||
stderr = process.handle.stderr.read().strip()
|
||||
stdout = self._read_log_tail(process.stdout_path)
|
||||
stderr = self._read_log_tail(process.stderr_path)
|
||||
|
||||
reports.append(
|
||||
ProcessExitReport(
|
||||
|
||||
@@ -17,27 +17,64 @@ from python_app.storage.npz.serialize import RAW_MAGIC, serialize_trace_collecti
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum number of acquisitions allowed to fail in a row before we give up and
|
||||
# let the supervisor restart the whole process. Picked high enough to survive
|
||||
# transient USB stalls (each retry triggers a full reset cycle of ~1-2s) but
|
||||
# bounded so a permanently broken device does not loop forever.
|
||||
_MAX_CONSECUTIVE_ACQUIRE_FAILURES = 20
|
||||
# Cooldown applied between a failed acquire and the next reset attempt. Stops
|
||||
# us from busy-spinning when the device keeps refusing to come back.
|
||||
_ACQUIRE_FAILURE_COOLDOWN_S = 1.0
|
||||
# The producer waits for the matrix radar forever: a device that is absent at boot
|
||||
# or disappears mid-run must never kill the producer, only make it wait. Reconnect
|
||||
# uses a capped exponential backoff so a long absence does not busy-spin, and every
|
||||
# wait is interruptible by SIGINT/SIGTERM (stop_requested) for a prompt clean exit.
|
||||
_OPEN_RETRY_MIN_S = 1.0
|
||||
_OPEN_RETRY_MAX_S = 10.0
|
||||
# Throttle open-failure logging during a long wait so a permanently absent device
|
||||
# does not flood the process log: log the first failure, then every Nth attempt.
|
||||
_OPEN_RETRY_LOG_EVERY = 30
|
||||
|
||||
|
||||
def _reset_radar_service(
|
||||
config: RunConfigModel, previous: MatrixRadarService | None
|
||||
) -> MatrixRadarService:
|
||||
"""Close `previous` (best-effort) and return a freshly opened+configured service."""
|
||||
def _open_radar_with_retry(
|
||||
config: RunConfigModel,
|
||||
previous: MatrixRadarService | None,
|
||||
stop_requested: threading.Event,
|
||||
) -> MatrixRadarService | None:
|
||||
"""Open+configure the matrix radar, retrying forever until success or stop.
|
||||
|
||||
Used for both the initial open and every in-loop reconnect, so a device that is
|
||||
absent at boot or disappears mid-run never kills the producer — it just waits.
|
||||
Returns the opened service, or ``None`` if a stop was requested before any device
|
||||
became available. Backoff is capped and every wait is interruptible by SIGTERM.
|
||||
"""
|
||||
if previous is not None:
|
||||
with suppress(Exception):
|
||||
previous.close()
|
||||
radar = create_matrix_radar_service(config)
|
||||
radar.open()
|
||||
radar.configure(config.radar.sweep)
|
||||
return radar
|
||||
|
||||
attempt = 0
|
||||
delay = _OPEN_RETRY_MIN_S
|
||||
while not stop_requested.is_set():
|
||||
radar = create_matrix_radar_service(config)
|
||||
try:
|
||||
radar.open()
|
||||
radar.configure(config.radar.sweep)
|
||||
except Exception as exc: # noqa: BLE001 — waiting for the device is the point
|
||||
with suppress(Exception):
|
||||
radar.close() # drop any partial open before the next attempt
|
||||
attempt += 1
|
||||
if attempt == 1 or attempt % _OPEN_RETRY_LOG_EVERY == 0:
|
||||
logger.warning(
|
||||
"Matrix radar not available (attempt %d); retrying every up to %.0fs "
|
||||
"until the device is present: %s",
|
||||
attempt,
|
||||
_OPEN_RETRY_MAX_S,
|
||||
exc,
|
||||
)
|
||||
if stop_requested.wait(delay):
|
||||
with suppress(Exception):
|
||||
radar.close()
|
||||
return None
|
||||
delay = min(delay * 2.0, _OPEN_RETRY_MAX_S)
|
||||
continue
|
||||
|
||||
if attempt > 0:
|
||||
logger.info("Matrix radar opened after %d attempt(s).", attempt + 1)
|
||||
return radar
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -75,52 +112,26 @@ def main() -> int:
|
||||
)
|
||||
|
||||
radar: MatrixRadarService | None = None
|
||||
consecutive_failures = 0
|
||||
try:
|
||||
radar = _reset_radar_service(config, previous=None)
|
||||
radar = _open_radar_with_retry(config, previous=None, stop_requested=stop_requested)
|
||||
if radar is None:
|
||||
return 0 # asked to stop before a device became available
|
||||
collection_id = 1
|
||||
while not stop_requested.is_set():
|
||||
collection_start = time.monotonic()
|
||||
try:
|
||||
if radar is None:
|
||||
radar = _reset_radar_service(config, previous=None)
|
||||
collection = radar.acquire_collection(collection_id=collection_id)
|
||||
except Exception as exc: # noqa: BLE001 — top-level recovery is the point
|
||||
consecutive_failures += 1
|
||||
if consecutive_failures > _MAX_CONSECUTIVE_ACQUIRE_FAILURES:
|
||||
logger.error(
|
||||
"Matrix radar acquisition failed %d times in a row; giving up. "
|
||||
"Last error: %s",
|
||||
consecutive_failures - 1,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — reconnect forever, never give up
|
||||
logger.warning(
|
||||
"Matrix radar acquisition failed (%d/%d), resetting service: %s",
|
||||
consecutive_failures,
|
||||
_MAX_CONSECUTIVE_ACQUIRE_FAILURES,
|
||||
"Matrix radar acquisition failed; reconnecting and waiting for the device: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
# Cooldown gives slow USB stacks (and the device firmware) time
|
||||
# to settle before the next open() attempt.
|
||||
if stop_requested.wait(_ACQUIRE_FAILURE_COOLDOWN_S):
|
||||
break
|
||||
try:
|
||||
radar = _reset_radar_service(config, previous=radar)
|
||||
except Exception as reset_exc: # noqa: BLE001
|
||||
logger.warning(
|
||||
"Matrix radar reset (%d/%d) failed, will retry: %s",
|
||||
consecutive_failures,
|
||||
_MAX_CONSECUTIVE_ACQUIRE_FAILURES,
|
||||
reset_exc,
|
||||
exc_info=True,
|
||||
)
|
||||
radar = None
|
||||
radar = _open_radar_with_retry(config, previous=radar, stop_requested=stop_requested)
|
||||
if radar is None:
|
||||
break # stop requested while waiting to reconnect
|
||||
continue
|
||||
|
||||
consecutive_failures = 0
|
||||
|
||||
payload = serialize_trace_collection(collection, RAW_MAGIC)
|
||||
if not raw_writer.push(payload):
|
||||
raise RuntimeError(
|
||||
|
||||
Reference in New Issue
Block a user