some fixes

This commit is contained in:
Ayzen
2026-06-04 18:33:38 +03:00
parent eacea436a4
commit 22942d9dc9
26 changed files with 1352 additions and 153 deletions
+64 -4
View File
@@ -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)
+131
View File
@@ -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(
+7 -1
View File
@@ -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__":
+22 -4
View File
@@ -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
+17 -1
View File
@@ -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,
+22
View File
@@ -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"
+21 -1
View File
@@ -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))
+4 -1
View File
@@ -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
+50 -11
View File
@@ -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(
+61 -50
View File
@@ -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(