diff --git a/src/aare/gui/main_window.py b/src/aare/gui/main_window.py index a8cb82ff..1094658f 100644 --- a/src/aare/gui/main_window.py +++ b/src/aare/gui/main_window.py @@ -1202,6 +1202,8 @@ class MainWindow(QMainWindow): self.daq.update.connect(self.sample_camera.update_daq_status) self.daq.update.connect(self.compact_sample_camera.update_daq_status) self.daq.update.connect(self.portrait_sample_camera.update_daq_status) + for camera in (self.sample_camera, self.compact_sample_camera, self.portrait_sample_camera): + self.daq.auto_centering.connect(camera.set_auto_centering) self.daq.update.connect(self.tell_samples.update_daq_status) self.daq.update.connect(self.ref_tools_panel.update_daq_status) if self.prediction_thread is not None: diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index 9f60f9af..81579a2b 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -84,6 +84,9 @@ class DAQWorker(QObject): http_error = Signal(str) status_message = Signal(str, bool) automation_progress = Signal(object) + # True while a loop centering runs — /status alone cannot tell it from + # a manual alignment move (see build_busy_overlay_style). + auto_centering = Signal(bool) gui_sessions_loaded = Signal(list) gui_close_requested = Signal(int, int, str) recovery_action_completed = Signal(str) @@ -154,6 +157,10 @@ class DAQWorker(QObject): self._timer.start() self._counter = 0 self._automation_progress_buffer = "" + # Two centering sources OR-ed into auto_centering: our own + # /alc/center_loop POST in flight, and the automation Center step. + self._manual_centering = False + self._automation_centering = False self._cleanup_done = False self._last_auth_error_log_ts = 0.0 @@ -747,7 +754,7 @@ class DAQWorker(QObject): """ if self._base_url is None: logger.info(f"POST /{url}: {body}") - return + return None request = QNetworkRequest(QUrl(f"{self._base_url}/{url}")) request.setRawHeader(b"Authorization", f"Bearer {self._token}".encode()) @@ -755,6 +762,7 @@ class DAQWorker(QObject): request.setRawHeader(b"Content-Type", b"application/json") reply = self._net_manager.post(request, QByteArray(body.encode("utf-8"))) reply.finished.connect(lambda: self.handle_req_response(reply)) + return reply def generic_put(self, url: str, body: str = ""): """ @@ -844,7 +852,20 @@ class DAQWorker(QObject): @Slot() def center_loop(self): - self.generic_post("alc/center_loop") + # The endpoint blocks server-side until centering ends, so the reply + # doubles as the "done" edge for the camera overlay. + reply = self.generic_post("alc/center_loop") + if reply is None: + return + self._set_centering(manual=True) + reply.finished.connect(lambda: self._set_centering(manual=False)) + + def _set_centering(self, *, manual: bool | None = None, automation: bool | None = None): + if manual is not None: + self._manual_centering = manual + if automation is not None: + self._automation_centering = automation + self.auto_centering.emit(self._manual_centering or self._automation_centering) @Slot() def force_session(self): @@ -1968,6 +1989,13 @@ class DAQWorker(QObject): progress = self._parse_automation_progress(progress_payload) self.automation_progress.emit(progress) self._emit_automation_progress_events(progress) + self._set_centering( + automation=not progress.finished + and any( + step.step == WorkflowStateKind.LOOP_CENTRE and step.status == StepStatus.RUNNING + for step in progress.steps + ) + ) current = progress.current_step or "Idle" if progress.finished: diff --git a/src/aare/gui/widgets/busy_overlay.py b/src/aare/gui/widgets/busy_overlay.py index 3a0d11e7..cbf356b7 100644 --- a/src/aare/gui/widgets/busy_overlay.py +++ b/src/aare/gui/widgets/busy_overlay.py @@ -17,6 +17,9 @@ from aare.gui.styles import ( BUSY_PSI_RED, BUSY_PSI_RED_BORDER, BUSY_PSI_RED_DOT, + BUSY_PURPLE, + BUSY_PURPLE_BORDER, + BUSY_PURPLE_DOT, BUSY_RED_BADGE, BUSY_RED_BORDER, BUSY_RED_DOT, @@ -174,6 +177,7 @@ def build_busy_overlay_style( is_busy: bool, tell_state: TellStateModel | None, session_state: SessionsStateEnum | None = None, + auto_centering: bool = False, ) -> BusyOverlayStyle | None: if session_state in { SessionsStateEnum.OwnedByElse, @@ -190,6 +194,24 @@ def build_busy_overlay_style( accent_dot=BUSY_YELLOW_DOT, subtext="Grab the baton if you need to interact with GUI", ) + + # Auto loop centering is busy + SampleAlignment in /status — the exact + # combo the callers hide (manual omega moves look identical), so it + # gets its own flag: the GUI knows it POSTed /alc/center_loop, or the + # automation progress says the Center step is running. Checked before + # is_busy because the callers pass is_busy=False during alignment. + if auto_centering: + return BusyOverlayStyle( + text="AUTO CENTERING", + badge_bg=BUSY_PURPLE, + badge_fg=WHITE, + overlay_fill=qcolor(BUSY_PURPLE, 190), + overlay_border=qcolor(BUSY_PURPLE_BORDER, 235), + overlay_text=qcolor(WHITE), + accent_dot=BUSY_PURPLE_DOT, + animate=True, + ) + if not is_busy: return None diff --git a/src/aare/gui/widgets/camera_image.py b/src/aare/gui/widgets/camera_image.py index c57482ca..d4093c74 100644 --- a/src/aare/gui/widgets/camera_image.py +++ b/src/aare/gui/widgets/camera_image.py @@ -130,6 +130,7 @@ class SampleCameraImageLabel(QGraphicsView): self._last_grid_update_ts = 0.0 self._grid_update_min_interval_s = 1.0 / 25.0 self._tell_state = None + self._auto_centering = False self._busy_overlay_style: BusyOverlayStyle | None = None self._geom = geom @@ -295,6 +296,12 @@ class SampleCameraImageLabel(QGraphicsView): self._smoothed_target_point = None self.update() + @Slot(bool) + def set_auto_centering(self, active: bool) -> None: + # ponytail: only stored — the overlay style is rebuilt on the next + # /status tick (500 ms), well within the seconds a centering takes. + self._auto_centering = active + def _busy_overlay_text(self) -> str: tell_state = self._tell_state if tell_state is None: @@ -843,6 +850,7 @@ class SampleCameraImageLabel(QGraphicsView): is_busy=bool(s.busy) and s.state != BeamlineStateEnum.SampleAlignment, tell_state=s.tell_state, session_state=self._session_state, + auto_centering=self._auto_centering, ) if new_busy_style != self._busy_overlay_style: self._busy_overlay_style = new_busy_style diff --git a/tests/unit/gui/test_busy_overlay.py b/tests/unit/gui/test_busy_overlay.py index 2b83d71c..6a71154b 100644 --- a/tests/unit/gui/test_busy_overlay.py +++ b/tests/unit/gui/test_busy_overlay.py @@ -2,6 +2,7 @@ the badge pill (which reads as a button). These checks fail if the text renderer stops painting or the video view stops routing through it.""" +from aarecommon.models.models import SessionsStateEnum from PySide6.QtCore import Qt from PySide6.QtGui import QPainter, QPixmap @@ -34,3 +35,18 @@ def test_video_view_paints_busy_text(qtbot): assert view.grab().toImage() != idle, "busy style must change the rendered view" view.set_busy_overlay_style(None) assert view.grab().toImage() == idle, "clearing the style must restore the view" + + +def test_auto_centering_flag_overrides_alignment_gate(): + # Callers pass is_busy=False during SampleAlignment; the flag must still + # produce the overlay, and the viewing-mode badge must still win. + style = build_busy_overlay_style(is_busy=False, tell_state=None, auto_centering=True) + assert style is not None and style.text == "AUTO CENTERING" + assert build_busy_overlay_style(is_busy=False, tell_state=None) is None + viewing = build_busy_overlay_style( + is_busy=False, + tell_state=None, + session_state=SessionsStateEnum.OwnedByElse, + auto_centering=True, + ) + assert viewing is not None and viewing.text.startswith("Viewing mode")