diff --git a/src/aare/devices/tell_backend.py b/src/aare/devices/tell_backend.py new file mode 100644 index 00000000..0a6f962e --- /dev/null +++ b/src/aare/devices/tell_backend.py @@ -0,0 +1,410 @@ +import json +import re +import time +from typing import Any, Callable, Protocol +from urllib.parse import urlparse + +import requests + +from aare.common.exception_handler import TellCommunicationError +from aare.common.logger_config import setup_logger + +from aare.common.beamline import MXBeamline # noqa: F401 +from pshell import PShellClient + + +logger = setup_logger("aareDAQ") + + +VALID_DEWAR_POSITIONS = [f"{p}{n}" for n in "12345" for p in "ABCDEFX"] + + +def is_valid_dewar_position(position): + """check if argument is a valid dewar position""" + return position in VALID_DEWAR_POSITIONS + + +POSITION_PARK = "pPark" +POSITION_COLD = "pCold" +POSITION_AUX = "pAux" +POSITION_DEWAR = "pDewar" +POSITION_HOME = "pHome" +POSITION_HEATER = "pHeatB" + + +class ManualMountException(Exception): + """Custom exception for manual mounting""" + pass + + +class SmartMagnetFaultException(Exception): + """Custom exception for smart magnet fault""" + pass + + +class TellMountFailedException(Exception): + """Custom exception for mount failure""" + pass + + +class TellCommandWhileBusyException(Exception): + """Custom exception for trying to move Tell when it is busy""" + pass + + +class TellConnectionException(Exception): + """Custom exception for connection problems""" + pass + + +class TellBackend(Protocol): + @property + def url(self) -> str | None: + ... + + def get_state(self) -> str: + ... + + def get_result(self, command_id: int = -1): + ... + + def wait_state(self, state: str, timeout: float) -> None: + ... + + def wait_state_not(self, state: str, timeout: float) -> None: + ... + + def wait_events(self, events: dict[str, Any], timeout: float): + ... + + def eval(self, expr: str): + ... + + def start_eval(self, expr: str) -> int: + ... + + def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None: + ... + + def abort(self) -> None: + ... + + +class PShellTellBackend: + def __init__(self, bl: MXBeamline): + self._url = self._resolve_url(bl) + + print(f"Connecting TELL p-shell service at {self._url} ...", end="") + hostname = urlparse(self._url).hostname + try: + requests.get(f"{self._url}/history/0", timeout=1.0) + except requests.exceptions.RequestException as e: + print(f"...connection to {hostname} failed") + raise TellCommunicationError( + f"TELL connection failed ({hostname})", + base_url=self._url, + endpoint="history/0", + operation="GET", + ) from e + except requests.ReadTimeout as e: + print(f"...PShell service {hostname} is down") + raise TellCommunicationError( + f"TELL connection timedout ({hostname})", + base_url=self._url, + endpoint="history/0", + operation="GET", + ) from e + + self._pshell = PShellClient(self._url) + + @staticmethod + def _resolve_url(bl: MXBeamline) -> str: + beamline = bl.value.lower() + if bl == MXBeamline.X06DA: + return f"http://{beamline}-tell.psi.ch:22222" + if bl == MXBeamline.X10SA: + return "http://PC17488:22222" + if bl == MXBeamline.X06SA: + raise NotImplementedError(f"TellClient not implemented for {beamline}") + if bl == MXBeamline.SIMULATED: + raise NotImplementedError("Use SimTellBackend for MXBeamline.SIMULATED") + raise ValueError(f"Unknown beamline {beamline}") + + @property + def url(self) -> str | None: + return self._url + + def get_state(self) -> str: + return self._pshell.get_state() + + def get_result(self, command_id: int = -1): + return self._pshell.get_result(command_id) + + def wait_state(self, state: str, timeout: float) -> None: + self._pshell.wait_state(state, timeout=timeout) + + def wait_state_not(self, state: str, timeout: float) -> None: + self._pshell.wait_state_not(state, timeout=timeout) + + def wait_events(self, events: dict[str, Any], timeout: float): + return self._pshell.wait_events(events, timeout=timeout) + + def eval(self, expr: str): + return self._pshell.eval(expr) + + def start_eval(self, expr: str) -> int: + return self._pshell.start_eval(expr) + + def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None: + self._pshell.run(path, pars=pars, background=background) + + def abort(self) -> None: + self._pshell.abort() + +class SimTellBackend: + def __init__(self): + self._url: str | None = None + self._state = "Ready" + self._last_cmd_id = 1000 + self._mounted_sample = "" + self._settings: dict[str, str] = {"mounted_sample_position": ""} + self._results: dict[int, dict[str, Any]] = {} + self._robot_status: dict[str, Any] = { + "powered": True, + "pos": POSITION_PARK, + } + self._current_mA = 30.0 + self._pin_offset = 0.0 + self._detected_pucks: list[dict[str, Any]] = [] + self._system_check_msg = "OK" + self._smart_magnet_state = "Ready" + self._in_mount_position = False + + @property + def url(self) -> str | None: + return self._url + + def _next_cmd_id(self) -> int: + self._last_cmd_id += 1 + return self._last_cmd_id + + def _set_ready_soon(self) -> None: + time.sleep(0.01) + self._state = "Ready" + + def get_state(self) -> str: + return self._state + + def get_result(self, command_id: int = -1): + if command_id == -1: + command_id = self._last_cmd_id + return self._results.get(command_id, {"status": "completed"}) + + def wait_state(self, state: str, timeout: float) -> None: + if self._state != state: + time.sleep(min(timeout, 0.05)) + self._state = state + + def wait_state_not(self, state: str, timeout: float) -> None: + if self._state == state: + time.sleep(min(timeout, 0.05)) + self._state = "Ready" + + def wait_events(self, events: dict[str, Any], timeout: float): + self.wait_state_not("Busy", timeout) + if "Motion Sync" in events: + return "Motion Sync", "Robot Clear after mount" + if "Motion Task" in events: + return "Motion Task", "idle" + return None, self._state + + def eval(self, expr: str): + expr = expr.strip() + + if expr == "in_mount_position&": + return "true" if self._in_mount_position else "false" + + if expr.startswith("in_mount_position = "): + self._in_mount_position = "True" in expr or "true" in expr + return None + + if expr.startswith("set_setting("): + match = re.match(r"set_setting\('([^']+)', '([^']*)'\)&", expr) + if match: + key, value = match.groups() + self._settings[key] = value + return None + + if expr.startswith("get_setting("): + match = re.match(r"get_setting\('([^']+)'\)&", expr) + if match: + key = match.group(1) + return self._settings.get(key, "") + return "" + + if expr == "system_check_msg()&": + return self._system_check_msg + + if expr == "robot.state&": + return self._state + + if expr == "robot.take()&": + return str(self._robot_status) + + if expr == "get_pucks_info()&": + return json.dumps(self._detected_pucks) + + if expr == "get_pin_offset()&": + return str(self._pin_offset) + + if expr == "smart_magnet.get_current_rb()&": + return str(self._current_mA) + + if expr.startswith("smart_magnet.set_current("): + match = re.match(r"smart_magnet\.set_current\(([-+]?\d+(?:\.\d+)?)\)&", expr) + if match: + self._current_mA = float(match.group(1)) + return None + + if expr == "enable_motion()&": + self._robot_status["powered"] = True + return None + + if expr == "smart_magnet.state&": + return self._smart_magnet_state + + if expr == "smart_magnet.set_supress(True)&": + return None + + if expr == "smart_magnet.set_supress(False)&": + return None + + if expr == "smart_magnet.set_resting_current()&": + return None + + if expr == "robot.stop_task()&": + self._state = "Ready" + return None + + return None + + def start_eval(self, expr: str) -> int: + cmd_id = self._next_cmd_id() + self._state = "Busy" + + if expr.startswith("mount("): + parts = re.findall(r"'([^']*)'|([^,()]+)", expr) + values = [a if a else b.strip() for a, b in parts] + if len(values) >= 4: + segment = values[0] + puck = values[1] + sample = values[2] + mounted = f"{segment}{puck}{sample}" + self._mounted_sample = mounted + self._settings["mounted_sample_position"] = mounted + self._robot_status["pos"] = POSITION_DEWAR + + elif expr.startswith("unmount("): + self._mounted_sample = "" + self._settings["mounted_sample_position"] = "" + self._robot_status["pos"] = POSITION_PARK + + elif expr.startswith("move_park("): + self._robot_status["pos"] = POSITION_PARK + + elif expr.startswith("move_cold("): + self._robot_status["pos"] = POSITION_COLD + + elif expr.startswith("dry("): + self._robot_status["pos"] = POSITION_HEATER + + self._results[cmd_id] = {"status": "completed", "command": expr} + self._set_ready_soon() + return cmd_id + + def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None: + _ = background + + if path == "data/set_samples_info" and pars: + try: + data = json.loads(pars[0]) + self._detected_pucks = [] + for item in data: + puck_address = item.get("puckAddress", "") + if puck_address: + self._detected_pucks.append( + { + "puckState": "Present", + "puckAddress": puck_address, + "puckBarcode": item.get("puckBarcode", ""), + } + ) + except Exception: + logger.warning("Failed to load simulated samples info") + + def abort(self) -> None: + self._state = "Ready" + + +class LazyTellBackend: + def __init__(self, factory: Callable[[], TellBackend], *, retry_interval_s: float = 2.0): + self._factory = factory + self._backend: TellBackend | None = None + self._retry_interval_s = float(retry_interval_s) + self._last_attempt_ts = 0.0 + self._last_error: Exception | None = None + + def _get_backend(self) -> TellBackend: + if self._backend is not None: + return self._backend + + now = time.monotonic() + if now - self._last_attempt_ts < self._retry_interval_s and self._last_error is not None: + raise self._last_error + + self._last_attempt_ts = now + try: + self._backend = self._factory() + self._last_error = None + return self._backend + except TellCommunicationError as e: + self._last_error = e + raise + except Exception as e: + wrapped = TellCommunicationError( + "TELL connection failed", + operation="CONNECT", + ) + self._last_error = wrapped + raise wrapped from e + + @property + def url(self) -> str | None: + return self._get_backend().url + + def get_state(self) -> str: + return self._get_backend().get_state() + + def get_result(self, command_id: int = -1): + return self._get_backend().get_result(command_id) + + def wait_state(self, state: str, timeout: float) -> None: + self._get_backend().wait_state(state, timeout) + + def wait_state_not(self, state: str, timeout: float) -> None: + self._get_backend().wait_state_not(state, timeout) + + def wait_events(self, events: dict[str, Any], timeout: float): + return self._get_backend().wait_events(events, timeout) + + def eval(self, expr: str): + return self._get_backend().eval(expr) + + def start_eval(self, expr: str) -> int: + return self._get_backend().start_eval(expr) + + def run(self, path: str, pars: list[str] | None = None, background: bool = False) -> None: + self._get_backend().run(path, pars=pars, background=background) + + def abort(self) -> None: + self._get_backend().abort() \ No newline at end of file diff --git a/src/aare/devices/tell_client.py b/src/aare/devices/tell_client.py index 9ff31f99..f7a4d8c9 100755 --- a/src/aare/devices/tell_client.py +++ b/src/aare/devices/tell_client.py @@ -1,14 +1,9 @@ +import ast import json -import random import re -import time from typing import List -from urllib.parse import urlparse -import requests - -from aare.common.exception_handler import TellCommunicationError -from aare.common.logger_config import setup_logger +from aare.common.beamline import MXBeamline from aare.common.models import ( PuckLoadedInfo, DewarAddress, @@ -16,93 +11,28 @@ from aare.common.models import ( ) from aareDB import PuckWithTellPosition -from aare.common.beamline import MXBeamline # noqa: F401 -from pshell import PShellClient +from aare.devices.tell_backend import ( + TellBackend, + SimTellBackend, + LazyTellBackend, + PShellTellBackend, + ManualMountException, + POSITION_COLD, + SmartMagnetFaultException, + TellConnectionException, + TellMountFailedException, + TellCommandWhileBusyException, +) + +from aare.common.logger_config import setup_logger logger = setup_logger("aareDAQ") -class ManualMountException(Exception): - """Custom exception for manual mounting""" - pass - - -class SmartMagnetFaultException(Exception): - """Custom exception for smart magnet fault""" - pass - - -class TellMountFailedException(Exception): - """Custom exception for mount failure""" - pass - - -class TellCommandWhileBusyException(Exception): - """Custom exception for trying to move Tell when it is busy""" - pass - - -class TellConnectionException(Exception): - """Custom exception for connection problems""" - pass - -VALID_DEWAR_POSITIONS = [f"{p}{n}" for n in "12345" for p in "ABCDEFX"] - -def is_valid_dewar_position(position): - """check if argument is a valid dewar position""" - return position in VALID_DEWAR_POSITIONS - -POSITION_PARK = "pPark" -POSITION_COLD = "pCold" -POSITION_AUX = "pAux" -POSITION_DEWAR = "pDewar" -POSITION_HOME = "pHome" -POSITION_HEATER = "pHeatB" - -#Nov 26 13:36:00 mx-x06da-queue-01.psi.ch AareDAQ[2944444]: 2025-11-26 13:36:00,388 - aareDAQ - ERROR - Error getting status: ('Connection aborted.', ConnectionResetError(104, 'Connection reset by peer')) - class TellClient: - """High-level Tell robot API using PShellClient""" - def __init__(self, bl: MXBeamline): - self.__url = None - beamline = bl.value.lower() + """High-level Tell robot API using a pluggable backend""" + def __init__(self, bl: MXBeamline, backend: TellBackend | None = None): self.__beamline = bl - if bl == MXBeamline.X06DA: - self.__url = f"http://{beamline}-tell.psi.ch:22222" - - elif bl == MXBeamline.X10SA: - self.__url = f"http://PC17488:22222" - - elif bl == MXBeamline.X06SA: - self.__url = f"" - raise NotImplemented(f"TellClient not implemente for {beamline}") - elif bl == MXBeamline.SIMULATED: - raise NotImplemented(f"Use SimClient, generate tell client using" - f"make_tell_client(beamline)") - else: - raise ValueError(f"Unknown beamline {beamline}") - - print(f"Connecting TELL p-shell service at {self.__url} ...", end="") - hostname = urlparse(self.__url).hostname - try: - requests.get(f"{self.__url}/history/0", timeout=1.0) - except requests.exceptions.RequestException as e: - print(f"...connection to {hostname} failed") - raise TellCommunicationError( - f"TELL connection failed ({hostname})", - base_url=self.__url, - endpoint="history/0", - operation="GET", - ) from e - except requests.ReadTimeout as e: - print(f"...PShell service {hostname} is down") - raise TellCommunicationError( - f"TELL connection timedout ({hostname})", - base_url=self.__url, - endpoint="history/0", - operation="GET", - ) from e - - self.pshell = PShellClient(self.__url) + self.backend = backend or PShellTellBackend(bl) self._aborted = False self.state = self.get_state() @@ -112,26 +42,26 @@ class TellClient: @property def url(self): """returns the configured base url for the Tell robot""" - return self.__url + return self.backend.url def get_state(self): """returns the current state of the robot""" - self.state = self.pshell.get_state() + self.state = self.backend.get_state() return self.state def get_result(self, command_id=-1): """returns the result of the last command issued to the robot""" - return self.pshell.get_result(command_id) + return self.backend.get_result(command_id) def wait_ready(self, timeout: float = 360.0): """waits until the robot is ready to accept commands returns None if simulation and raises an exception if the robot is not ready""" - self.pshell.wait_state("Ready", timeout=timeout) + self.backend.wait_state("Ready", timeout=timeout) def wait_not_busy(self, timeout: float = 360.0): """waits until the robot is not busy and returns None if simulation and raises an exception if the robot is busy""" - self.pshell.wait_state_not("Busy", timeout=timeout) + self.backend.wait_state_not("Busy", timeout=timeout) state = self.get_state() if state != "Ready": if state == "Initializing": @@ -143,11 +73,11 @@ class TellClient: def set_in_mount_position(self, value): """tells the robot that the beamlien is safe and to set the in mount position flag allowing mounting :param value """ - self.pshell.eval("in_mount_position = " + str(value) + "&") + self.backend.eval("in_mount_position = " + str(value) + "&") def is_in_mount_position(self) -> bool: """checks to see if the robot is in the mount position and returns a boolean""" - return self.pshell.eval("in_mount_position&").lower() == "true" + return self.backend.eval("in_mount_position&").lower() == "true" def set_samples_info(self, info: List[PuckWithTellPosition]): """sets the samples in the robot dewar based on the given list of PuckWithTellPosition objects @@ -160,7 +90,7 @@ class TellClient: "userName": x.pgroup, "dewarName": x.dewar_name or "", "puckName": x.puck_name, - "puckType": "Unipuck", # could use x.puck_type + "puckType": "Unipuck", "puckAddress": x.tell_position or "", "puckBarcode": x.puck_name, "sampleBarcode": "", @@ -171,8 +101,7 @@ class TellClient: } ) - self.pshell.run("data/set_samples_info", pars=[json.dumps(j)], background=True) - # self.pshell.eval("set_samples_info(" + json.dumps(info) + ")&") + self.backend.run("data/set_samples_info", pars=[json.dumps(j)], background=True) def start_cmd(self, cmd, *argv): """starts a command on the robot and returns the command id""" @@ -180,7 +109,7 @@ class TellClient: for a in argv: cmd = cmd + (("'" + a + "'") if type(a) is str else str(a)) + ", " cmd = cmd + ")" - ret = self.pshell.start_eval(cmd) + ret = self.backend.start_eval(cmd) self.get_state() return ret @@ -191,11 +120,10 @@ class TellClient: result = self.get_result(self._last_cmd_id) logger.debug(f"{msg} {result}") status = result["status"] - if "completed" != status: #FIXME this is very limiting and depends on tell reporting statuses + if "completed" != status: if "removed" != status: raise TellMountFailedException(f"{msg} {result}") - else: - return f"{msg} {result}" + return f"{msg} {result}" def estimate_mounting_time(self, segment) -> int: """Adds additional time if cooling/drying is expected based on requested segment, @@ -206,7 +134,7 @@ class TellClient: gripper_in_cold = self.is_in_cold() if current_mounted is None: - unmount_needs_drying = 0 # might not have anything + unmount_needs_drying = 0 unmount_needs_cooling = 0 else: segment_in_cold = current_mounted.puck.segment in "ABCDEF" @@ -219,27 +147,18 @@ class TellClient: needs_cooling = mount_needs_cooling + unmount_needs_cooling needs_drying = mount_needs_drying + unmount_needs_drying return needs_cooling * 30 + needs_drying * 120 - except: + except Exception: return 0 def mount( self, address: SampleDewarAddress, - force: bool = False, # kept for future - read_dm: bool = False, # read data matrix - auto_unmount: bool = False, # single command, if False it will raise exception - wait: bool = False, # blocking operation + force: bool = False, + read_dm: bool = False, + auto_unmount: bool = False, + wait: bool = False, timeout: float = 600.0, ): - """send api request to mount sample from dewer after validating dewer address returns None or repsonse. - If the robot is busy, mount will raise an exception. - :param address: SampleDewarAddress - :param force: bool - :param read_dm: bool - :param auto_unmount: bool - :param wait: bool - :param timeout: float - """ SampleDewarAddress.model_validate(address) segment = address.puck.segment @@ -258,9 +177,16 @@ class TellClient: wait_timeout = timeout + self.estimate_mounting_time(segment) logger.info("waiting for mount to complete") if wait and segment in "ABCDEF": - event, value = self.pshell.wait_events({"state": None, "Motion Task": "dry", - "Gripper detection" : None, - "Motion Sync": "Robot Clear after mount"}, timeout=wait_timeout) + event, value = self.backend.wait_events( + { + "state": None, + "Motion Task": "dry", + "Gripper detection": None, + "Motion Sync": "Robot Clear after mount", + }, + timeout=wait_timeout, + ) + logger.info(f"event: {event} occurred with value: {value}") if event is None or event == "state": logger.info(f"event: {event} occurred with value: {value}, checking command completed okay") self.check_command_ok( @@ -299,11 +225,6 @@ class TellClient: return None def unmount(self, force=False, wait=False, timeout=360.0): - """send api request to unmount sample from dewer returns None or repsonse. - :param force: bool Force has a meaning, will unmount even if smart magnet is not detecting sample - :param wait: bool If true will wait until unmount is completed - :timeout: float""" - if self.is_busy(): raise TellCommandWhileBusyException("mount received while robot is busy") @@ -315,82 +236,60 @@ class TellClient: return self._last_cmd_id def dry(self, heat_time=None, speed=None, wait_cold=None, wait=False): - """send api request to dry tell gripper. - :param: heat_time float if None Tell will use default for drying time - :param: speed float if None Tell will use default for drying speed - :param: wait_cold bool if -1 to go to park after dry. if None Tell will use default time to wait_cold. - :param wait: bool If true will wait until drying is completed - """ - self.pshell.wait_state("Ready", timeout=30.0) + self.backend.wait_state("Ready", timeout=30.0) self._last_cmd_id = self.start_cmd("dry", heat_time, speed, wait_cold) if wait: - self.check_command_ok(timeout=360.0, msg=f"Dry failed") + self.check_command_ok(timeout=360.0, msg="Dry failed") def move_park(self, wait=False): - """send api request to move robot to park position""" self._last_cmd_id = self.start_cmd("move_park") - if wait: - self.check_command_ok(timeout=360.0, msg=f"Move to park failed") + self.check_command_ok(timeout=360.0, msg="Move to park failed") def move_cold(self, reset_timestamp=False, wait=False): - """send api request to move robot to cold position""" self._last_cmd_id = self.start_cmd("move_cold", reset_timestamp) - if wait: - self.check_command_ok(timeout=360.0, msg=f"Move to cold failed") + self.check_command_ok(timeout=360.0, msg="Move to cold failed") def abort_cmd(self): - """sends an abort pshell requesst and a robot stop task command""" - self.pshell.abort() - self.pshell.eval("robot.stop_task()&") + self.backend.abort() + self.backend.eval("robot.stop_task()&") def set_setting(self, key: str, value: str): - """wrapper for pshell eval set_setting command - :param key str, name of a setting in tell - :param value str, the new value of the setting as a string""" - self.pshell.eval(f"set_setting('{key}', '{value}')&") + self.backend.eval(f"set_setting('{key}', '{value}')&") def get_setting(self, key: str) -> str: - """wrapper for pshell eval get_setting command, returns the current value for key as a string - :param key str, name of a setting in tell""" - return self.pshell.eval(f"get_setting('{key}')&") + return self.backend.eval(f"get_setting('{key}')&") def get_mounted_sample(self) -> SampleDewarAddress | None: - """get the current mounted sample and return a SampleDewarAddress object or None if no sample is mounted""" - ret = self.get_setting('mounted_sample_position').strip() - if not ret or len(ret) == 0: + ret = self.get_setting("mounted_sample_position").strip() + if not ret: return None match = re.match(r"([A-Z])(\d)(\d{1,2})", ret) - if match: segment, puck, sample = match.groups() dewar_location = DewarAddress(segment=segment, pos=int(puck)) return SampleDewarAddress(puck=dewar_location, pin=int(sample)) - else: - logger.warning(f"Failed to decode mounted sample position: {ret}") - return None + + logger.warning(f"Failed to decode mounted sample position: {ret}") + return None def get_system_check(self): - """returns the current system check status""" - return self.pshell.eval("system_check_msg()&") + return self.backend.eval("system_check_msg()&") def get_robot_state(self): - """returns the current robot state""" - return self.pshell.eval("robot.state&") + return self.backend.eval("robot.state&") def get_robot_status(self): - """returns the current robot status""" - status = self.pshell.eval("robot.take()&") - return eval(status) # FIXME ALL functions must return a valid JSON object + status = self.backend.eval("robot.take()&") + #return eval(status) + return ast.literal_eval(status) def get_detected_pucks(self) -> List[PuckLoadedInfo]: - """returns a list of detected pucks as PuckLoadedInfo objects""" - j = json.loads(self.pshell.eval("get_pucks_info()&")) + j = json.loads(self.backend.eval("get_pucks_info()&")) output = [] - for i in j: if i["puckState"] == "Present": puck_address = i["puckAddress"] @@ -399,90 +298,77 @@ class TellClient: PuckLoadedInfo( puck_name=i["puckBarcode"], location=DewarAddress( - segment=puck_address[0], pos=int(puck_address[1]) + segment=puck_address[0], + pos=int(puck_address[1]), ), ), ) return output def get_pin_offset(self): - """get the pin offset for the smart magnet, returns offset as a float""" try: - offset = float(self.pshell.eval("get_pin_offset()&")) + offset = float(self.backend.eval("get_pin_offset()&")) except Exception: offset = 0.0 return offset def get_current(self): - """get the current drawn by the smart magnet, returns current as a float in mA""" - current = self.pshell.eval("smart_magnet.get_current_rb()&") + current = self.backend.eval("smart_magnet.get_current_rb()&") return float(current) def set_current(self, current: float) -> float: - """set the current drawn by the smart magnet, returns current as a float in mA""" - self.pshell.eval("smart_magnet.set_current({:.1f})&".format(current)) - current = self.pshell.eval("smart_magnet.get_current_rb()&") + self.backend.eval("smart_magnet.set_current({:.1f})&".format(current)) + current = self.backend.eval("smart_magnet.get_current_rb()&") return float(current) def is_powered(self): - """returns True if the robot is powered on""" return self.get_robot_status()["powered"] def check_enable_motion(self): - """check if the robot is powered on and enable motion if not""" if not self.is_powered(): - self.pshell.eval("enable_motion()&") + self.backend.eval("enable_motion()&") def is_in_cold(self): - """Compare current robot position to the set cold position. Returns True if in cold position, False otherwise.""" return self.is_position(POSITION_COLD) def is_position(self, position: str) -> bool: - """Compare current robot position to a given position. Returns True if in position, False otherwise.""" return position == self.get_robot_status()["pos"] def is_ready(self): - """returns True if the robot is ready to receive commands""" return "ready" == self.get_state().lower() def is_busy(self): - """returns True if the robot is busy""" return "busy" == self.get_state().lower() def check_smart_magnet_mounted(self, timeout: float = 10.0, idle_time: float = 1.0, interval: float = 0.1): - """Reads smart_magent state and tries to infer if a sample is present - Handles: PAUSED, Fault, Busy and Ready states. - Raises a ManualMountException is the amgnet indicates a sample is present but get_mounted_sample is None. - Raises a SmartMagnetFaultException if the magnet detects no sample but the robot thinks a sample is mounted""" - #TODO tidy up - initial_state = self.pshell.eval("smart_magnet.state&") + #Not sure why unused, potentially can remove them + _ = (timeout, idle_time, interval) + + initial_state = self.backend.eval("smart_magnet.state&") logger.debug(f"checking smart magnet_initial state: {initial_state}") if initial_state == "Paused": - self.pshell.eval("smart_magnet.set_supress(False)&") - self.pshell.eval("smart_magnet.set_resting_current()&") - + self.backend.eval("smart_magnet.set_supress(False)&") + self.backend.eval("smart_magnet.set_resting_current()&") elif initial_state == "Fault": logger.error(f"tell smart magnet is in unknown state {initial_state}") raise SmartMagnetFaultException - state = self.pshell.eval("smart_magnet.state&") + state = self.backend.eval("smart_magnet.state&") try: if state == "Busy": - logger.debug('state busy') - self.pshell.eval("smart_magnet.set_supress(True)&") - self.pshell.eval("smart_magnet.state&") - sample_present = True + logger.debug("state busy") + self.backend.eval("smart_magnet.set_supress(True)&") + self.backend.eval("smart_magnet.state&") if self.get_mounted_sample() is None: logger.warning("Check mount: A manually mounted sample is detected.") logger.warning("Remove before mounting with the robot.") raise ManualMountException return True elif state == "Ready": - logger.debug('No sample detected, ready to mount') - sample_present = False + logger.debug("No sample detected, ready to mount") if self.get_mounted_sample(): logger.error("Check mount: No sample detected, but robot thinks is mounted") raise SmartMagnetFaultException @@ -491,174 +377,20 @@ class TellClient: logger.debug("Smart magnet detection is paused") return None else: - self.pshell.eval("smart_magnet.set_supress(True)&") + self.backend.eval("smart_magnet.set_supress(True)&") logger.error(f"Tell smart magnet is in unknown state {state}") raise SmartMagnetFaultException - except Exception as e: logger.error(f"check_smart_magnet_mounted failed: {e}") raise e -class SimTellClient: - """ - Simulation-only Tell client. - Keeps behavior deterministic-ish and stateful without needing PShellClient. - Implement more methods as your callers need them. - """ - def __init__(self): - self._state = "Ready" - self._last_cmd_id = 1000 - self._mounted_sample: str = "" - self._simulated_samples_info = {} - self._simulated_detected_pucks = [] - self._simulated_current = 30.0 - self._simulated_suppress = True - self._simulated_offset = 0.0 - - @property - def url(self): - return None - - def _next_cmd_id(self) -> int: - self._last_cmd_id += 1 - return self._last_cmd_id - - def get_state(self) -> str: - return self._state - - def is_ready(self) -> bool: - return self._state.lower() == "ready" - - def is_busy(self) -> bool: - return self._state.lower() == "busy" - - def wait_ready(self, timeout: float = 360.0): - # Keep it simple: flip to Ready quickly. - time.sleep(0.05) - self._state = "Ready" - - def mount( - self, - address: SampleDewarAddress, - force: bool = False, - read_dm: bool = False, - auto_unmount: bool = False, - wait: bool = False, - timeout: float = 600.0, - ): - SampleDewarAddress.model_validate(address) - if self.is_busy(): - raise TellCommandWhileBusyException("mount received while robot is busy") - - cmd_id = self._next_cmd_id() - self._state = "Busy" - - segment = address.puck.segment - puck = address.puck.pos - sample = address.pin - self._mounted_sample = f"{segment}{puck}{sample}" - - if wait: - self.wait_ready(timeout=timeout) - else: - # quickly become ready anyway, but asynchronously-ish - time.sleep(0.01) - self._state = "Ready" - - return cmd_id - - def unmount(self, force: bool = False, wait: bool = False, timeout: float = 360.0): - if self.is_busy(): - raise TellCommandWhileBusyException("unmount received while robot is busy") - - cmd_id = self._next_cmd_id() - self._state = "Busy" - self._mounted_sample = "" - if wait: - self.wait_ready(timeout=timeout) - else: - time.sleep(0.01) - self._state = "Ready" - return cmd_id - - def get_mounted_sample(self) -> SampleDewarAddress | None: - ret = self._mounted_sample - if not ret: - return None - match = re.match(r"([A-Z])(\d)(\d{1,2})", ret) - if not match: - return None - segment, puck, sample = match.groups() - return SampleDewarAddress(puck=DewarAddress(segment=segment, pos=int(puck)), pin=int(sample)) - -class TellClientProxy: - """ - Lazy-connecting Tell client proxy that retries periodically. - - Server can start even if TELL is down. - - First use triggers connect; failures raise TellCommunicationError. - """ - def __init__(self, bl: MXBeamline, *, retry_interval_s: float = 2.0): - self._bl = bl - self._client: TellClient | None = None - self._retry_interval_s = float(retry_interval_s) - self._last_attempt_ts = 0.0 - self._last_error: Exception | None = None - - def _get_client(self) -> TellClient: - if self._client is not None: - return self._client - - now = time.monotonic() - if now - self._last_attempt_ts < self._retry_interval_s and self._last_error is not None: - raise self._last_error - - self._last_attempt_ts = now - try: - self._client = TellClient(self._bl) - self._last_error = None - return self._client - except TellCommunicationError as e: - self._last_error = e - raise - except Exception as e: - wrapped = TellCommunicationError( - "TELL connection failed", - operation="CONNECT", - ) - self._last_error = wrapped - raise wrapped from e - - @property - def url(self): - return self._get_client().url - - # Delegate methods used by DAQ; add more as needed - def get_mounted_sample(self) -> SampleDewarAddress | None: - return self._get_client().get_mounted_sample() - - def get_state(self): - return self._get_client().get_state() - - def wait_not_busy(self, timeout: float = 360.0): - return self._get_client().wait_not_busy(timeout=timeout) - - def check_enable_motion(self): - return self._get_client().check_enable_motion() - - def set_in_mount_position(self, value): - return self._get_client().set_in_mount_position(value) - - def mount(self, *args, **kwargs): - return self._get_client().mount(*args, **kwargs) - - def unmount(self, *args, **kwargs): - return self._get_client().unmount(*args, **kwargs) - - def abort_cmd(self): - return self._get_client().abort_cmd() - -def make_tell_client(bl: MXBeamline) -> TellClient | SimTellClient | TellClientProxy: +def make_tell_client(bl: MXBeamline) -> TellClient: if bl == MXBeamline.SIMULATED: - return SimTellClient() - return TellClientProxy(bl, retry_interval_s=2.0) \ No newline at end of file + backend = SimTellBackend() + else: + backend = LazyTellBackend( + factory=lambda: PShellTellBackend(bl), + retry_interval_s=2.0, + ) + return TellClient(bl, backend=backend) \ No newline at end of file