From a82a083c6704579b12836ddd87c3f0a53679e8a6 Mon Sep 17 00:00:00 2001 From: appleb_m Date: Mon, 4 May 2026 17:01:07 +0200 Subject: [PATCH] DAQ: added tell_state handler that creates states based on sse stream from tell in redis. Currently not used in logc, tracking for debugging before implementing. --- src/aare/common/models.py | 2 + src/aare/common/tell_models.py | 62 ++++ src/aare/daq/daq.py | 25 +- src/aare/daq/tell_state_machine.py | 364 ++++++++++++++++++++++ src/aare/daq/tellupdater.py | 71 ++++- src/aare/devices/tell_client.py | 1 + src/aare/gui/panels/tell_sample_panel.py | 36 ++- tests/unit/daq/test_tell_state_updater.py | 135 ++++++++ 8 files changed, 675 insertions(+), 21 deletions(-) create mode 100644 src/aare/common/tell_models.py create mode 100644 src/aare/daq/tell_state_machine.py create mode 100644 tests/unit/daq/test_tell_state_updater.py diff --git a/src/aare/common/models.py b/src/aare/common/models.py index 39d8d979..bfad1976 100644 --- a/src/aare/common/models.py +++ b/src/aare/common/models.py @@ -11,6 +11,7 @@ from aare.common.sample_geometry import SampleGeometryModel from jfjoch_client.models.scan_result import ScanResult from aare.common.beamline import MXBeamline +from aare.common.tell_models import TellStateModel class StagePositionEnum(Enum): @@ -773,6 +774,7 @@ class DAQStatusModel(BaseModel): tell_connected: bool = True tell_error: str | None = None + tell_state: TellStateModel | None = None smargon_connected: bool = True smargon_error: str | None = None diff --git a/src/aare/common/tell_models.py b/src/aare/common/tell_models.py new file mode 100644 index 00000000..e52b98e8 --- /dev/null +++ b/src/aare/common/tell_models.py @@ -0,0 +1,62 @@ +from enum import Enum + +from pydantic import BaseModel + + +class TellActivityEnum(str, Enum): + IDLE = "idle" + MOUNTING = "mounting" + UNMOUNTING = "unmounting" + DRYING = "drying" + COOLING = "cooling" + ERROR = "error" + + def display_name(self) -> str: + return { + TellActivityEnum.IDLE: "Idle", + TellActivityEnum.MOUNTING: "Mounting", + TellActivityEnum.UNMOUNTING: "Unmounting", + TellActivityEnum.DRYING: "Drying", + TellActivityEnum.COOLING: "Cooling", + TellActivityEnum.ERROR: "Error", + }.get(self, str(self.value).capitalize()) + + +class TellPhaseEnum(str, Enum): + IDLE = "idle" + PREPARING = "preparing" + AUTO_UNMOUNT = "auto_unmount" + RETURNING_OLD_SAMPLE = "returning_old_sample" + OLD_SAMPLE_RETURNED = "old_sample_returned" + PICKING_NEW_SAMPLE = "picking_new_sample" + PLACING_NEW_SAMPLE = "placing_new_sample" + FINALIZING = "finalizing" + COMPLETE = "complete" + FAILED = "failed" + + def display_name(self) -> str: + return { + TellPhaseEnum.IDLE: "Idle", + TellPhaseEnum.PREPARING: "Preparing", + TellPhaseEnum.AUTO_UNMOUNT: "Auto-unmount", + TellPhaseEnum.RETURNING_OLD_SAMPLE: "Returning old sample", + TellPhaseEnum.OLD_SAMPLE_RETURNED: "Old sample returned", + TellPhaseEnum.PICKING_NEW_SAMPLE: "Picking new sample", + TellPhaseEnum.PLACING_NEW_SAMPLE: "Placing sample on gonio", + TellPhaseEnum.FINALIZING: "Finalizing", + TellPhaseEnum.COMPLETE: "Complete", + TellPhaseEnum.FAILED: "Failed", + }.get(self, str(self.value).replace("_", " ").capitalize()) + + +class TellStateModel(BaseModel): + activity: TellActivityEnum = TellActivityEnum.IDLE + message: str | None = None + last_event_class: str | None = None + last_event_value: str | None = None + last_update_ts: str | None = None + mount_success: bool | None = None + mount_error: str | None = None + sample_position: str | None = None + operation: str | None = None + phase: TellPhaseEnum | None = None \ No newline at end of file diff --git a/src/aare/daq/daq.py b/src/aare/daq/daq.py index 8d4d0885..3a32d70d 100644 --- a/src/aare/daq/daq.py +++ b/src/aare/daq/daq.py @@ -12,6 +12,7 @@ from aareDB import SampleEventType from jfjoch_client.exceptions import NotFoundException from jfjoch_client import ScanResult, ScanResultImagesInner +from aare.common.tell_models import TellStateModel from aare.daq import workflows from aare.daq.aaredb import AareWrapper @@ -3215,6 +3216,27 @@ class AareDAQ: dtz_max=1000, ) + def _safe_tell_state(self) -> TellStateModel | None: + try: + redis_client = getattr(self._AareDAQ__cfg, "_BeamlineConfig__client", None) + beamline_key = getattr(self._AareDAQ__cfg, "_BeamlineConfig__bl", None) + + if redis_client is None or beamline_key is None: + return None + + redis_key = f"{beamline_key}:tell_state" + raw_value = redis_client.get(redis_key) + if raw_value in (None, "", b""): + return None + + if isinstance(raw_value, bytes): + raw_value = raw_value.decode("utf-8") + + return TellStateModel.model_validate_json(str(raw_value)) + except Exception as e: + logger.debug(f"Failed to read tell_state from Redis: {e}") + return None + def _safe_diffraction_geometry(self) -> DiffractionGeometry: try: return self.diffraction_geometry @@ -3236,7 +3258,7 @@ class AareDAQ: def status(self) -> DAQStatusModel: safe_sample, tell_ok, tell_err = self._safe_sample() safe_geom, smargon_ok, smargon_err, aerotech_ok, aerotech_err = self._safe_geom() - + safe_tell_state = self._safe_tell_state() return DAQStatusModel( state=self.state, @@ -3256,6 +3278,7 @@ class AareDAQ: crystal_size=self.__cfg.crystal_size, tell_connected=tell_ok, tell_error=tell_err, + tell_state=safe_tell_state, smargon_connected=smargon_ok, smargon_error=smargon_err, aerotech_connected=aerotech_ok, diff --git a/src/aare/daq/tell_state_machine.py b/src/aare/daq/tell_state_machine.py new file mode 100644 index 00000000..8ed298c8 --- /dev/null +++ b/src/aare/daq/tell_state_machine.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import re +from datetime import datetime, timezone + +from aare.common.tell_models import TellActivityEnum, TellPhaseEnum, TellStateModel + +MOUNT_CMD_RE = re.compile(r'^mount\("([^"]+)",\s*(\d+),\s*(\d+)') +UNMOUNT_CMD_RE = re.compile(r"^unmount\(") +UNMOUNT_STATUS_RE = re.compile(r"^unmount:\s*") + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def initial_tell_state() -> TellStateModel: + return TellStateModel( + activity=TellActivityEnum.IDLE, + message="Ready", + operation="idle", + phase=TellPhaseEnum.IDLE, + last_update_ts=_utc_now_iso(), + ) + + +def _parse_sample_position_from_mount_command(value: str) -> str | None: + match = MOUNT_CMD_RE.match(value) + if match is None: + return None + + segment, puck, pin = match.groups() + return f"{segment}{int(puck)}-{int(pin)}" + + +def _update_state( + state: TellStateModel, + *, + activity: TellActivityEnum | None = None, + message: str | None = None, + event_name: str | None = None, + event_value: str | None = None, + operation: str | None = None, + phase: TellPhaseEnum | None = None, + sample_position: str | None = None, + mount_success: bool | None = None, + mount_error: str | None = None, +) -> TellStateModel: + updates = { + "last_update_ts": _utc_now_iso(), + } + + if activity is not None: + updates["activity"] = activity + if message is not None: + updates["message"] = message + if event_name is not None: + updates["last_event_class"] = event_name + if event_value is not None: + updates["last_event_value"] = event_value + if operation is not None: + updates["operation"] = operation + if phase is not None: + updates["phase"] = phase + if sample_position is not None: + updates["sample_position"] = sample_position + if mount_success is not None: + updates["mount_success"] = mount_success + if mount_error is not None: + updates["mount_error"] = mount_error + + return state.model_copy(update=updates) + + +def _ready_state(state: TellStateModel, event_name: str, event_value: str | None) -> TellStateModel: + return _update_state( + state, + activity=TellActivityEnum.IDLE, + message="Ready", + event_name=event_name, + event_value=event_value, + operation="idle", + phase=TellPhaseEnum.IDLE, + mount_error="", + ) + + +def advance_tell_state( + state: TellStateModel, + event_name: str, + event_value: str | None, +) -> TellStateModel: + value = (event_value or "").strip() + + if event_name == "Motion Task" and value == "dry": + return _update_state( + state, + activity=TellActivityEnum.DRYING, + message="Drying", + event_name=event_name, + event_value=value, + operation="dry", + phase=TellPhaseEnum.PREPARING, + mount_success=None, + mount_error="", + ) + + if event_name == "Motion Sync" and value == "Gripper cooling down": + return _update_state( + state, + activity=TellActivityEnum.COOLING, + message="Cooling after drying", + event_name=event_name, + event_value=value, + operation="dry", + phase=TellPhaseEnum.FINALIZING, + ) + + if event_name == "shell": + if value == "true": + return _update_state( + state, + event_name=event_name, + event_value=value, + ) + + if value.startswith("mount("): + sample_position = _parse_sample_position_from_mount_command(value) + return _update_state( + state, + activity=TellActivityEnum.MOUNTING, + message=f"Mounting {sample_position}" if sample_position else "Mounting sample", + event_name=event_name, + event_value=value, + operation="mount", + phase=TellPhaseEnum.PREPARING, + sample_position=sample_position or "", + mount_success=None, + mount_error="", + ) + + if UNMOUNT_CMD_RE.match(value): + return _update_state( + state, + activity=TellActivityEnum.UNMOUNTING, + message="Unmounting sample", + event_name=event_name, + event_value=value, + operation="unmount", + phase=TellPhaseEnum.PREPARING, + mount_success=None, + mount_error="", + ) + + if UNMOUNT_STATUS_RE.match(value): + next_operation = "mount" if state.operation == "mount" else "unmount" + next_phase = TellPhaseEnum.AUTO_UNMOUNT if state.operation == "mount" else TellPhaseEnum.PREPARING + + return _update_state( + state, + activity=TellActivityEnum.UNMOUNTING, + message="Unmounting current sample", + event_name=event_name, + event_value=value, + operation=next_operation, + phase=next_phase, + mount_success=None, + mount_error="", + ) + + if value == "got result True": + if state.operation == "mount" and state.phase in { + TellPhaseEnum.PREPARING, + TellPhaseEnum.AUTO_UNMOUNT, + TellPhaseEnum.RETURNING_OLD_SAMPLE, + TellPhaseEnum.OLD_SAMPLE_RETURNED, + TellPhaseEnum.PICKING_NEW_SAMPLE, + TellPhaseEnum.PLACING_NEW_SAMPLE, + TellPhaseEnum.FINALIZING, + }: + return _update_state( + state, + message=state.message or "Mount in progress", + event_name=event_name, + event_value=value, + ) + + if state.operation == "unmount": + return _update_state( + state, + message="Unmount step completed", + event_name=event_name, + event_value=value, + ) + + return _update_state( + state, + event_name=event_name, + event_value=value, + ) + + if value == "got result False": + if state.operation == "mount" and state.phase in { + TellPhaseEnum.AUTO_UNMOUNT, + TellPhaseEnum.RETURNING_OLD_SAMPLE, + TellPhaseEnum.OLD_SAMPLE_RETURNED, + }: + return _update_state( + state, + activity=TellActivityEnum.UNMOUNTING, + message="Unmounting current sample", + event_name=event_name, + event_value=value, + ) + + if state.operation == "unmount" and state.phase in { + TellPhaseEnum.RETURNING_OLD_SAMPLE, + TellPhaseEnum.COMPLETE, + }: + return _update_state( + state, + activity=TellActivityEnum.UNMOUNTING, + message=state.message or "Unmounting sample", + event_name=event_name, + event_value=value, + ) + + operation = state.operation or "robot operation" + return _update_state( + state, + activity=TellActivityEnum.ERROR, + message=f"{operation.capitalize()} failed", + event_name=event_name, + event_value=value, + mount_success=False, + mount_error=f"{operation.capitalize()} failed", + phase=TellPhaseEnum.FAILED, + ) + + if value.startswith("setting mounted sample to"): + sample_position = value.removeprefix("setting mounted sample to").strip() + return _update_state( + state, + activity=TellActivityEnum.MOUNTING, + message=f"Mounted {sample_position}", + event_name=event_name, + event_value=value, + operation="mount", + phase=TellPhaseEnum.COMPLETE, + sample_position=sample_position, + mount_success=True, + mount_error="", + ) + + return _update_state( + state, + event_name=event_name, + event_value=value, + ) + + if event_name == "Motion Sync": + if value == "Sample get from Gonio": + next_phase = ( + TellPhaseEnum.RETURNING_OLD_SAMPLE + if state.operation == "mount" + else TellPhaseEnum.RETURNING_OLD_SAMPLE + ) + next_operation = "mount" if state.operation == "mount" else "unmount" + return _update_state( + state, + activity=TellActivityEnum.UNMOUNTING, + message="Removing sample from gonio", + event_name=event_name, + event_value=value, + operation=next_operation, + phase=next_phase, + ) + + if value == "Sample put on Puck": + next_phase = ( + TellPhaseEnum.OLD_SAMPLE_RETURNED + if state.operation == "mount" + else TellPhaseEnum.COMPLETE + ) + next_operation = "mount" if state.operation == "mount" else "unmount" + next_message = "Returning sample to puck" if state.operation == "mount" else "Sample returned to puck" + return _update_state( + state, + activity=TellActivityEnum.UNMOUNTING, + message=next_message, + event_name=event_name, + event_value=value, + operation=next_operation, + phase=next_phase, + mount_success=True if state.operation == "unmount" else state.mount_success, + mount_error="" if state.operation == "unmount" else state.mount_error, + ) + + if value == "Sample get on Puck": + return _update_state( + state, + activity=TellActivityEnum.MOUNTING, + message="Picking sample from puck", + event_name=event_name, + event_value=value, + operation="mount", + phase=TellPhaseEnum.PICKING_NEW_SAMPLE, + mount_error="", + ) + + if value == "Sample put on Gonio": + return _update_state( + state, + activity=TellActivityEnum.MOUNTING, + message="Placing sample on gonio", + event_name=event_name, + event_value=value, + operation="mount", + phase=TellPhaseEnum.PLACING_NEW_SAMPLE, + mount_error="", + ) + + if value == "Robot Clear after mount": + return _update_state( + state, + activity=TellActivityEnum.MOUNTING, + message="Robot clear after mount", + event_name=event_name, + event_value=value, + operation="mount", + phase=TellPhaseEnum.FINALIZING, + mount_error="", + ) + + if event_name == "Gripper detection": + if value in {"No Pin in Gripper", "Pin still in Gripper", "Pin is lost"}: + return _update_state( + state, + activity=TellActivityEnum.ERROR, + message=value, + event_name=event_name, + event_value=value, + mount_success=False, + mount_error=value, + phase=TellPhaseEnum.FAILED, + ) + + if event_name == "state": + if value == "Ready": + return _ready_state(state, event_name, value) + + if value == "Busy": + return _update_state( + state, + message=state.message if state.operation not in {None, "idle"} else "Busy", + event_name=event_name, + event_value=value, + ) + + return _update_state( + state, + event_name=event_name, + event_value=value, + ) \ No newline at end of file diff --git a/src/aare/daq/tellupdater.py b/src/aare/daq/tellupdater.py index e1ab97c7..aaa39897 100644 --- a/src/aare/daq/tellupdater.py +++ b/src/aare/daq/tellupdater.py @@ -19,8 +19,10 @@ from aare.devices.tell_client import TellClient from aare.common.exception_handler import TellCommunicationError, TellConnectionException from aare.common.logger_config import setup_logger +from aare.common.tell_models import TellStateModel from aare.daq.aaredb import AareWrapper from aare.daq.config import BeamlineConfig +from aare.daq.tell_state_machine import advance_tell_state, initial_tell_state from aare.common.beamline import MXBeamline, mx_beamline logger = setup_logger("aareDAQ") @@ -62,8 +64,10 @@ TRACKED_MOTION_SYNC_EVENTS = { latest_tell_events = {} tell_event_history = deque(maxlen=25) +current_tell_state = initial_tell_state() TELL_JOURNAL_PREFIX = "[TELL][JOURNAL]" TELL_EVENTS_REDIS_KEY_SUFFIX = "tell_events" +TELL_STATE_REDIS_KEY_SUFFIX = "tell_state" SSE_RECONNECT_DELAY_S = 5 SSE_RECOVERABLE_EXCEPTIONS = ( RequestException, @@ -114,6 +118,30 @@ class TellEventRecord(BaseModel): } +def _get_redis_context() -> tuple[Any | None, str | None]: + if config is None: + logger.debug("[REDIS] BeamlineConfig unavailable; skipping TELL redis write") + return None, None + + redis_client = getattr(config, "_BeamlineConfig__client", None) + beamline_key = getattr(config, "_BeamlineConfig__bl", None) + if redis_client is None or beamline_key is None: + logger.error("[REDIS] BeamlineConfig internals unavailable; skipping TELL redis write") + return None, None + + return cast(Any, redis_client), str(beamline_key) + + +def _set_json_in_redis(key_suffix: str, payload: Any, *, log_label: str) -> None: + redis_client, beamline_key = _get_redis_context() + if redis_client is None or beamline_key is None: + return + + redis_key = f"{beamline_key}:{key_suffix}" + redis_client.set(redis_key, json.dumps(payload)) + logger.info(f"[REDIS] Written {log_label} to: {redis_key}") + + def _normalize_event_data(data): if data is None: return None @@ -128,7 +156,11 @@ def _normalize_event_data(data): try: payload = TellSsePayload.model_validate_json(stripped) except (ValidationError, json.JSONDecodeError, ValueError, TypeError): - return stripped + try: + decoded = json.loads(stripped) + except (json.JSONDecodeError, ValueError, TypeError): + return stripped + return str(decoded).strip() if decoded is not None else None return payload.normalized_value @@ -158,19 +190,19 @@ def extract_tracked_tell_event(event_name, event_data): def set_tell_events_in_redis(events: list[dict[str, str]]) -> None: - if config is None: - logger.debug("[REDIS] BeamlineConfig unavailable; skipping TELL event write") - return + _set_json_in_redis( + TELL_EVENTS_REDIS_KEY_SUFFIX, + events, + log_label="TELL events", + ) - redis_client = getattr(config, "_BeamlineConfig__client", None) - beamline_key = getattr(config, "_BeamlineConfig__bl", None) - if redis_client is None or beamline_key is None: - logger.error("[REDIS] BeamlineConfig internals unavailable; skipping TELL event write") - return - redis_key = f"{beamline_key}:{TELL_EVENTS_REDIS_KEY_SUFFIX}" - cast(Any, redis_client).set(redis_key, json.dumps(events)) - logger.info(f"[REDIS] Written TELL events to: {redis_key}") +def set_tell_state_in_redis(state: TellStateModel) -> None: + _set_json_in_redis( + TELL_STATE_REDIS_KEY_SUFFIX, + state.model_dump(), + log_label="TELL state", + ) def record_tell_event(event_name, event_value): @@ -265,8 +297,19 @@ def handle_tell_change_event(): logger.exception("[SSE] Failed to update puck state after TELL change") def on_sse_event(event): + global current_tell_state + logger.debug(f"[EVENT] SSE event={event.event} data={event.data!r}") - tracked_event = extract_tracked_tell_event(event.event, event.data) + normalized_data = _normalize_event_data(event.data) + + current_tell_state = advance_tell_state( + current_tell_state, + event.event, + normalized_data, + ) + set_tell_state_in_redis(current_tell_state) + + tracked_event = extract_tracked_tell_event(event.event, normalized_data) if tracked_event is not None: record_tell_event(*tracked_event) if event.event == DEWAR_CONTENT_UPDATE_EVENT: @@ -382,4 +425,6 @@ if __name__ == "__main__": WS_HEADERS = [f"X-Shared-Password: {os.getenv('AAREDB_SHARED_PASSWORD')}"] logger.debug(f"[WS] Headers configured: {WS_HEADERS}") + set_tell_state_in_redis(current_tell_state) + main() diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index 3ca25354..98fbecf0 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -263,6 +263,7 @@ class TellClient: return self._last_cmd_id def dry(self, heat_time=None, speed=None, wait_cold=None, wait=False): + #TODO add timeout variable???? self.backend.wait_state("Ready", timeout=30.0) self._last_cmd_id = self.start_cmd("dry", heat_time, speed, wait_cold) if wait: diff --git a/src/aare/gui/panels/tell_sample_panel.py b/src/aare/gui/panels/tell_sample_panel.py index fce011fd..253c8e3a 100644 --- a/src/aare/gui/panels/tell_sample_panel.py +++ b/src/aare/gui/panels/tell_sample_panel.py @@ -41,6 +41,8 @@ class TellSamplePanel(QFrame): grid_layout.addWidget(self.table_view, 1, 0, 1, 4) self.curr_sample_label = QLabel("No sample mounted", parent=self) + self.curr_sample_label.setTextFormat(Qt.TextFormat.RichText) + self.curr_sample_label.setWordWrap(True) grid_layout.addWidget(self.curr_sample_label, 2, 0) self.unmount_button = QPushButton("Unmount", parent=self) @@ -182,24 +184,44 @@ class TellSamplePanel(QFrame): @Slot(DAQStatusModel) def update_daq_status(self, status: DAQStatusModel): sample = status.sample + tell_state = status.tell_state + + tell_details = "" + if tell_state is not None: + activity = tell_state.activity.display_name() + phase = tell_state.phase.display_name() if tell_state.phase is not None else "" + message = (tell_state.message or "").strip() + + tell_parts = [activity] + if phase: + tell_parts.append(phase) + + tell_details = " / ".join(tell_parts) + if message: + tell_details = f"{tell_details} — {message}" + if sample is None: - self.curr_sample_label.setText("No sample mounted") + base_text = "No sample mounted" self.table_model.updateCurrentSample(current_puck=None, current_sample=None) else: try: if sample.location is None: - self.curr_sample_label.setText( - f"Current sample: {sample.sample_name} (Manual mount)" - ) + base_text = f"Current sample: {sample.sample_name} (Manual mount)" else: - self.curr_sample_label.setText( - f"Current sample: {sample.sample_name} ({sample.location.segment}{sample.location.pos}-{sample.pin})" + base_text = ( + f"Current sample: {sample.sample_name} " + f"({sample.location.segment}{sample.location.pos}-{sample.pin})" ) self.table_model.updateCurrentSample( current_puck=sample.puck_name, current_sample=sample.db_id ) except Exception as e: - self.curr_sample_label.setText(f"Confusing information :/ {e}") + base_text = f"Confusing information :/ {e}" + + if tell_details: + self.curr_sample_label.setText(f"{base_text}
TELL: {tell_details}") + else: + self.curr_sample_label.setText(base_text) if status.session.current_pgroup is not None: self.__current_pgroup = status.session.current_pgroup diff --git a/tests/unit/daq/test_tell_state_updater.py b/tests/unit/daq/test_tell_state_updater.py new file mode 100644 index 00000000..ed56402b --- /dev/null +++ b/tests/unit/daq/test_tell_state_updater.py @@ -0,0 +1,135 @@ +from aare.common.tell_models import TellActivityEnum, TellPhaseEnum +from aare.daq.tell_state_machine import advance_tell_state, initial_tell_state + + +def test_mount_with_auto_unmount_uses_internal_phases(): + state = initial_tell_state() + + state = advance_tell_state( + state, + "shell", + 'mount("F", 2, 1, force=1, read_dm=0, auto_unmount=1)', + ) + assert state.activity == TellActivityEnum.MOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.PREPARING + assert state.sample_position == "F2-1" + assert state.mount_success is None + + state = advance_tell_state(state, "shell", "got result True") + assert state.activity == TellActivityEnum.MOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.PREPARING + assert state.mount_success is None + + state = advance_tell_state(state, "shell", "unmount: None None None True") + assert state.activity == TellActivityEnum.UNMOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.AUTO_UNMOUNT + + state = advance_tell_state(state, "Motion Sync", "Sample get from Gonio") + assert state.activity == TellActivityEnum.UNMOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.RETURNING_OLD_SAMPLE + + state = advance_tell_state(state, "Motion Sync", "Sample put on Puck") + assert state.activity == TellActivityEnum.UNMOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.OLD_SAMPLE_RETURNED + + state = advance_tell_state(state, "Motion Sync", "Sample get on Puck") + assert state.activity == TellActivityEnum.MOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.PICKING_NEW_SAMPLE + + state = advance_tell_state(state, "Motion Sync", "Sample put on Gonio") + assert state.activity == TellActivityEnum.MOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.PLACING_NEW_SAMPLE + + state = advance_tell_state(state, "Motion Sync", "Robot Clear after mount") + assert state.activity == TellActivityEnum.MOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.FINALIZING + + state = advance_tell_state(state, "shell", "setting mounted sample to F2-1") + assert state.activity == TellActivityEnum.MOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.COMPLETE + assert state.sample_position == "F2-1" + assert state.mount_success is True + + state = advance_tell_state(state, "state", "Ready") + assert state.activity == TellActivityEnum.IDLE + assert state.operation == "idle" + assert state.phase == TellPhaseEnum.IDLE + assert state.message == "Ready" + + +def test_plain_unmount_reaches_complete_before_ready(): + state = initial_tell_state() + + state = advance_tell_state(state, "shell", "unmount()") + assert state.activity == TellActivityEnum.UNMOUNTING + assert state.operation == "unmount" + assert state.phase == TellPhaseEnum.PREPARING + + state = advance_tell_state(state, "Motion Sync", "Sample get from Gonio") + assert state.phase == TellPhaseEnum.RETURNING_OLD_SAMPLE + + state = advance_tell_state(state, "Motion Sync", "Sample put on Puck") + assert state.phase == TellPhaseEnum.COMPLETE + assert state.mount_success is True + + state = advance_tell_state(state, "state", "Ready") + assert state.activity == TellActivityEnum.IDLE + assert state.operation == "idle" + assert state.phase == TellPhaseEnum.IDLE + + +def test_gripper_detection_moves_to_failed_phase(): + state = initial_tell_state() + state = advance_tell_state(state, "shell", 'mount("F", 2, 1)') + state = advance_tell_state(state, "Gripper detection", "Pin is lost") + + assert state.activity == TellActivityEnum.ERROR + assert state.phase == TellPhaseEnum.FAILED + assert state.mount_success is False + assert state.mount_error == "Pin is lost" + + +def test_got_result_false_during_auto_unmount_is_not_error(): + state = initial_tell_state() + + state = advance_tell_state( + state, + "shell", + 'mount("D", 5, 14, force=1, read_dm=0, auto_unmount=1)', + ) + assert state.activity == TellActivityEnum.MOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.PREPARING + + state = advance_tell_state(state, "shell", "unmount: None None None True") + assert state.activity == TellActivityEnum.UNMOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.AUTO_UNMOUNT + + state = advance_tell_state(state, "Motion Sync", "Sample get from Gonio") + assert state.activity == TellActivityEnum.UNMOUNTING + assert state.phase == TellPhaseEnum.RETURNING_OLD_SAMPLE + + state = advance_tell_state(state, "shell", "got result False") + assert state.activity == TellActivityEnum.UNMOUNTING + assert state.operation == "mount" + assert state.phase == TellPhaseEnum.RETURNING_OLD_SAMPLE + assert state.mount_success is None + assert state.mount_error in (None, "") + + state = advance_tell_state(state, "Motion Sync", "Sample put on Puck") + assert state.activity == TellActivityEnum.UNMOUNTING + assert state.phase == TellPhaseEnum.OLD_SAMPLE_RETURNED + + state = advance_tell_state(state, "Motion Sync", "Sample get on Puck") + assert state.activity == TellActivityEnum.MOUNTING + assert state.phase == TellPhaseEnum.PICKING_NEW_SAMPLE \ No newline at end of file