diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index a93a7bc2..42a5a0d8 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -64,6 +64,8 @@ class AareDAQ: self.__saved_box = None self._smargon_trace_path = Path("logs") / "smargon_trace.csv" self._face_detection_progress_cb: Callable[[dict], None] | None = None + self._last_sample_sync_ts = 0.0 + self._sample_sync_min_interval_s = 2.0 def set_face_detection_progress_callback(self, cb: Callable[[dict], None] | None) -> None: self._face_detection_progress_cb = cb @@ -109,6 +111,80 @@ class AareDAQ: except Exception as e: logger.warning(f"Failed to append smargon trace: {e}") + def _sample_matches_mounted_address( + self, + sample: SampleShortInfo | None, + mounted_address, + ) -> bool: + if sample is None or sample.location is None or mounted_address is None: + return False + return ( + sample.location.segment == mounted_address.puck.segment + and sample.location.pos == mounted_address.puck.pos + and sample.pin == mounted_address.pin + ) + + def _find_sample_by_mounted_address(self, mounted_address) -> SampleShortInfo | None: + for sample in self.__cfg.spreadsheet.s: + if self._sample_matches_mounted_address(sample, mounted_address): + return sample + + for sample in self.__cfg.reference_tools.s: + if self._sample_matches_mounted_address(sample, mounted_address): + return sample + + return None + + def _placeholder_sample_from_mounted_address(self, mounted_address) -> SampleShortInfo: + return SampleShortInfo( + db_id=-1, + puck_name="", + dewar_name="", + sample_name=f"Mounted sample {mounted_address.puck.segment}{mounted_address.puck.pos}-{mounted_address.pin}", + run_number=0, + user="", + pin=mounted_address.pin, + location=mounted_address.puck, + ) + + def sync_current_sample_from_tell(self, force: bool = False) -> SampleShortInfo | None: + current_sample = self.__cfg.current_sample + + if current_sample is not None and current_sample.location is None: + return current_sample + + now = time.monotonic() + if not force and (now - self._last_sample_sync_ts) < self._sample_sync_min_interval_s: + return current_sample + + self._last_sample_sync_ts = now + mounted_address = self.__devs.tell.get_mounted_sample() + + if mounted_address is None: + if current_sample is not None and current_sample.location is not None: + logger.warning("TELL reports no mounted sample; clearing cached current_sample") + self.__cfg.current_sample = None + return self.__cfg.current_sample + + if self._sample_matches_mounted_address(current_sample, mounted_address): + return current_sample + + resolved_sample = self._find_sample_by_mounted_address(mounted_address) + if resolved_sample is None: + resolved_sample = self._placeholder_sample_from_mounted_address(mounted_address) + logger.warning( + "Mounted sample from TELL was not found in known sample lists; using placeholder", + extra={"mounted_address": str(mounted_address)}, + ) + else: + logger.info( + f"Reconciled cached sample from TELL to {resolved_sample.sample_name}", + extra={"db_id": resolved_sample.db_id}, + ) + + self.__cfg.current_sample = resolved_sample + return resolved_sample + @property def state(self) -> BeamlineStateEnum: return self.__cfg.state @@ -272,11 +348,6 @@ class AareDAQ: @property def sample(self) -> SampleShortInfo | None: - #TODO figure out why this works and what it should be - if self.__cfg.current_sample is not None and self.__cfg.current_sample.location is None: - return self.__cfg.current_sample - if self.__devs.tell.get_mounted_sample() is None: - return None return self.__cfg.current_sample @property @@ -347,6 +418,7 @@ class AareDAQ: self.__devs.tell.set_in_mount_position(True) if target is None: self.__devs.tell.unmount(wait=True, timeout=60.0) + self.__cfg.current_sample = None else: value = self.__devs.tell.mount(address=target.tell_address(), force=True, auto_unmount=True, read_dm=False, wait=True, timeout=360.0) @@ -1553,12 +1625,12 @@ class AareDAQ: Return (sample, tell_connected, tell_error) without raising. """ try: - return self.sample, True, None + return self.sync_current_sample_from_tell(), True, None except TellCommunicationError as e: - return None, False, str(e) + return self.__cfg.current_sample, False, str(e) except Exception as e: # Keep status flowing even if Tell code throws something unexpected - return None, False, f"TELL unavailable: {e}" + return self.__cfg.current_sample, False, f"TELL unavailable: {e}" def _safe_geom(self) -> tuple[SampleGeometryModel, bool, str | None]: """ diff --git a/src/aare/daq/server.py b/src/aare/daq/server.py index e0a097ef..45f57d4f 100644 --- a/src/aare/daq/server.py +++ b/src/aare/daq/server.py @@ -34,7 +34,7 @@ from aare.common.exception_handler import ( SampleException, UserRightsException, ) - +logger = setup_logger("aareDAQ") app = FastAPI() register_exception_handlers(app) @@ -45,10 +45,14 @@ bl = mx_beamline() cfg = BeamlineConfig(bl) daq = AareDAQ(cfg, bl) +try: + daq.sync_current_sample_from_tell(force=True) +except Exception as e: + logger.warning(f"Initial sample sync from TELL failed: {e}") + _all_pgroups_cache: dict[str, tuple[list[str], float]] = {} _ALL_PGROUPS_TTL_S = 60.0 # adjust TTL as needed -logger = setup_logger("aareDAQ") _face_detection_state: dict = { "seq": 0, "running": False, @@ -355,6 +359,16 @@ async def manual(s: SampleShortInfo, token: str = Depends(oauth2_scheme)): daq.create_sample(s) print(f"DB ID after creating {s.db_id}") +@app.post("/sample/resync") +async def sample_resync(token: str = Depends(oauth2_scheme)) -> dict: + auth.check_jwt_rw(cfg, auth.parse_token(token)) + daq.sync_current_sample_from_tell(force=True) + logger.info("TELL sample cache resynced via API request.") + return { + "ok": True, + "message": "TELL sample cache resynced.", + } + def get_spreadsheet(data: TokenData) -> SampleShortInfoList: if data.staff: @@ -454,7 +468,7 @@ async def maintenance(token: str = Depends(oauth2_scheme)) -> str: @app.post("/access/take_over_beamline") async def take_over_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> str: data = auth.parse_token(token) - auth.check_jwt_staff(cfg, data) + auth.check_jwt_staff_only(data) _validate_recovery_code(payload.confirmation_code) auth.force_current_sesion(cfg, data) logger.warning( @@ -466,7 +480,7 @@ async def take_over_beamline(payload: RecoveryActionRequest, token: str = Depend @app.post("/state/free_beamline") async def free_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> str: data = auth.parse_token(token) - auth.check_jwt_staff(cfg, data) + auth.check_jwt_staff_only(data) _validate_recovery_code(payload.confirmation_code) cfg.state_busy = False logger.warning( @@ -478,7 +492,7 @@ async def free_beamline(payload: RecoveryActionRequest, token: str = Depends(oau @app.post("/recovery/recover_beamline") async def recover_beamline(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> dict: data = auth.parse_token(token) - auth.check_jwt_staff(cfg, data) + auth.check_jwt_staff_only(data) _validate_recovery_code(payload.confirmation_code) sample_mounted = _sample_is_mounted() @@ -509,7 +523,7 @@ async def recover_beamline(payload: RecoveryActionRequest, token: str = Depends( @app.post("/recovery/unmount_sample") async def recovery_unmount_sample(payload: RecoveryActionRequest, token: str = Depends(oauth2_scheme)) -> dict: data = auth.parse_token(token) - auth.check_jwt_staff(cfg, data) + auth.check_jwt_staff_only(data) _validate_recovery_code(payload.confirmation_code) auth.force_current_sesion(cfg, data) diff --git a/src/aare/gui/panels/beamline_recovery_panel.py b/src/aare/gui/panels/beamline_recovery_panel.py index 6f9afa13..4891e9e0 100644 --- a/src/aare/gui/panels/beamline_recovery_panel.py +++ b/src/aare/gui/panels/beamline_recovery_panel.py @@ -72,6 +72,20 @@ class RecoveryPanel(QWidget): ) layout.addWidget(self._status) + self._last_action = QLabel("Last action: -", self) + self._last_action.setWordWrap(True) + self._last_action.setStyleSheet( + "QLabel {" + " background: #eef6ff;" + " color: #12406a;" + " border: 1px solid #a8c7e6;" + " border-radius: 6px;" + " padding: 8px;" + " font-weight: 600;" + "}" + ) + layout.addWidget(self._last_action) + self._take_over_btn = QPushButton("Take over beamline", self) self._take_over_btn.setStyleSheet( "QPushButton {" @@ -126,9 +140,24 @@ class RecoveryPanel(QWidget): self._recovery_unmount_btn.clicked.connect(self._recovery_unmount_sample) layout.addWidget(self._recovery_unmount_btn) + self._resync_sample_btn = QPushButton("Resync sample from TELL", self) + self._resync_sample_btn.setStyleSheet( + "QPushButton {" + " background: #eef6ff;" + " color: #12406a;" + " border: 1px solid #a8c7e6;" + " border-radius: 6px;" + " padding: 8px;" + " font-weight: 600;" + "}" + ) + self._resync_sample_btn.clicked.connect(self._resync_sample) + layout.addWidget(self._resync_sample_btn) + layout.addStretch(1) self._daq.update.connect(self._set_daq_status) + self._daq.sample_resync_completed.connect(self._set_last_action) self._refresh_buttons() def _prompt_recovery_code(self, action_name: str) -> str | None: @@ -185,6 +214,9 @@ class RecoveryPanel(QWidget): "" if beamline_busy else "Disabled because beamline does not currently appear busy." ) + self._resync_sample_btn.setEnabled(True) + self._resync_sample_btn.setToolTip("Force a one-shot sample reconciliation against TELL.") + def _confirm(self, title: str, msg: str) -> bool: reply = QMessageBox.warning( self, @@ -211,6 +243,7 @@ class RecoveryPanel(QWidget): code = self._prompt_recovery_code("Take over beamline") if not code: return + self._last_action.setText("Last action: Taking over beamline session...") self._daq.take_over_beamline(code) @Slot() @@ -223,6 +256,7 @@ class RecoveryPanel(QWidget): code = self._prompt_recovery_code("Free beamline") if not code: return + self._last_action.setText("Last action: Clearing beamline busy flag...") self._daq.free_beamline(code) @Slot() @@ -246,6 +280,7 @@ class RecoveryPanel(QWidget): code = self._prompt_recovery_code("Recover beamline") if not code: return + self._last_action.setText("Last action: Recovering beamline to Maintenance...") self._daq.recover_beamline(code) @Slot() @@ -260,8 +295,18 @@ class RecoveryPanel(QWidget): code = self._prompt_recovery_code("Unmount sample (recovery)") if not code: return + self._last_action.setText("Last action: Performing recovery sample unmount...") self._daq.recovery_unmount_sample(code) + @Slot() + def _resync_sample(self) -> None: + self._last_action.setText("Last action: Resyncing sample cache from TELL...") + self._daq.resync_sample() + + @Slot(str) + def _set_last_action(self, message: str) -> None: + self._last_action.setText(f"Last action: {message}") + class BeamlineRecoveryDialog(QDialog): def __init__(self, *, daq: DAQWorker, parent=None): diff --git a/src/aare/gui/threads/daq_worker.py b/src/aare/gui/threads/daq_worker.py index d54877e0..16f77b8d 100644 --- a/src/aare/gui/threads/daq_worker.py +++ b/src/aare/gui/threads/daq_worker.py @@ -39,6 +39,7 @@ class DAQWorker(QObject): staff_pgroups_loaded = Signal(list) fluorimeter_update = Signal(list, list, int) fluorimeter_spectrum_update = Signal(FluorescenceSpectrumOutputModel) + sample_resync_completed = Signal(str) error_codes_loaded = Signal(dict) last_error_payload_changed = Signal(dict) @@ -361,6 +362,32 @@ class DAQWorker(QObject): reply.deleteLater() + def _handle_sample_resync_response(self, reply: QNetworkReply): + try: + response_data = self.handle_response(reply) + payload = json.loads(response_data) if response_data else {} + message = str(payload.get("message") or "TELL sample cache resynced.") + logger.info(message) + self.status_message.emit(message, False) + self.sample_resync_completed.emit(message) + self.send_status_request() + except Exception as e: + logger.error(f"Sample resync failed: {e}") + self.http_error.emit(str(e)) + + def _handle_recovery_action_response(self, reply: QNetworkReply, default_message: str): + try: + response_data = self.handle_response(reply) + payload = json.loads(response_data) if response_data else {} + message = str(payload.get("message") or default_message) + logger.info(message) + self.status_message.emit(message, False) + self.recovery_action_completed.emit(message) + self.send_status_request() + except Exception as e: + logger.error(f"Recovery action failed: {e}") + self.http_error.emit(str(e)) + def generic_post(self, url: str, body: str = ""): if self.__base_url is None: logger.info(f"POST /{url}: {body}") @@ -456,30 +483,62 @@ class DAQWorker(QObject): @Slot(str) def free_beamline(self, confirmation_code: str): - self.generic_post( - "state/free_beamline", - json.dumps({"confirmation_code": confirmation_code}), + if self.__base_url is None: + logger.info("POST /state/free_beamline") + return + + request = QNetworkRequest(QUrl(f"{self.__base_url}/state/free_beamline")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + request.setRawHeader(b"Content-Type", b"application/json") + body = json.dumps({"confirmation_code": confirmation_code}) + reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8"))) + reply.finished.connect( + lambda: self._handle_recovery_action_response(reply, "Beamline busy flag cleared.") ) @Slot(str) def take_over_beamline(self, confirmation_code: str): - self.generic_post( - "access/take_over_beamline", - json.dumps({"confirmation_code": confirmation_code}), + if self.__base_url is None: + logger.info("POST /access/take_over_beamline") + return + + request = QNetworkRequest(QUrl(f"{self.__base_url}/access/take_over_beamline")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + request.setRawHeader(b"Content-Type", b"application/json") + body = json.dumps({"confirmation_code": confirmation_code}) + reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8"))) + reply.finished.connect( + lambda: self._handle_recovery_action_response(reply, "Beamline session taken over.") ) @Slot(str) def recover_beamline(self, confirmation_code: str): - self.generic_post( - "recovery/recover_beamline", - json.dumps({"confirmation_code": confirmation_code}), + if self.__base_url is None: + logger.info("POST /recovery/recover_beamline") + return + + request = QNetworkRequest(QUrl(f"{self.__base_url}/recovery/recover_beamline")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + request.setRawHeader(b"Content-Type", b"application/json") + body = json.dumps({"confirmation_code": confirmation_code}) + reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8"))) + reply.finished.connect( + lambda: self._handle_recovery_action_response(reply, "Beamline recovered to Maintenance.") ) @Slot(str) def recovery_unmount_sample(self, confirmation_code: str): - self.generic_post( - "recovery/unmount_sample", - json.dumps({"confirmation_code": confirmation_code}), + if self.__base_url is None: + logger.info("POST /recovery/unmount_sample") + return + + request = QNetworkRequest(QUrl(f"{self.__base_url}/recovery/unmount_sample")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + request.setRawHeader(b"Content-Type", b"application/json") + body = json.dumps({"confirmation_code": confirmation_code}) + reply = self.__net_manager.post(request, QByteArray(body.encode("utf-8"))) + reply.finished.connect( + lambda: self._handle_recovery_action_response(reply, "Recovery unmount completed.") ) @Slot(str) @@ -732,6 +791,18 @@ class DAQWorker(QObject): def beam_size_mm(self, x: float, y: float): self.generic_post(f"beamline/beam_size_mm?x={x}&y={y}") + @Slot() + def resync_sample(self): + if self.__base_url is None: + logger.info("POST /sample/resync") + return + + request = QNetworkRequest(QUrl(f"{self.__base_url}/sample/resync")) + request.setRawHeader(b"Authorization", f"Bearer {self.__token}".encode("utf-8")) + request.setRawHeader(b"Content-Type", b"application/json") + reply = self.__net_manager.post(request, QByteArray(b"")) + reply.finished.connect(lambda: self._handle_sample_resync_response(reply)) + @Slot() def unmount(self): self.generic_post("sample/unmount")