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)