From 3d4e252438511085285629ddd3d992506a862a61 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Wed, 25 Mar 2026 13:23:20 +0100 Subject: [PATCH] Fixes to alert banner --- src/aare/gui/main_window.py | 33 ++++---- src/aare/gui/threads/daq_worker.py | 112 +++++++++++++++++++++------ src/aare/gui/widgets/alert_banner.py | 9 ++- 3 files changed, 105 insertions(+), 49 deletions(-) diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index 05069a1d..9d05a250 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -70,6 +70,7 @@ class MainWindow(QMainWindow): gonio_cam_id: int | None ): super().__init__() + self.__base_url = base_url self.__token = token self.__mounting = False @@ -78,8 +79,6 @@ class MainWindow(QMainWindow): self._controls_help_dialog = None self._cleanup_done = False self._default_window_state = None - self._last_status_message: str | None = None - self._last_status_is_error: bool | None = None # Tutorial manager (define tutorials after widgets exist) self._tutorial_event_bus = TutorialEventBus(self) @@ -115,9 +114,12 @@ class MainWindow(QMainWindow): root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) - self.alert_banner = AlertBanner(parent=root_widget) + self.alert_banner = AlertBanner(parent=root_widget, error_timeout_ms=30000, recover_timeout_ms=5000) root_layout.addWidget(self.alert_banner) + self.alert_banner_secondary = AlertBanner(parent=root_widget, error_timeout_ms=10000, recover_timeout_ms=5000) + root_layout.addWidget(self.alert_banner_secondary) + top_widget = QWidget(parent=root_widget) top_widget_layout = QHBoxLayout(top_widget) top_widget.setLayout(top_widget_layout) @@ -483,27 +485,18 @@ class MainWindow(QMainWindow): self.daq.fluorimeter_spectrum_update.connect(self.fluor_panel.update_plot) self.daq.fluorimeter_spectrum_update.connect(lambda: self.fluor_panel_dock.setVisible(True)) + # === Alert/Status Message Routing === + # Primary alert banner: Infrastructure devices (Server/Tell/Smargon/Aerotech) + self.daq.polled_devices_status.connect(self.alert_banner.show_message) + + # Secondary alert banner: Detector errors (JFJoch) + self.daq.detector_error.connect(self.alert_banner_secondary.show_message) + + # Status bar: General status messages (not device connection status) self.daq.status_message.connect(self.status_bar.show_connection_message) - self.daq.status_message.connect(self._show_status_message_once) register_tutorials(self, self.tutorial_manager) - @Slot(str, bool) - def _show_status_message_once(self, message: str, is_error: bool = True) -> None: - message = (message or "").strip() - if not message: - self.alert_banner.clear_message() - self._last_status_message = None - self._last_status_is_error = None - return - - if message == self._last_status_message and is_error == self._last_status_is_error: - return - - self._last_status_message = message - self._last_status_is_error = is_error - self.alert_banner.show_message(message, is_error=is_error) - @Slot(QPixmap) def _on_samcam_prediction_pixmap(self, pix: QPixmap) -> None: self._last_pred_image_ts = time.monotonic() diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index 0787d2c9..43cdc327 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -10,9 +10,10 @@ from jfjoch_client import ScanResult, ScanResultImagesInner from aare.common.coordinate import SmargonCoordinate, Coordinate, AerotechCoordinate from aare.common.error_codes import export_error_codes +from aare.common.exception_handler import JFJochCommunicationError from aare.common.models import DAQStatusModel, SampleShortInfoList, SampleShortInfo, SampleCameraSettings, \ AutofocusSettings, SimpleScanParameters, FluorescenceSpectrumParameterModel, FluorescenceSpectrumOutputModel -from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid +from aare.common.raster_grid import RasterGridRequest, CompletedRasterGrid, CompletedRasterGridElem from aare.common.rotation_scan import RotationScanRequest, CompletedRotationScan from aare.common.logger_config import setup_logger @@ -28,8 +29,13 @@ class DAQWorker(QObject): reference_tools = Signal(SampleShortInfoList) http_error = Signal(str) status_message = Signal(str, bool) + + # New dedicated signals for polled device errors and request-time errors + polled_devices_status = Signal(str, bool) # (message, is_error) + detector_error = Signal(str, bool) # (message, is_error) auth_error = Signal() sample_missing = Signal(str) + automated_scan_done = Signal(int, bool, str) # sample ID, success run_number_incremented = Signal() raster_scan_completed = Signal(CompletedRasterGrid) @@ -86,6 +92,17 @@ class DAQWorker(QObject): self._server_connected: bool | None = None self._last_server_error: str | None = None + self._has_seen_disconnection: bool = False + self._server_was_disconnected: bool = False + + # Deduplication tracking for polled device messages + self._last_polled_msg: str | None = None + self._last_polled_is_error: bool | None = None + + # Deduplication tracking for detector/request-time messages + self._last_detector_msg: str | None = None + self._last_detector_is_error: bool | None = None + self._face_detection_stream_reply: QNetworkReply | None = None if self.__base_url is not None: @@ -105,6 +122,46 @@ class DAQWorker(QObject): self.last_error_payload_changed.emit(self.get_last_error_payload()) self.last_error_payloads_changed.emit(self.get_last_error_payloads()) + def _emit_polled_device_message(self, msg: str, is_error: bool) -> None: + """Emit polled device status message with deduplication.""" + if (msg, is_error) == (self._last_polled_msg, self._last_polled_is_error): + return + self._last_polled_msg = msg + self._last_polled_is_error = is_error + self.polled_devices_status.emit(msg, is_error) + + def _emit_detector_message(self, msg: str, is_error: bool) -> None: + """Emit detector/request-time error message with deduplication.""" + if (msg, is_error) == (self._last_detector_msg, self._last_detector_is_error): + return + self._last_detector_msg = msg + self._last_detector_is_error = is_error + self.detector_error.emit(msg, is_error) + + def _emit_status_if_changed(self, key: str | None, message: str | None, is_error: bool) -> None: + """ + Emit polled device status to the primary alert banner. + Used for Server/Tell/Smargon/Aerotech connection status. + """ + if not message: + self._active_status_error_key = None + self._emit_polled_device_message("", False) + return + + if is_error: + self._has_seen_disconnection = True + if self._active_status_error_key != key: + self._active_status_error_key = key + self._emit_polled_device_message(message, True) + return + + if not self._has_seen_disconnection: + self._active_status_error_key = None + return + + self._active_status_error_key = None + self._emit_polled_device_message(message, False) + def _log_smargon_throttled(self, *, endpoint: str | None, message: str) -> None: """ Log immediately if endpoint/message changed; otherwise at most every N seconds. @@ -180,20 +237,6 @@ class DAQWorker(QObject): logger.error(f"Error in response: {reply.errorString()}") raise RuntimeError(reply.errorString()) - def _emit_status_if_changed(self, key: str | None, message: str | None, is_error: bool) -> None: - if not message: - self._active_status_error_key = None - return - - if is_error: - if self._active_status_error_key != key: - self._active_status_error_key = key - self.status_message.emit(message, True) - return - - self._active_status_error_key = None - self.status_message.emit(message, False) - def _compose_device_status_message( self, *, @@ -258,8 +301,15 @@ class DAQWorker(QObject): parsed_response = DAQStatusModel.model_validate_json(response_data) self.update.emit(parsed_response) - if self._server_connected is False: + # Handle server reconnection - show "Server reconnected" not device messages + if self._server_connected is False and self._has_seen_disconnection: self._emit_status_if_changed(None, "Server reconnected.", False) + # Reset device states so we don't also emit device reconnection messages + self._last_tell_connected = None + self._last_smargon_connected = None + self._last_aerotech_connected = None + self._server_was_disconnected = False + self._server_connected = True self._last_server_error = None @@ -274,20 +324,29 @@ class DAQWorker(QObject): smargon_err_text = None if smargon_err is None else str(smargon_err).strip() aerotech_err_text = None if aerotech_err is None else str(aerotech_err).strip() + # Skip device status processing if we just reconnected from server down + # (we already showed "Server reconnected") + if self._last_tell_connected is None and self._last_smargon_connected is None and self._last_aerotech_connected is None: + # First status after startup or server reconnect - just record states, don't emit + self._last_tell_connected = tell_conn + self._last_smargon_connected = smargon_conn + self._last_aerotech_connected = aerotech_conn + self._last_tell_error = tell_err_text + self._last_smargon_error = smargon_err_text + self._last_aerotech_error = aerotech_err_text + return + tell_changed = ( - self._last_tell_connected is None - or self._last_tell_connected != tell_conn - or self._last_tell_error != tell_err_text + self._last_tell_connected != tell_conn + or self._last_tell_error != tell_err_text ) smargon_changed = ( - self._last_smargon_connected is None - or self._last_smargon_connected != smargon_conn - or self._last_smargon_error != smargon_err_text + self._last_smargon_connected != smargon_conn + or self._last_smargon_error != smargon_err_text ) aerotech_changed = ( - self._last_aerotech_connected is None - or self._last_aerotech_connected != aerotech_conn - or self._last_aerotech_error != aerotech_err_text + self._last_aerotech_connected != aerotech_conn + or self._last_aerotech_error != aerotech_err_text ) self._last_tell_connected = tell_conn @@ -318,6 +377,8 @@ class DAQWorker(QObject): err_msg = str(e) if self._server_connected is not False: + self._has_seen_disconnection = True + self._server_was_disconnected = True self._emit_status_if_changed( "server-down", "Server disconnected. Reconnecting...", @@ -326,6 +387,7 @@ class DAQWorker(QObject): self._server_connected = False self._last_server_error = err_msg + # Clear device states so we show "Server reconnected" on recovery self._last_tell_connected = None self._last_smargon_connected = None self._last_aerotech_connected = None diff --git a/src/aare/gui/widgets/alert_banner.py b/src/aare/gui/widgets/alert_banner.py index 86e5c791..a0ea3f78 100644 --- a/src/aare/gui/widgets/alert_banner.py +++ b/src/aare/gui/widgets/alert_banner.py @@ -8,11 +8,13 @@ logger = setup_logger("aareGUI") class AlertBanner(QFrame): - def __init__(self, parent=None): + def __init__(self, parent=None, error_timeout_ms: int = 15000, recover_timeout_ms: int = 5000): super().__init__(parent) self._current_message: str | None = None self._current_is_error: bool | None = None + self._error_timeout_ms = error_timeout_ms + self._recovery_timeout_ms = recover_timeout_ms self._clear_timer = QTimer(self) self._clear_timer.setSingleShot(True) @@ -62,9 +64,8 @@ class AlertBanner(QFrame): " padding: 2px 6px 2px 6px;" "}" ) + self._clear_timer.start(self._error_timeout_ms) else: - if msg == self._current_message: - return decorated = f"✅ {msg} ✅" self.setStyleSheet( "QFrame {" @@ -80,7 +81,7 @@ class AlertBanner(QFrame): " padding: 2px 6px 2px 6px;" "}" ) - self._clear_timer.start(5000) + self._clear_timer.start(self._recovery_timeout_ms) self._current_message = msg self._current_is_error = is_error