diff --git a/_typos.toml b/_typos.toml index 742efae9454..1b5ef750b18 100644 --- a/_typos.toml +++ b/_typos.toml @@ -23,6 +23,9 @@ UE = "UE" commutated = "commutated" commutating = "commutating" DOUT = "DOUT" +LSI = "LSI" +LSO = "LSO" +SPP = "SPP" inconsistence = "inconsistence" mis = "mis" RHE = "RHE" diff --git a/docs/api/pylabrobot.capabilities.rst b/docs/api/pylabrobot.capabilities.rst index 666ce292e20..e3faa12c445 100644 --- a/docs/api/pylabrobot.capabilities.rst +++ b/docs/api/pylabrobot.capabilities.rst @@ -192,6 +192,21 @@ Barcode Scanning BarcodeScannerBackend +Plate Access +------------ + +.. currentmodule:: pylabrobot.capabilities.plate_access + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PlateAccess + PlateAccessBackend + PlateAccessState + + Microscopy ---------- diff --git a/docs/api/pylabrobot.labcyte.rst b/docs/api/pylabrobot.labcyte.rst new file mode 100644 index 00000000000..42987790a86 --- /dev/null +++ b/docs/api/pylabrobot.labcyte.rst @@ -0,0 +1,29 @@ +.. currentmodule:: pylabrobot.labcyte + +pylabrobot.labcyte package +========================== + +Echo +---- + +.. currentmodule:: pylabrobot.labcyte.echo + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + Echo + EchoDriver + EchoPlateMap + EchoInstrumentInfo + EchoSurveyParams + EchoSurveyWell + EchoSurveyData + EchoSurveyRunResult + EchoDryPlateMode + EchoDryPlateParams + EchoPlateAccessBackend + EchoError + EchoProtocolError + EchoCommandError diff --git a/docs/api/pylabrobot.rst b/docs/api/pylabrobot.rst index 53a04635937..0d51510302d 100644 --- a/docs/api/pylabrobot.rst +++ b/docs/api/pylabrobot.rst @@ -39,6 +39,7 @@ Manufacturers pylabrobot.byonoy pylabrobot.hamilton pylabrobot.inheco + pylabrobot.labcyte pylabrobot.liconic pylabrobot.mettler_toledo pylabrobot.molecular_devices diff --git a/docs/user_guide/capabilities/index.md b/docs/user_guide/capabilities/index.md index 2a091c83f64..0468c0e8346 100644 --- a/docs/user_guide/capabilities/index.md +++ b/docs/user_guide/capabilities/index.md @@ -55,6 +55,7 @@ loading-tray pumping weighing barcode-scanning +plate-access microscopy automated-retrieval absorbance diff --git a/docs/user_guide/capabilities/plate-access.md b/docs/user_guide/capabilities/plate-access.md new file mode 100644 index 00000000000..e56de24d41f --- /dev/null +++ b/docs/user_guide/capabilities/plate-access.md @@ -0,0 +1,69 @@ +# Plate Access + +The plate access capability standardizes a narrow but common class of machine interactions: +locking an instrument, presenting an access path for the source or destination side, polling +access state, and closing the door afterwards. + +This is useful for devices where the user-facing control surface is about getting hardware into +an accessible state rather than immediately running a transfer or assay. + +## API + +```python +from pylabrobot.capabilities.plate_access import PlateAccessState +``` + +Capability methods: + +- `lock(app=None, owner=None)` +- `unlock()` +- `get_access_state()` +- `open_source_plate(timeout=30.0, poll_interval=0.1) -> PlateAccessState` +- `close_source_plate(plate_type=None, barcode_location=None, barcode="", timeout=30.0, poll_interval=0.1) -> PlateAccessState` +- `open_destination_plate(timeout=30.0, poll_interval=0.1) -> PlateAccessState` +- `close_destination_plate(plate_type=None, barcode_location=None, barcode="", timeout=30.0, poll_interval=0.1) -> PlateAccessState` +- `close_door(timeout=30.0, poll_interval=0.1) -> PlateAccessState` + +`get_access_state()` returns a `PlateAccessState` with normalized fields for: + +- source access open/closed +- destination access open/closed when the backend can infer them +- door open/closed +- source and destination plate position values when available +- a `raw` dictionary with the backend's native state payload + +## Echo Example + +```python +import asyncio + +from pylabrobot.labcyte import Echo + + +async def main(): + async with Echo(host="192.168.0.25") as echo: + info = await echo.get_instrument_info() + print(info.model, info.serial_number) + + await echo.lock() + try: + baseline = await echo.get_access_state() + print("baseline:", baseline) + + opened = await echo.open_source_plate(timeout=2.0) + print("opened:", opened) + + retracted = await echo.close_source_plate(timeout=2.0) + print("retracted:", retracted) + + closed = await echo.close_door(timeout=2.0) + print("closed:", closed) + finally: + await echo.unlock() + + +asyncio.run(main()) +``` + +For the Echo integration, motion commands require an active lock. Read-only polling and +instrument info queries do not. diff --git a/docs/user_guide/index.md b/docs/user_guide/index.md index bb273f350cf..9a06c6a3b87 100644 --- a/docs/user_guide/index.md +++ b/docs/user_guide/index.md @@ -37,6 +37,7 @@ brooks/index byonoy/index hamilton/index inheco/index +labcyte/index liconic/index mettler_toledo/index molecular_devices/index diff --git a/docs/user_guide/labcyte/echo.md b/docs/user_guide/labcyte/echo.md new file mode 100644 index 00000000000..7e89ba36847 --- /dev/null +++ b/docs/user_guide/labcyte/echo.md @@ -0,0 +1,408 @@ +# Echo + +The Labcyte Echo integration currently targets the Medman surface validated against an Echo 650. +It covers safe mechanical access operations, source-plate survey workflows, PLR-native transfer +planning, and raw Echo protocol execution through `DoWellTransfer`. + +Supported operations: + +- fetch instrument identity with `get_instrument_info()` +- fetch Echo configuration, power calibration, protocol names, protocol payloads, and fluid metadata +- fetch typed power calibration, focus time-of-flight, scan-position, fluid, and plate-insert + metadata +- fetch typed Echo source/destination plate catalogs with `get_echo_plate_catalog()` +- reconcile PLR plates against Echo-defined plate types with `resolve_echo_plate_type()` +- clone/delete destination plate definitions through the direct Echo `SetPlateInfoEx` / + `RemovePlateInfo` API +- lock and unlock the instrument session +- poll `GetDIOEx2` through `get_access_state()` +- check registered source and destination plate presence +- present and retract the source-side access path +- present and retract the destination-side access path +- open and close the door +- home all axes +- control the coupling-fluid pump direction, bubbler pump, and bubbler nozzle +- control the vacuum pump/nozzle and ionizer +- read and set Echo focus time-of-flight values, and expose low-level power/scanner calibration + RPCs +- upload source plate maps with `set_plate_map()` +- run `PlateSurvey`, retrieve `GetSurveyData`, and run `DryPlate` +- build Echo protocol XML from PLR source wells, destination wells, and volumes +- execute an existing Echo transfer protocol XML with `do_well_transfer()` +- execute PLR-native transfers with `transfer()` or `transfer_wells()` +- parse transfer reports into completed and skipped wells +- update PLR volume trackers from survey and transfer results when volume tracking is enabled +- run source/destination load and eject workflows with caller-provided operator pauses +- model the physical Echo source and destination positions as PLR plate holders + +The driver matches the Echo's observed transport quirks: + +- HTTP-like `POST /Medman` +- gzip-compressed SOAP bodies +- LF-terminated request headers +- token-based client identity reused for lock ownership + +State normalization notes: + +- source access uses the explicit `LSO` / `LSI` bits when present and falls back to `SPP` +- destination access is currently inferred from `DPP` +- door state uses `DFO` / `DFC` when present and otherwise falls back to the normalized access + state + +Survey notes: + +- survey support is Echo-specific for now and lives in `pylabrobot.labcyte` +- the validated survey workflow is `SetPlateMap -> PlateSurvey`, with optional + `GetSurveyData` and `DryPlate(TWO_PASS)` +- retracting a loaded source plate is much slower than an empty retract; the + Echo integration uses longer default timeouts when `plate_type` is supplied + +Transfer notes: + +- `do_well_transfer()` is a thin wrapper around the Echo `DoWellTransfer` RPC +- `transfer()` accepts PLR wells directly, infers the source/destination plates, and executes them +- `transfer_wells()` builds the Echo protocol XML from explicit plates plus well references and + executes it +- `EchoTransferPrintOptions` controls the nested `PrintOptions` payload +- Echo transfer volumes are in nL by default; pass `volume_unit="uL"` to use PLR-style uL inputs +- successful transfer reports update PLR volume trackers only when PLR volume tracking is enabled + +Plate catalog notes: + +- the Echo instrument catalog is authoritative for Echo protocol plate type names +- if `source_plate_type` or `destination_plate_type` is omitted, PLR uses the plate resource's + `model` only when that exact name exists in the relevant Echo catalog +- transfer and load workflows fail before motion or mutation when the Echo does not know the + requested plate type +- PLR validates Echo `Rows` / `Columns` against the PLR plate grid before transfer planning +- source survey dimensions use Echo-reported columns, while PLR wells and volume trackers remain the + source of transfer intent and local state + +## Architecture + +The integration follows the PLR device/driver/capability split: + +- `Echo` is the user-facing device frontend. It exposes Echo operations, owns the source and + destination PLR plate holders, and delegates instrument I/O to its driver. +- `EchoDriver` owns the Medman protocol details: SOAP envelope construction, gzip framing, + lock tokens, polling, event streams, survey parsing, and transfer report parsing. +- `EchoPlateAccessBackend` adapts the Echo driver to the generic `PlateAccess` capability. +- Application code, web services, and workcell controllers should call the `Echo` frontend or the + `PlateAccess` capability instead of duplicating Medman transport logic. + +## Live Echo 650 Validation + +The non-live unit tests mock the Medman transport. Before relying on a new Echo firmware build or +new workflow code, run the opt-in live validation tests against an idle Echo 650: + +```bash +PYLABROBOT_ECHO_HOST=192.168.0.25 \ + uv run --extra dev pytest pylabrobot/labcyte/echo_live_tests.py +``` + +By default, the live validation reads identity, configuration, access state, plate catalogs, +protocol catalogs, and verifies that the event channel can be opened. It does not move plates, +grippers, or the door. + +To include a door open/close lock cycle, run the same test with explicit motion enabled: + +```bash +PYLABROBOT_ECHO_HOST=192.168.0.25 \ +PYLABROBOT_ECHO_VALIDATE_MOTION=1 \ + uv run --extra dev pytest pylabrobot/labcyte/echo_live_tests.py +``` + +The expected model defaults to `Echo 650`. Override it only when validating a compatible +instrument variant: + +```bash +PYLABROBOT_ECHO_EXPECTED_MODEL="Echo 655" +``` + +## Low-Level Metadata And Controls + +```python +import asyncio + +from pylabrobot.labcyte import Echo + +async def main(): + async with Echo(host="192.168.0.25") as echo: + info = await echo.get_instrument_info() + config_xml = await echo.get_echo_configuration() + fluid = await echo.get_fluid_info("DMSO") + protocols = await echo.get_all_protocol_names() + print(info.model, len(config_xml), fluid.name, protocols) + + await echo.lock() + try: + await echo.open_door() + await echo.home_axes() + await echo.set_pump_direction(True) + await echo.enable_bubbler_pump(True) + await echo.actuate_bubbler_nozzle(True) + await echo.enable_vacuum_nozzle(True) + await echo.actuate_ionizer(True) + finally: + await echo.unlock() + +asyncio.run(main()) +``` + +The actuator calls are low-level Echo RPCs. They require an instrument lock and should be validated +on the target Echo 650 before they are used in an automated workflow. + +## Loading And Ejecting Plates + +```python +import asyncio + +from pylabrobot.labcyte import Echo + +async def pause(message: str): + input(f"{message}. Press Enter when ready.") + +async def main(): + async with Echo(host="192.168.0.25") as echo: + await echo.lock() + try: + loaded = await echo.load_source_plate( + "384PP_DMSO2", + operator_pause=pause, + ) + print(loaded.plate_present, loaded.barcode) + + await echo.eject_source_plate(operator_pause=pause, open_door_first=True) + finally: + await echo.unlock() + +asyncio.run(main()) +``` + +The operator pause callback is optional. It is where an application should prompt the user to place +or remove the plate after the gripper has been presented. + +## Safe Source Access Cycle + +```python +import asyncio + +from pylabrobot.labcyte import Echo + + +async def main(): + async with Echo(host="192.168.0.25") as echo: + await echo.lock() + try: + opened = await echo.open_source_plate(timeout=2.0) + print(opened.raw) + + retracted = await echo.close_source_plate(timeout=2.0) + print(retracted.raw) + + closed = await echo.close_door(timeout=2.0) + print(closed.raw) + finally: + await echo.unlock() + + +asyncio.run(main()) +``` + +## Surveying a Source Plate + +```python +import asyncio + +from pylabrobot.labcyte import Echo, EchoPlateMap, EchoSurveyParams + +async def main(): + async with Echo(host="192.168.0.25") as echo: + await echo.lock() + try: + plate_map = EchoPlateMap( + plate_type="384PP_DMSO2", + well_identifiers=("A1", "A2", "B1", "B2"), + ) + result = await echo.survey_source_plate( + plate_map, + EchoSurveyParams( + plate_type="384PP_DMSO2", + num_rows=16, + num_cols=24, + ), + fetch_saved_data=True, + dry_after=True, + ) + print(result.saved_data.wells[0].identifier if result.saved_data else "no saved data") + finally: + await echo.unlock() + + +asyncio.run(main()) +``` + +`survey_source_plate()` does not change access state for you. It assumes the +plate is already loaded, retracted, and ready for survey. + +## Running an Existing Echo Transfer Protocol + +```python +import asyncio + +from pylabrobot.labcyte import Echo, EchoTransferPrintOptions + +PROTOCOL_XML = """ + + example + + +""" + +async def main(): + async with Echo(host="192.168.0.25") as echo: + await echo.lock() + try: + result = await echo.do_well_transfer( + PROTOCOL_XML, + EchoTransferPrintOptions( + do_plate_survey=True, + monitor_power=True, + save_print=True, + plate_map=True, + ), + timeout=300.0, + ) + print(result.report_xml or result.raw) + finally: + await echo.unlock() + + +asyncio.run(main()) +``` + +`do_well_transfer()` intentionally does not synthesize a protocol from PLR resources. Use it when +you already have a valid Echo transfer protocol XML document and want PyLabRobot to execute it +through Medman. + +## Running PLR-Native Transfers + +```python +import asyncio + +from pylabrobot.labcyte import Echo +from pylabrobot.resources import set_volume_tracking + +async def main(source_plate, destination_plate): + set_volume_tracking(True) + source_plate.get_well("A1").set_volume(1.0) # PLR trackers use uL. + + async with Echo(host="192.168.0.25") as echo: + await echo.lock() + try: + result = await echo.transfer( + [(source_plate.get_well("A1"), destination_plate.get_well("B1"), 5.0)], + source_plate_type="384PP_DMSO2", + destination_plate_type="1536LDV_Dest", + do_survey=True, + timeout=300.0, + ) + print(len(result.transfers), len(result.skipped)) + finally: + await echo.unlock() +``` + +The `Echo` frontend also exposes the physical plate positions as PLR resource holders: + +```python +echo.source_plate = source_plate +echo.destination_plate = destination_plate + +assert echo.source_plate is source_plate +assert source_plate.parent is echo.source_position +``` + +Use these positions to keep a PLR workcell model aligned with the plates physically loaded in the +Echo. The holders accept `Plate` resources only; source/destination access commands still control +the real instrument state. + +`transfer()` is the high-level PLR API. It accepts `Well` objects, infers one source plate and one +destination plate from their parents, and then uses the same execution path as `transfer_wells()`. +Use `transfer_wells()` when the caller has plate objects plus string well identifiers. Both methods +perform the observed Echo flow: source/destination checks, sparse source `SetPlateMap`, optional +source survey, status reads, protocol XML generation, `DoWellTransfer`, and structured report +parsing. When volume tracking is enabled, survey data can set measured source volumes and successful +transfer report entries move actual dispensed volume from source wells to destination wells. + +## Focus And Calibration + +PyEcho covers the same Medman transport and most normal operation calls, but its Focus tab is not +implemented. The PLR Echo driver includes the Focus/Calibration RPCs decoded from the installed Echo +client binaries and verified with packet capture on `echo-win`. + +Read-side helpers: + +- `get_echo_power_calibration()` parses `GetPwrCal` into typed amplitude, reference-energy, + feedback, and system-gain values +- `get_focus_tof()` / `get_duo_focus_tof()` read `GetTOFFocus` / `GetDuoTOFFocus` +- `get_coupling_fluid_sound_velocity()` reads `GetCouplingFluidSoundVelocity` +- `get_scan_positions()` reads `GetScanPositions` +- `get_calibration_plate_names()` reads `GetCalPlateNames` +- `get_focus_state()` gathers the focus, scan-position, sound-velocity, and power-calibration + reads into one state object + +Low-level calibration controls: + +- `set_focus_tof()` and `set_duo_focus_tof()` send Echo's string-valued numeric focus setters +- `calibrate_power()` and `commit_power_calibration()` expose `CalibratePower` / `CommitPwrCal` +- `retract_source_gripper_for_scan_calibration()` and + `retract_destination_gripper_for_scan_calibration()` expose the scan-calibration retract paths +- `calibrate_scanner()` and `cancel_scanner_calibration()` expose scanner calibration control +- `focal_sweep()` exposes the low-level `FocalSweep` RPC + +The read-side calls and no-op focus setter shape were live-verified. The calibration calls are +deliberately low-level and require an instrument lock because they can move hardware or change +calibration state. + +Additional catalog helpers decoded during the same pass: + +- `get_dio_ex()` for raw `GetDIOEx` +- `get_all_fluid_types()` and `get_fluids_for_plate()` +- `get_all_plate_inserts()` +- `get_transfer_volume_resolution_nl()` + +## Echo Plate Definitions + +PLR reads the source and destination plate definitions already registered on the Echo. It can create +a minimal transfer-compatible `Plate` from `EchoPlateInfo` with `create_plate_from_echo_info()`, but +that helper is not a manufacturer-precise labware definition. + +PLR can clone/delete destination plate definitions through the direct Echo Medman API, without Echo +Client Utility or vendor DLLs: + +- `clone_destination_plate_definition(base_plate_type, new_plate_type)` reads the existing + destination definition, sends the captured `SetPlateInfoEx` payload shape, and verifies the new + name appears in the destination catalog +- `delete_destination_plate_definition(plate_type)` sends `RemovePlateInfo` and verifies the name + leaves the destination catalog +- `set_plate_info_ex()` and `remove_plate_info()` remain low-level escape hatches + +Source plate definition writes are not exposed by PLR. In testing, the Echo Client Utility write +surface sent `SetPlateInfoEx`, accepted a cloned source definition, but registered it in the +destination catalog even when the source usage fields were preserved. Treat source definitions as +read-only from PLR for now. + +No pcapng capture is needed for catalog reconciliation, validation, transfer, survey, loading +existing Echo-defined plates, or cloning/deleting destination definitions. The destination write path +was decoded from an Echo Client Utility capture and verified by sending `SetPlateInfoEx` / +`RemovePlateInfo` directly to the instrument. A future capture is still useful if source +plate-definition writes need to be decoded beyond the observed `SetPlateInfoEx` behavior. + +## Scope + +This integration does not yet implement: + +- live validation of every low-level actuator and workflow call on every Echo 650 firmware build +- a CSV picklist parser or UI layer +- arbitrary plate-definition editing beyond cloning an existing destination definition +- source plate-definition writes diff --git a/docs/user_guide/labcyte/index.md b/docs/user_guide/labcyte/index.md new file mode 100644 index 00000000000..d4483549570 --- /dev/null +++ b/docs/user_guide/labcyte/index.md @@ -0,0 +1,8 @@ +# Labcyte + +```{toctree} +:maxdepth: 1 + +echo +workcell-integration +``` diff --git a/docs/user_guide/labcyte/workcell-integration.md b/docs/user_guide/labcyte/workcell-integration.md new file mode 100644 index 00000000000..cd66452ac10 --- /dev/null +++ b/docs/user_guide/labcyte/workcell-integration.md @@ -0,0 +1,86 @@ +# Echo Workcell Integration + +The `Echo` frontend models the physical source and destination positions as PLR resource holders. +That lets a workcell keep the resource tree aligned with real plate movement while the Echo driver +continues to handle Medman commands. + +## Moving Plates Into The Echo Model + +```python +from pylabrobot.labcyte import Echo +from pylabrobot.resources.resource_holder import ResourceHolder + + +async def move_plate_with_arm(arm, plate, source: ResourceHolder, destination: ResourceHolder): + """Move a plate physically, then update the PLR resource tree. + + Replace `arm.move_resource(...)` with the matching arm API in your workcell. + """ + + await arm.move_resource(plate, destination) + source.resource = None + destination.resource = plate + + +async def run_echo_transfer(arm, source_hotel_site, assay_hotel_site, source_plate, assay_plate): + echo = Echo(host="192.168.0.25", app_name="PLR Echo workcell") + + source_hotel_site.resource = source_plate + assay_hotel_site.resource = assay_plate + + async with echo: + await move_plate_with_arm( + arm, + source_plate, + source=source_hotel_site, + destination=echo.source_position, + ) + await move_plate_with_arm( + arm, + assay_plate, + source=assay_hotel_site, + destination=echo.destination_position, + ) + + await echo.lock() + try: + await echo.load_source_plate(source_plate.model or "384PP_DMSO2") + await echo.load_destination_plate(assay_plate.model or "1536LDV_Dest") + result = await echo.transfer( + [(source_plate.get_well("A1"), assay_plate.get_well("B1"), 2.5)], + do_survey=True, + ) + finally: + await echo.unlock() + + await echo.eject_destination_plate() + await move_plate_with_arm( + arm, + assay_plate, + source=echo.destination_position, + destination=assay_hotel_site, + ) + + await echo.eject_source_plate() + await move_plate_with_arm( + arm, + source_plate, + source=echo.source_position, + destination=source_hotel_site, + ) + + return result +``` + +`echo.source_position` and `echo.destination_position` are PLR holders and accept `Plate` resources +only. The convenience properties `echo.source_plate` and `echo.destination_plate` are shortcuts for +assigning or reading the held plates: + +```python +echo.source_plate = source_plate +echo.destination_plate = assay_plate +``` + +This model is intentionally separate from Echo access state. Assigning a plate to the holder updates +the PLR workcell model; calling `load_source_plate`, `load_destination_plate`, `eject_source_plate`, +or `eject_destination_plate` controls the real Echo. diff --git a/pylabrobot/capabilities/plate_access/__init__.py b/pylabrobot/capabilities/plate_access/__init__.py new file mode 100644 index 00000000000..4c5968b3d98 --- /dev/null +++ b/pylabrobot/capabilities/plate_access/__init__.py @@ -0,0 +1,3 @@ +from .backend import PlateAccessBackend, PlateAccessState +from .chatterbox import PlateAccessChatterboxBackend +from .plate_access import PlateAccess diff --git a/pylabrobot/capabilities/plate_access/backend.py b/pylabrobot/capabilities/plate_access/backend.py new file mode 100644 index 00000000000..5b51c06bc5d --- /dev/null +++ b/pylabrobot/capabilities/plate_access/backend.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from abc import ABCMeta, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Dict, Optional + +from pylabrobot.capabilities.capability import CapabilityBackend + + +@dataclass +class PlateAccessState: + """Machine access state returned by plate-access capable devices.""" + + source_access_open: Optional[bool] = None + source_access_closed: Optional[bool] = None + destination_access_open: Optional[bool] = None + destination_access_closed: Optional[bool] = None + door_open: Optional[bool] = None + door_closed: Optional[bool] = None + source_plate_position: Optional[int] = None + destination_plate_position: Optional[int] = None + raw: Dict[str, Any] = field(default_factory=dict) + + @property + def active_access_paths(self) -> tuple[str, ...]: + """Names of access paths currently known to be open.""" + active: list[str] = [] + if self.source_access_open is True: + active.append("source") + if self.destination_access_open is True: + active.append("destination") + return tuple(active) + + +class PlateAccessBackend(CapabilityBackend, metaclass=ABCMeta): + """Abstract backend for plate access operations.""" + + @abstractmethod + async def lock(self, app: Optional[str] = None, owner: Optional[str] = None) -> None: + """Lock the machine for exclusive control.""" + + @abstractmethod + async def unlock(self) -> None: + """Release the machine lock held by this client.""" + + @abstractmethod + async def get_access_state(self) -> PlateAccessState: + """Poll the current access state.""" + + @abstractmethod + async def open_source_plate(self, timeout: Optional[float] = None) -> None: + """Present the source-side plate access path.""" + + @abstractmethod + async def close_source_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: Optional[float] = None, + ) -> Optional[str]: + """Retract the source-side access path and return a read barcode when available.""" + + @abstractmethod + async def open_destination_plate(self, timeout: Optional[float] = None) -> None: + """Present the destination-side plate access path.""" + + @abstractmethod + async def close_destination_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: Optional[float] = None, + ) -> Optional[str]: + """Retract the destination-side access path and return a read barcode when available.""" + + @abstractmethod + async def close_door(self, timeout: Optional[float] = None) -> None: + """Close the machine door.""" diff --git a/pylabrobot/capabilities/plate_access/chatterbox.py b/pylabrobot/capabilities/plate_access/chatterbox.py new file mode 100644 index 00000000000..bd67680b639 --- /dev/null +++ b/pylabrobot/capabilities/plate_access/chatterbox.py @@ -0,0 +1,94 @@ +import logging +from typing import Optional + +from .backend import PlateAccessBackend, PlateAccessState + +logger = logging.getLogger(__name__) + + +class PlateAccessChatterboxBackend(PlateAccessBackend): + """Chatterbox backend for device-free testing.""" + + def __init__(self): + self._locked = False + self._state = PlateAccessState( + source_access_open=False, + source_access_closed=True, + destination_access_open=False, + destination_access_closed=True, + door_open=False, + door_closed=True, + source_plate_position=0, + destination_plate_position=0, + ) + + async def lock(self, app: Optional[str] = None, owner: Optional[str] = None) -> None: + logger.info("Locking plate access backend with app=%s owner=%s.", app, owner) + self._locked = True + + async def unlock(self) -> None: + logger.info("Unlocking plate access backend.") + self._locked = False + + async def get_access_state(self) -> PlateAccessState: + logger.info("Returning chatterbox access state.") + return self._state + + async def open_source_plate(self, timeout: Optional[float] = None) -> None: + logger.info("Opening source-side access.") + self._state.source_access_open = True + self._state.source_access_closed = False + self._state.source_plate_position = -1 + self._state.door_open = True + self._state.door_closed = False + + async def close_source_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: Optional[float] = None, + ) -> Optional[str]: + logger.info( + "Closing source-side access with plate_type=%s barcode_location=%s barcode=%s timeout=%s.", + plate_type, + barcode_location, + barcode, + timeout, + ) + self._state.source_access_open = False + self._state.source_access_closed = True + self._state.source_plate_position = 0 + return None + + async def open_destination_plate(self, timeout: Optional[float] = None) -> None: + logger.info("Opening destination-side access.") + self._state.destination_access_open = True + self._state.destination_access_closed = False + self._state.destination_plate_position = -1 + self._state.door_open = True + self._state.door_closed = False + + async def close_destination_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: Optional[float] = None, + ) -> Optional[str]: + logger.info( + "Closing destination-side access with plate_type=%s barcode_location=%s barcode=%s timeout=%s.", + plate_type, + barcode_location, + barcode, + timeout, + ) + self._state.destination_access_open = False + self._state.destination_access_closed = True + self._state.destination_plate_position = 0 + return None + + async def close_door(self, timeout: Optional[float] = None) -> None: + logger.info("Closing plate access door.") + self._state.door_open = False + self._state.door_closed = True diff --git a/pylabrobot/capabilities/plate_access/plate_access.py b/pylabrobot/capabilities/plate_access/plate_access.py new file mode 100644 index 00000000000..25e3fc293ba --- /dev/null +++ b/pylabrobot/capabilities/plate_access/plate_access.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import asyncio +import time +from typing import Callable, Optional + +from pylabrobot.capabilities.capability import Capability, need_capability_ready + +from .backend import PlateAccessBackend, PlateAccessState + + +class PlateAccess(Capability): + """Plate access capability. + + See :doc:`/user_guide/capabilities/plate-access` for a walkthrough. + """ + + def __init__(self, backend: PlateAccessBackend): + super().__init__(backend=backend) + self.backend: PlateAccessBackend = backend + + @need_capability_ready + async def lock(self, app: Optional[str] = None, owner: Optional[str] = None) -> None: + """Lock the machine for exclusive access.""" + await self.backend.lock(app=app, owner=owner) + + @need_capability_ready + async def unlock(self) -> None: + """Release the machine lock held by this client.""" + await self.backend.unlock() + + @need_capability_ready + async def get_access_state(self) -> PlateAccessState: + """Poll the current access state.""" + return await self.backend.get_access_state() + + async def _wait_for_access_state( + self, + predicate: Callable[[PlateAccessState], bool], + timeout: float = 30.0, + poll_interval: float = 0.1, + description: str = "plate access state", + ) -> PlateAccessState: + """Wait for a normalized plate-access state predicate to become true.""" + deadline = time.monotonic() + timeout + while True: + state = await self.backend.get_access_state() + if predicate(state): + return state + if time.monotonic() >= deadline: + raise TimeoutError(f"Timed out waiting for {description}.") + await asyncio.sleep(poll_interval) + + def _remaining_timeout(self, deadline: float) -> float: + return max(0.0, deadline - time.monotonic()) + + @need_capability_ready + async def open_source_plate( + self, + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Present the source-side access path and return the final access state.""" + deadline = time.monotonic() + timeout + await self.backend.open_source_plate(timeout=self._remaining_timeout(deadline)) + return await self._wait_for_access_state( + lambda state: state.source_access_open is True, + timeout=self._remaining_timeout(deadline), + poll_interval=poll_interval, + description="source access to open", + ) + + @need_capability_ready + async def close_source_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Retract the source-side access path and return the final access state.""" + deadline = time.monotonic() + timeout + barcode_result = await self.backend.close_source_plate( + plate_type=plate_type, + barcode_location=barcode_location, + barcode=barcode, + timeout=self._remaining_timeout(deadline), + ) + state = await self._wait_for_access_state( + lambda state: state.source_access_closed is True, + timeout=self._remaining_timeout(deadline), + poll_interval=poll_interval, + description="source access to close", + ) + if barcode_result not in (None, ""): + state.raw = {**state.raw, "barcode": str(barcode_result)} + return state + + @need_capability_ready + async def open_destination_plate( + self, + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Present the destination-side access path and return the final access state.""" + deadline = time.monotonic() + timeout + await self.backend.open_destination_plate(timeout=self._remaining_timeout(deadline)) + return await self._wait_for_access_state( + lambda state: state.destination_access_open is True, + timeout=self._remaining_timeout(deadline), + poll_interval=poll_interval, + description="destination access to open", + ) + + @need_capability_ready + async def close_destination_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Retract the destination-side access path and return the final access state.""" + deadline = time.monotonic() + timeout + barcode_result = await self.backend.close_destination_plate( + plate_type=plate_type, + barcode_location=barcode_location, + barcode=barcode, + timeout=self._remaining_timeout(deadline), + ) + state = await self._wait_for_access_state( + lambda state: state.destination_access_closed is True, + timeout=self._remaining_timeout(deadline), + poll_interval=poll_interval, + description="destination access to close", + ) + if barcode_result not in (None, ""): + state.raw = {**state.raw, "barcode": str(barcode_result)} + return state + + @need_capability_ready + async def close_door( + self, + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Close the machine door and return the final access state.""" + deadline = time.monotonic() + timeout + await self.backend.close_door(timeout=self._remaining_timeout(deadline)) + return await self._wait_for_access_state( + lambda state: state.door_closed is True, + timeout=self._remaining_timeout(deadline), + poll_interval=poll_interval, + description="door to close", + ) diff --git a/pylabrobot/capabilities/plate_access/plate_access_tests.py b/pylabrobot/capabilities/plate_access/plate_access_tests.py new file mode 100644 index 00000000000..609361182fd --- /dev/null +++ b/pylabrobot/capabilities/plate_access/plate_access_tests.py @@ -0,0 +1,198 @@ +import unittest +from unittest.mock import AsyncMock, Mock + +from pylabrobot.capabilities.plate_access import PlateAccess, PlateAccessBackend, PlateAccessState + + +class TestPlateAccess(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.mock_backend = Mock(spec=PlateAccessBackend) + self.mock_backend.lock = AsyncMock() + self.mock_backend.unlock = AsyncMock() + self.mock_backend.get_access_state = AsyncMock( + return_value=PlateAccessState( + source_access_open=False, + source_access_closed=True, + destination_access_open=False, + destination_access_closed=True, + door_open=False, + door_closed=True, + ) + ) + self.mock_backend.open_source_plate = AsyncMock() + self.mock_backend.close_source_plate = AsyncMock(return_value=None) + self.mock_backend.open_destination_plate = AsyncMock() + self.mock_backend.close_destination_plate = AsyncMock(return_value=None) + self.mock_backend.close_door = AsyncMock() + + async def _make_cap(self): + cap = PlateAccess(backend=self.mock_backend) + await cap._on_setup() + return cap + + async def test_lock(self): + cap = await self._make_cap() + await cap.lock(app="PyLabRobot", owner="tester") + self.mock_backend.lock.assert_awaited_once_with(app="PyLabRobot", owner="tester") + + async def test_get_access_state(self): + cap = await self._make_cap() + state = await cap.get_access_state() + self.assertIsInstance(state, PlateAccessState) + self.mock_backend.get_access_state.assert_awaited_once() + + async def test_source_plate_methods(self): + opened_state = PlateAccessState( + source_access_open=True, + source_access_closed=False, + door_open=True, + door_closed=False, + source_plate_position=-1, + ) + closed_state = PlateAccessState( + source_access_open=False, + source_access_closed=True, + door_open=True, + door_closed=False, + source_plate_position=0, + ) + self.mock_backend.get_access_state = AsyncMock(side_effect=[opened_state, closed_state]) + cap = await self._make_cap() + opened = await cap.open_source_plate(timeout=5.0, poll_interval=0.001) + closed = await cap.close_source_plate(timeout=5.0, poll_interval=0.001) + self.mock_backend.open_source_plate.assert_awaited_once() + self.assertGreaterEqual(self.mock_backend.open_source_plate.await_args.kwargs["timeout"], 0.0) + self.mock_backend.close_source_plate.assert_awaited_once_with( + plate_type=None, + barcode_location=None, + barcode="", + timeout=self.mock_backend.close_source_plate.await_args.kwargs["timeout"], + ) + self.assertTrue(opened.source_access_open) + self.assertTrue(closed.source_access_closed) + + async def test_source_close_preserves_backend_barcode(self): + self.mock_backend.close_source_plate = AsyncMock(return_value="SRC123") + self.mock_backend.get_access_state = AsyncMock( + return_value=PlateAccessState(source_access_closed=True, raw={"SPP": 0}) + ) + cap = await self._make_cap() + + state = await cap.close_source_plate(timeout=5.0, poll_interval=0.001) + + self.assertEqual(state.raw["barcode"], "SRC123") + + async def test_destination_plate_methods(self): + opened_state = PlateAccessState( + destination_access_open=True, + destination_access_closed=False, + door_open=True, + door_closed=False, + destination_plate_position=-1, + ) + closed_state = PlateAccessState( + destination_access_open=False, + destination_access_closed=True, + door_open=True, + door_closed=False, + destination_plate_position=0, + ) + self.mock_backend.get_access_state = AsyncMock(side_effect=[opened_state, closed_state]) + cap = await self._make_cap() + opened = await cap.open_destination_plate(timeout=5.0, poll_interval=0.001) + closed = await cap.close_destination_plate( + plate_type="Foo", + barcode_location="Rear", + barcode="123", + timeout=5.0, + poll_interval=0.001, + ) + self.mock_backend.open_destination_plate.assert_awaited_once() + self.mock_backend.close_destination_plate.assert_awaited_once_with( + plate_type="Foo", + barcode_location="Rear", + barcode="123", + timeout=self.mock_backend.close_destination_plate.await_args.kwargs["timeout"], + ) + self.assertTrue(opened.destination_access_open) + self.assertTrue(closed.destination_access_closed) + + async def test_destination_close_preserves_backend_barcode(self): + self.mock_backend.close_destination_plate = AsyncMock(return_value="DST123") + self.mock_backend.get_access_state = AsyncMock( + return_value=PlateAccessState(destination_access_closed=True, raw={"DPP": 0}) + ) + cap = await self._make_cap() + + state = await cap.close_destination_plate(timeout=5.0, poll_interval=0.001) + + self.assertEqual(state.raw["barcode"], "DST123") + + async def test_source_plate_timeout_passthrough(self): + self.mock_backend.get_access_state = AsyncMock( + return_value=PlateAccessState(source_access_closed=True) + ) + cap = await self._make_cap() + await cap.close_source_plate(plate_type="384PP_DMSO2", timeout=30.0, poll_interval=0.001) + self.mock_backend.close_source_plate.assert_awaited_once_with( + plate_type="384PP_DMSO2", + barcode_location=None, + barcode="", + timeout=self.mock_backend.close_source_plate.await_args.kwargs["timeout"], + ) + self.assertGreaterEqual(self.mock_backend.close_source_plate.await_args.kwargs["timeout"], 0.0) + + async def test_close_door(self): + closed_state = PlateAccessState( + source_access_open=False, + source_access_closed=True, + destination_access_open=False, + destination_access_closed=True, + door_open=False, + door_closed=True, + ) + self.mock_backend.get_access_state = AsyncMock(return_value=closed_state) + cap = await self._make_cap() + state = await cap.close_door(timeout=5.0, poll_interval=0.001) + self.mock_backend.close_door.assert_awaited_once() + self.assertTrue(state.door_closed) + + async def test_action_methods_wait_for_expected_state(self): + self.mock_backend.get_access_state = AsyncMock( + side_effect=[ + PlateAccessState( + source_access_open=False, destination_access_closed=True, door_closed=False + ), + PlateAccessState( + source_access_open=True, destination_access_closed=False, door_closed=False + ), + PlateAccessState( + source_access_open=False, destination_access_closed=True, door_closed=True + ), + ] + ) + cap = await self._make_cap() + + opened_state = await cap.open_source_plate(timeout=0.1, poll_interval=0.001) + final_state = await cap.close_door(timeout=0.1, poll_interval=0.001) + + self.assertTrue(opened_state.source_access_open) + self.assertTrue(final_state.door_closed) + + async def test_action_timeout_raises(self): + self.mock_backend.get_access_state = AsyncMock( + return_value=PlateAccessState(source_access_open=False) + ) + cap = await self._make_cap() + + with self.assertRaises(TimeoutError): + await cap.open_source_plate(timeout=0.01, poll_interval=0.001) + + async def test_not_setup_raises(self): + cap = PlateAccess(backend=self.mock_backend) + with self.assertRaises(RuntimeError): + await cap.open_source_plate() + + +if __name__ == "__main__": + unittest.main() diff --git a/pylabrobot/labcyte/__init__.py b/pylabrobot/labcyte/__init__.py new file mode 100644 index 00000000000..3641d92aa20 --- /dev/null +++ b/pylabrobot/labcyte/__init__.py @@ -0,0 +1,39 @@ +from .echo import ( + Echo, + EchoCommandError, + EchoDryPlateMode, + EchoDryPlateParams, + EchoDriver, + EchoEvent, + EchoEventStream, + EchoFocalSweepParams, + EchoFocusState, + EchoError, + EchoFluidInfo, + EchoInstrumentInfo, + EchoPlannedTransfer, + EchoPlateAccessBackend, + EchoPlateCatalog, + EchoPlateInfo, + EchoPlateMap, + EchoPlatePosition, + EchoPowerCalibration, + EchoPowerCalibrationResult, + EchoScanPositions, + EchoScannerCalibrationResult, + EchoPlateWorkflowResult, + EchoProtocolError, + EchoResolvedPlateType, + EchoSurveyData, + EchoSurveyParams, + EchoSurveyRunResult, + EchoSurveyWell, + EchoSkippedWell, + EchoTransferPlan, + EchoTransferInput, + EchoTransferPrintOptions, + EchoTransferResult, + EchoTransferredWell, + build_echo_transfer_plan, + create_plate_from_echo_info, +) diff --git a/pylabrobot/labcyte/echo.py b/pylabrobot/labcyte/echo.py new file mode 100644 index 00000000000..4a73750890c --- /dev/null +++ b/pylabrobot/labcyte/echo.py @@ -0,0 +1,4301 @@ +from __future__ import annotations + +import asyncio +import enum +import gzip +import html +import inspect +import logging +import os +import re +import socket +import time +import xml.etree.ElementTree as ET +import zlib +from dataclasses import dataclass, field +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Dict, + Iterable, + Optional, + Sequence, + Tuple, + Union, +) + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.plate_access import PlateAccess, PlateAccessBackend, PlateAccessState +from pylabrobot.device import Device, Driver, need_setup_finished +from pylabrobot.resources.coordinate import Coordinate +from pylabrobot.resources.plate import Plate +from pylabrobot.resources.resource import Resource +from pylabrobot.resources.resource_holder import ResourceHolder +from pylabrobot.resources.utils import create_ordered_items_2d, label_to_row_index, split_identifier +from pylabrobot.resources.volume_tracker import does_volume_tracking +from pylabrobot.resources.well import Well + +ET.register_namespace("SOAP-ENV", "http://schemas.xmlsoap.org/soap/envelope/") + +logger = logging.getLogger(__name__) + +HTTP_HEADER_END = b"\r\n\r\n" +DEFAULT_SLOT_A = 15588 +DEFAULT_SLOT_B = 8240 +DEFAULT_RPC_PORT = 8000 +DEFAULT_EVENT_PORT = 8010 +DEFAULT_TIMEOUT = 10.0 +DEFAULT_LOADED_RETRACT_TIMEOUT = 30.0 +DEFAULT_SURVEY_TIMEOUT = 120.0 +DEFAULT_DRY_TIMEOUT = 30.0 +DEFAULT_HOME_TIMEOUT = 60.0 +DEFAULT_TRANSFER_TIMEOUT = 300.0 +DEFAULT_ECHO_CONFIGURATION_QUERY = ( + '' +) +ECHO_TRANSFER_VOLUME_INCREMENT_NL = 2.5 + +OperatorPause = Callable[[str], Union[None, Awaitable[None]]] + + +class EchoError(Exception): + """Base error for Echo interactions.""" + + +class EchoProtocolError(EchoError): + """Raised when the Echo returns malformed data.""" + + +class EchoCommandError(EchoError): + """Raised when the Echo rejects a command or required state is missing.""" + + def __init__(self, method: str, status: Optional[str] = None): + message = f"{method} failed" + if status: + message = f"{message}: {status}" + super().__init__(message) + self.method = method + self.status = status + + +@dataclass +class EchoInstrumentInfo: + """Identity information returned by ``GetInstrumentInfo``.""" + + serial_number: str + instrument_name: str + ip_address: str + software_version: str + boot_time: str + instrument_status: str + model: str + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class EchoFluidInfo: + """Fluid metadata returned by ``GetFluidInfo``.""" + + name: str + description: str + fc_min: Optional[float] + fc_max: Optional[float] + fc_units: str + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class EchoPowerCalibration: + """Power calibration values returned by ``GetPwrCal``.""" + + amplitude: Optional[float] + reference_energy: Optional[float] + amp_feedback: Optional[float] + system_gain: Optional[float] + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class EchoPowerCalibrationResult: + """Measured values returned by ``CalibratePower``.""" + + amp_feedback: Optional[float] + pulse_energy: Optional[float] + vpp: Optional[float] + status: str = "" + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class EchoScanPositions: + """Scanner calibration position flags returned by ``GetScanPositions``.""" + + left_up: Optional[bool] = None + left_down: Optional[bool] = None + right_up: Optional[bool] = None + right_down: Optional[bool] = None + bottom_up: Optional[bool] = None + bottom_down: Optional[bool] = None + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class EchoFocusState: + """Read-side Echo focus and calibration state.""" + + tof_focus: Optional[float] + duo_tof_focus: Tuple[Optional[float], Optional[float]] + coupling_fluid_sound_velocity: Optional[float] + scan_positions: EchoScanPositions + power_calibration: EchoPowerCalibration + + +@dataclass(frozen=True) +class EchoScannerCalibrationResult: + """Result returned by ``CalibrateScanner``.""" + + barcode: Optional[str] + status: str = "" + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class EchoPlateInfo: + """Plate metadata returned by the Echo instrument catalog.""" + + name: str + rows: int + columns: int + well_capacity: Optional[float] = None + fluid: str = "" + plate_format: str = "" + usage: str = "" + barcode_location: Optional[str] = None + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class EchoPlateCatalog: + """Source and destination plate definitions registered on the Echo.""" + + source: Dict[str, EchoPlateInfo] + destination: Dict[str, EchoPlateInfo] + + def for_side(self, side: str) -> Dict[str, EchoPlateInfo]: + normalized = _normalize_plate_side(side) + return self.source if normalized == "source" else self.destination + + +@dataclass(frozen=True) +class EchoResolvedPlateType: + """A PLR plate reconciled against an Echo plate type.""" + + side: str + plate_type: str + info: EchoPlateInfo + requested_plate_type: Optional[str] = None + derived_from: str = "explicit" + + +@dataclass(frozen=True) +class EchoPlateMap: + """Echo plate-map payload built from canonical PLR well identifiers.""" + + plate_type: str + well_identifiers: Tuple[str, ...] + + @classmethod + def from_plate( + cls, + plate: Plate, + *, + plate_type: str, + wells: Optional[Sequence[str]] = None, + ) -> "EchoPlateMap": + if wells is None: + identifiers = tuple(well.get_identifier() for well in plate.get_all_items()) + else: + identifiers = tuple(plate.get_well(identifier).get_identifier() for identifier in wells) + return cls(plate_type=plate_type, well_identifiers=identifiers) + + def to_xml(self) -> str: + plate_map = ET.Element("PlateMap", {"p": self.plate_type}) + wells = ET.SubElement(plate_map, "Wells") + for identifier in self.well_identifiers: + row_label, column_label = split_identifier(identifier) + ET.SubElement( + wells, + "Well", + { + "n": identifier, + "r": str(label_to_row_index(row_label)), + "c": str(int(column_label) - 1), + "wc": "", + "sid": "", + }, + ) + return ET.tostring(plate_map, encoding="unicode", short_empty_elements=True) + + +class EchoDryPlateMode(enum.Enum): + """Observed DryPlate modes.""" + + TWO_PASS = "TWO_PASS" + + +@dataclass +class EchoSurveyParams(BackendParams): + """Parameters for Echo ``PlateSurvey``.""" + + plate_type: str + num_rows: int + num_cols: int + start_row: int = 0 + start_col: int = 0 + save: bool = True + check_source: bool = False + timeout: Optional[float] = None + + +@dataclass +class EchoDryPlateParams(BackendParams): + """Parameters for Echo ``DryPlate``.""" + + mode: EchoDryPlateMode = EchoDryPlateMode.TWO_PASS + timeout: Optional[float] = None + + +@dataclass +class EchoFocalSweepParams(BackendParams): + """Parameters for the low-level Echo ``FocalSweep`` calibration RPC.""" + + plate_type: str + well_row: int + well_column: int + start_tof: float + stop_tof: float + increment_z: float + start_z: float + stop_z: float + feature: int = 0 + timeout: Optional[float] = None + + +@dataclass +class EchoSurveyWell: + """Single well entry parsed from Echo survey XML.""" + + identifier: str + row: int + column: int + volume_nl: Optional[float] = None + current_volume_nl: Optional[float] = None + fluid: str = "" + fluid_units: str = "" + raw_attributes: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class EchoSurveyData: + """Parsed survey dataset plus the original XML payload.""" + + plate_type: Optional[str] + wells: list[EchoSurveyWell] + raw_xml: str + raw_attributes: Dict[str, str] = field(default_factory=dict) + + @classmethod + def from_xml(cls, xml_text: str) -> "EchoSurveyData": + normalized_xml = html.unescape(xml_text).strip() + try: + root = ET.fromstring(normalized_xml) + except ET.ParseError as exc: + raise EchoProtocolError("Malformed survey XML.") from exc + + plate_type = ( + root.attrib.get("p") + or root.attrib.get("PlateType") + or root.attrib.get("plate_type") + or root.attrib.get("plateType") + ) + wells: list[EchoSurveyWell] = [] + seen: set[tuple[str, int, int]] = set() + for element in root.iter(): + attributes = {str(key): str(value) for key, value in element.attrib.items()} + identifier = attributes.get("n") or attributes.get("name") + if identifier is None: + continue + + row_value = attributes.get("r") + column_value = attributes.get("c") + row: Optional[int] = None + column: Optional[int] = None + if row_value is not None and column_value is not None: + try: + row = int(row_value) + column = int(column_value) + except ValueError: + row = None + column = None + if row is None or column is None: + try: + row_label, column_label = split_identifier(identifier) + except ValueError: + continue + row = label_to_row_index(row_label) + column = int(column_label) - 1 + + key = (identifier, row, column) + if key in seen: + continue + seen.add(key) + wells.append( + EchoSurveyWell( + identifier=identifier, + row=row, + column=column, + volume_nl=_float_or_none( + attributes.get("vl") or attributes.get("volume") or attributes.get("volume_nL") + ), + current_volume_nl=_float_or_none(attributes.get("cvl")), + fluid=attributes.get("fld", ""), + fluid_units=attributes.get("fldu", ""), + raw_attributes=attributes, + ) + ) + + return cls( + plate_type=plate_type, + wells=wells, + raw_xml=normalized_xml, + raw_attributes={str(key): str(value) for key, value in root.attrib.items()}, + ) + + @property + def barcode(self) -> Optional[str]: + """Return the plate barcode when Echo included one in the survey payload.""" + + for key, value in self.raw_attributes.items(): + normalized_key = key.lower().replace("_", "") + if normalized_key in {"barcode", "platebarcode", "srcbarcode", "sourcebarcode", "bc"}: + return value + for key, value in self.raw_attributes.items(): + if "barcode" in key.lower(): + return value + return None + + def apply_volumes_to_plate(self, plate: Plate, *, prefer_current: bool = True) -> int: + """Set PLR well volumes from Echo survey volume fields. + + Echo reports nanoliters; PLR volume trackers use microliters. + """ + + updated = 0 + for well_data in self.wells: + volume_nl = ( + well_data.current_volume_nl + if prefer_current and well_data.current_volume_nl is not None + else well_data.volume_nl + ) + if volume_nl is None: + continue + well = plate.get_well(well_data.identifier) + if well.tracker.is_disabled: + continue + well.tracker.set_volume(_nl_to_ul(volume_nl)) + updated += 1 + return updated + + +@dataclass +class EchoSurveyRunResult: + """Combined result from the high-level survey helper.""" + + response_data: Optional[EchoSurveyData] = None + saved_data: Optional[EchoSurveyData] = None + dry_mode: Optional[EchoDryPlateMode] = None + + +@dataclass +class EchoTransferPrintOptions(BackendParams): + """Options passed to ``DoWellTransfer``.""" + + do_plate_survey: bool = False + monitor_power: bool = False + homogeneous_plate: bool = False + save_survey: bool = False + save_print: bool = False + source_plate_sensor: bool = False + destination_plate_sensor: bool = False + source_plate_sensor_override: bool = False + destination_plate_sensor_override: bool = False + plate_map: bool = False + + def to_params(self) -> Tuple[Tuple[str, str, str], ...]: + return ( + ("DoPlateSurvey", "boolean", _format_bool(self.do_plate_survey)), + ("MonitorPower", "boolean", _format_bool(self.monitor_power)), + ("HomogeneousPlate", "boolean", _format_bool(self.homogeneous_plate)), + ("SaveSurvey", "boolean", _format_bool(self.save_survey)), + ("SavePrint", "boolean", _format_bool(self.save_print)), + ("SrcPlateSensor", "boolean", _format_bool(self.source_plate_sensor)), + ("DstPlateSensor", "boolean", _format_bool(self.destination_plate_sensor)), + ("SrcPlateSensorOverride", "boolean", _format_bool(self.source_plate_sensor_override)), + ("DstPlateSensorOverride", "boolean", _format_bool(self.destination_plate_sensor_override)), + ("PlateMap", "boolean", _format_bool(self.plate_map)), + ) + + +@dataclass(frozen=True) +class EchoPlannedTransfer: + """One PLR well-to-well Echo transfer in Echo-native nL.""" + + source: Well + destination: Well + volume_nl: float + + @property + def source_identifier(self) -> str: + return self.source.get_identifier() + + @property + def destination_identifier(self) -> str: + return self.destination.get_identifier() + + @property + def volume_ul(self) -> float: + return _nl_to_ul(self.volume_nl) + + +@dataclass(frozen=True) +class EchoTransferPlan: + """Protocol XML and source plate map generated from PLR resources.""" + + source_plate: Plate + destination_plate: Plate + source_plate_type: str + destination_plate_type: str + protocol_name: str + transfers: Tuple[EchoPlannedTransfer, ...] + protocol_xml: str + plate_map: EchoPlateMap + + +EchoTransferInput = Union[EchoPlannedTransfer, Tuple[Well, Well, float]] + + +def _infer_transfer_plates(transfers: Sequence[EchoTransferInput]) -> Tuple[Plate, Plate]: + source_plate: Optional[Plate] = None + destination_plate: Optional[Plate] = None + + for transfer in transfers: + if isinstance(transfer, EchoPlannedTransfer): + source = transfer.source + destination = transfer.destination + else: + source, destination, _volume = transfer + + if not isinstance(source.parent, Plate): + raise ValueError(f"Source well {source.name!r} is not assigned to a PLR Plate.") + if not isinstance(destination.parent, Plate): + raise ValueError(f"Destination well {destination.name!r} is not assigned to a PLR Plate.") + + if source_plate is None: + source_plate = source.parent + elif source.parent is not source_plate: + raise ValueError("Echo transfer() currently supports one source plate per call.") + + if destination_plate is None: + destination_plate = destination.parent + elif destination.parent is not destination_plate: + raise ValueError("Echo transfer() currently supports one destination plate per call.") + + if source_plate is None or destination_plate is None: + raise ValueError("At least one transfer is required.") + + return source_plate, destination_plate + + +@dataclass +class EchoTransferredWell: + """One completed well transfer parsed from an Echo transfer report.""" + + source_identifier: str + source_row: int + source_column: int + destination_identifier: str + destination_row: int + destination_column: int + requested_volume_nl: Optional[float] = None + actual_volume_nl: Optional[float] = None + current_volume_nl: Optional[float] = None + starting_volume_nl: Optional[float] = None + timestamp: str = "" + fluid: str = "" + fluid_units: str = "" + composition: Optional[float] = None + fluid_thickness: Optional[float] = None + reason: str = "" + raw_attributes: Dict[str, str] = field(default_factory=dict) + + @property + def tracker_volume_nl(self) -> Optional[float]: + return self.actual_volume_nl if self.actual_volume_nl is not None else self.requested_volume_nl + + +@dataclass +class EchoSkippedWell: + """One skipped transfer parsed from an Echo transfer report.""" + + source_identifier: str + source_row: int + source_column: int + destination_identifier: str + destination_row: int + destination_column: int + requested_volume_nl: Optional[float] = None + reason: str = "" + raw_attributes: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class EchoTransferResult: + """Result returned by ``DoWellTransfer``.""" + + report_xml: Optional[str] + raw: Dict[str, Any] = field(default_factory=dict) + succeeded: Optional[bool] = None + status: Optional[str] = None + source_plate_type: Optional[str] = None + destination_plate_type: Optional[str] = None + date: str = "" + serial_number: str = "" + transfers: list[EchoTransferredWell] = field(default_factory=list) + skipped: list[EchoSkippedWell] = field(default_factory=list) + + +@dataclass +class EchoPlateWorkflowResult: + """Result from a high-level source/destination load or eject workflow.""" + + side: str + plate_type: Optional[str] + plate_present: bool + barcode: str = "" + current_plate_type: Optional[str] = None + dio: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class EchoEvent: + """Single callback event emitted on the Echo event channel.""" + + event_id: Optional[str] + source: str + payload: str + timestamp: Optional[str] + raw: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class _HttpMessage: + start_line: str + headers: Dict[str, str] + body: bytes + + def decoded_body_bytes(self) -> bytes: + payload = self.body + if _is_probably_gzip(payload): + if not _gzip_stream_complete(payload): + decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS) + partial = decompressor.decompress(payload) + text = partial.decode("utf-8", errors="replace") + if "" in text or "" in text: + logger.warning( + "Echo gzip body was missing its end-of-stream marker; using complete decoded XML payload." + ) + return text.encode("utf-8") + repaired_text = _repair_partial_xml_document(text) + if repaired_text is not None: + logger.warning( + "Echo gzip body was missing its end-of-stream marker and final XML closing tags; " + "using repaired decoded XML payload." + ) + return repaired_text.encode("utf-8") + logger.warning("Incomplete Echo gzip body tail: %r", text[-500:]) + raise EchoProtocolError( + f"Incomplete gzip-compressed Echo HTTP body ({len(payload)} bytes)." + ) + try: + payload = gzip.decompress(payload) + except (EOFError, OSError, zlib.error) as exc: + raise EchoProtocolError("Failed to decompress gzip-compressed Echo HTTP body.") from exc + return payload + + def decoded_body(self) -> str: + return self.decoded_body_bytes().decode("utf-8", errors="replace") + + +@dataclass +class _RpcResult: + method: str + values: Dict[str, Any] + succeeded: Optional[bool] + status: Optional[str] + + +def _local_name(tag: str) -> str: + if "}" in tag: + return tag.rsplit("}", 1)[1] + return tag + + +def _parse_scalar(value: str) -> Any: + normalized = value.strip() + lowered = normalized.lower() + if lowered == "true": + return True + if lowered == "false": + return False + try: + return int(normalized) + except ValueError: + pass + try: + return float(normalized) + except ValueError: + return normalized + + +def _element_value(element: ET.Element) -> Any: + if len(element) == 0: + text = element.text or "" + return _parse_scalar(text) if text.strip() else "" + return "".join( + ET.tostring(child, encoding="unicode", short_empty_elements=True) for child in element + ) + + +def _value_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if value in (None, ""): + return [] + return [value] + + +def _bool_or_none(value: Any) -> Optional[bool]: + if isinstance(value, bool): + return value + if value in (None, ""): + return None + normalized = str(value).strip().lower() + if normalized == "true": + return True + if normalized == "false": + return False + return None + + +def _float_or_none(value: Any) -> Optional[float]: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _float_values(value: Any) -> list[float]: + values: list[float] = [] + for item in _value_list(value): + numeric = _float_or_none(item) + if numeric is not None: + values.append(numeric) + return values + + +def _format_numeric_string(value: float) -> str: + numeric = float(value) + if numeric.is_integer(): + return str(int(numeric)) + return f"{numeric:g}" + + +def _int_or_zero(value: Any) -> int: + try: + return int(value) + except (TypeError, ValueError): + return 0 + + +def _nl_to_ul(volume_nl: float) -> float: + return volume_nl / 1000.0 + + +def _normalize_volume_nl(volume: float, volume_unit: str) -> float: + normalized_unit = volume_unit.strip().lower().replace("µ", "u") + if normalized_unit in {"nl", "nanoliter", "nanoliters"}: + return float(volume) + if normalized_unit in {"ul", "microliter", "microliters"}: + return float(volume) * 1000.0 + raise ValueError("volume_unit must be 'nL' or 'uL'.") + + +def _validate_transfer_volume_nl(volume_nl: float, context: str = "") -> None: + prefix = f"{context}: " if context else "" + if volume_nl <= 0: + raise ValueError(f"{prefix}volume must be positive, got {volume_nl} nL.") + units = volume_nl / ECHO_TRANSFER_VOLUME_INCREMENT_NL + if abs(units - round(units)) > 1e-9: + raise ValueError( + f"{prefix}volume {volume_nl} nL is not a multiple of {ECHO_TRANSFER_VOLUME_INCREMENT_NL} nL." + ) + + +def _format_transfer_volume_nl(volume_nl: float) -> str: + if float(volume_nl).is_integer(): + return str(int(volume_nl)) + return f"{volume_nl:g}" + + +def _resolve_plate_type(plate: Plate, plate_type: Optional[str], role: str) -> str: + if plate_type: + return plate_type + if plate.model: + return plate.model + raise ValueError( + f"{role} plate type is required when the PLR plate has no model matching an Echo plate type." + ) + + +def _normalize_plate_side(side: str) -> str: + normalized = side.strip().lower() + if normalized in {"source", "src"}: + return "source" + if normalized in {"destination", "dest", "dst"}: + return "destination" + raise ValueError("side must be 'source' or 'destination'.") + + +def _format_plate_catalog_names(names: Iterable[str]) -> str: + ordered = sorted(str(name) for name in names) + return ", ".join(ordered) if ordered else "" + + +def _optional_string(value: Any) -> Optional[str]: + if value in (None, ""): + return None + return str(value) + + +def _value_by_any_key(values: Dict[str, Any], *keys: str) -> Any: + normalized_values = {key.lower(): value for key, value in values.items()} + for key in keys: + if key in values: + return values[key] + lowered = key.lower() + if lowered in normalized_values: + return normalized_values[lowered] + return None + + +def _record_from_xml_fragment(root_name: str, fragment: str) -> Dict[str, Any]: + text = fragment.strip() + if not text: + return {} + if text.startswith(f"<{root_name}"): + xml_text = text + else: + xml_text = f"<{root_name}>{text}" + try: + root = ET.fromstring(xml_text) + except ET.ParseError as exc: + raise EchoProtocolError(f"Malformed {root_name} XML in Echo response.") from exc + return {_local_name(child.tag): _element_value(child) for child in root} + + +def _plate_info_from_values(plate_type: str, values: Dict[str, Any]) -> EchoPlateInfo: + name = _value_by_any_key(values, "Name", "PlateName", "PlateType", "PlateTypeEx") + if isinstance(name, str) and "<" in name: + name = plate_type + return EchoPlateInfo( + name=str(name or plate_type), + rows=_int_or_zero(_value_by_any_key(values, "Rows", "RowCount")), + columns=_int_or_zero(_value_by_any_key(values, "Columns", "Cols", "ColumnCount")), + well_capacity=_float_or_none(_value_by_any_key(values, "WellCapacity", "Capacity")), + fluid=str(_value_by_any_key(values, "Fluid", "FluidType") or ""), + plate_format=str(_value_by_any_key(values, "PlateFormat", "Format") or ""), + usage=str(_value_by_any_key(values, "PlateUsage", "Usage") or ""), + barcode_location=_optional_string( + _value_by_any_key(values, "BarcodeLoc", "BarcodeLocation", "BarCodeLocation") + ), + raw=values, + ) + + +def _power_calibration_from_values(values: Dict[str, Any]) -> EchoPowerCalibration: + record = values + pwr_cal = _value_by_any_key(values, "PwrCal") + if isinstance(pwr_cal, str) and "<" in pwr_cal: + record = _record_from_xml_fragment("PwrCal", pwr_cal) + return EchoPowerCalibration( + amplitude=_float_or_none(_value_by_any_key(record, "Amp", "Amplitude", "AmpV")), + reference_energy=_float_or_none( + _value_by_any_key(record, "Reference", "ReferenceEnergy", "PulseEnergy") + ), + amp_feedback=_float_or_none(_value_by_any_key(record, "AmpFeedback", "CurrentAmpFeedback")), + system_gain=_float_or_none(_value_by_any_key(record, "SysGain", "SystemGain")), + raw=values, + ) + + +def _power_calibration_result_from_values(values: Dict[str, Any]) -> EchoPowerCalibrationResult: + return EchoPowerCalibrationResult( + amp_feedback=_float_or_none(_value_by_any_key(values, "AmpFeedback", "CurrentAmpFeedback")), + pulse_energy=_float_or_none(_value_by_any_key(values, "PulseEnergy", "ReferenceEnergy")), + vpp=_float_or_none(_value_by_any_key(values, "Vpp", "VPP")), + status=str(_value_by_any_key(values, "Status") or ""), + raw=values, + ) + + +def _scan_positions_from_values(values: Dict[str, Any]) -> EchoScanPositions: + record = values + scan_positions = _value_by_any_key(values, "ScanPositions") + if isinstance(scan_positions, str) and "<" in scan_positions: + record = _record_from_xml_fragment("ScanPositions", scan_positions) + return EchoScanPositions( + left_up=_bool_or_none(_value_by_any_key(record, "LeftUp")), + left_down=_bool_or_none(_value_by_any_key(record, "LeftDown")), + right_up=_bool_or_none(_value_by_any_key(record, "RightUp")), + right_down=_bool_or_none(_value_by_any_key(record, "RightDown")), + bottom_up=_bool_or_none(_value_by_any_key(record, "BottomUp")), + bottom_down=_bool_or_none(_value_by_any_key(record, "BottomDown")), + raw=values, + ) + + +def _fluid_info_from_record(record: Dict[str, Any]) -> Optional[EchoFluidInfo]: + name = _value_by_any_key(record, "FluidName", "Name", "FluidType") + if name in (None, ""): + return None + return EchoFluidInfo( + name=str(name), + description=str(_value_by_any_key(record, "Description") or ""), + fc_min=_float_or_none(_value_by_any_key(record, "FCMin")), + fc_max=_float_or_none(_value_by_any_key(record, "FCMax")), + fc_units=str(_value_by_any_key(record, "FCUnits") or ""), + raw=record, + ) + + +def _fluid_record_from_xml_fragment(fragment: str) -> Dict[str, Any]: + return _record_from_xml_fragment("FluidType", fragment) + + +def _fluid_infos_from_values(values: Dict[str, Any]) -> list[EchoFluidInfo]: + nested_fluids = _value_by_any_key(values, "FluidType") + if nested_fluids not in (None, ""): + fluids: list[EchoFluidInfo] = [] + for fluid_value in _value_list(nested_fluids): + if isinstance(fluid_value, dict): + record = fluid_value + elif isinstance(fluid_value, str) and "<" in fluid_value: + record = _fluid_record_from_xml_fragment(fluid_value) + else: + record = {"FluidName": fluid_value} + fluid = _fluid_info_from_record(record) + if fluid is not None: + fluids.append(fluid) + return fluids + + names = _value_list(_value_by_any_key(values, "FluidName", "Name")) + descriptions = _value_list(_value_by_any_key(values, "Description")) + fc_mins = _value_list(_value_by_any_key(values, "FCMin")) + fc_maxes = _value_list(_value_by_any_key(values, "FCMax")) + fc_units = _value_list(_value_by_any_key(values, "FCUnits")) + fluids: list[EchoFluidInfo] = [] + for index, name in enumerate(names): + fluid_name = str(name) + fluids.append( + EchoFluidInfo( + name=fluid_name, + description=str(descriptions[index]) if index < len(descriptions) else "", + fc_min=_float_or_none(fc_mins[index]) if index < len(fc_mins) else None, + fc_max=_float_or_none(fc_maxes[index]) if index < len(fc_maxes) else None, + fc_units=str(fc_units[index]) if index < len(fc_units) else "", + raw={ + "FluidName": fluid_name, + "Description": descriptions[index] if index < len(descriptions) else "", + "FCMin": fc_mins[index] if index < len(fc_mins) else None, + "FCMax": fc_maxes[index] if index < len(fc_maxes) else None, + "FCUnits": fc_units[index] if index < len(fc_units) else "", + }, + ) + ) + return fluids + + +_PLATE_TYPE_EX_FIELD_TYPES: Tuple[Tuple[str, str], ...] = ( + ("Name", "string"), + ("Mfg", "string"), + ("LotNum", "string"), + ("PartNum", "string"), + ("Rows", "int"), + ("Columns", "int"), + ("A1OffsetX", "double"), + ("A1OffsetY", "double"), + ("CenterX", "double"), + ("CenterY", "double"), + ("SkirtHeight", "double"), + ("PlateHeight", "double"), + ("WellWidth", "double"), + ("CenterSpacingX", "double"), + ("CenterSpacingY", "double"), + ("WellCapacity", "double"), + ("SoundVelocity", "double"), + ("BottomInset", "double"), + ("BarcodeLoc", "string"), + ("MinWellVolumeUL", "double"), + ("MaxWellVolumeUL", "double"), + ("MaxVolumeTotalNL", "double"), + ("WellLength", "double"), + ("ParentPlate", "string"), + ("PlateFormat", "string"), + ("Fluid", "string"), + ("PlateUsage", "string"), +) + +_PLATE_TYPE_EX_ALIASES: Dict[str, Tuple[str, ...]] = { + "Name": ("Name", "PlateName", "PlateType", "PlateTypeEx"), + "Mfg": ("Mfg", "Manufacturer"), + "LotNum": ("LotNum", "LotNumber"), + "PartNum": ("PartNum", "PartNumber"), + "CenterX": ("CenterX", "CenterWellPosX"), + "CenterY": ("CenterY", "CenterWellPosY"), + "BarcodeLoc": ("BarcodeLoc", "BarcodeLocation", "BarCodeLocation"), + "Fluid": ("Fluid", "FluidName", "FluidType"), + "PlateUsage": ("PlateUsage", "PlateUse", "Usage"), +} + + +def _plate_type_ex_values_from_rpc_values(values: Dict[str, Any]) -> Dict[str, Any]: + normalized = dict(values) + plate_type_ex = values.get("PlateTypeEx") + if not isinstance(plate_type_ex, str) or "<" not in plate_type_ex: + return normalized + try: + root = ET.fromstring(f"{plate_type_ex}") + except ET.ParseError: + return normalized + for child in root: + normalized[_local_name(child.tag)] = _element_value(child) + return normalized + + +def _plate_type_ex_value(values: Dict[str, Any], field: str, value_type: str) -> Any: + if field == "Name": + return _value_by_any_key(values, *_PLATE_TYPE_EX_ALIASES[field]) + keys = _PLATE_TYPE_EX_ALIASES.get(field, (field,)) + value = _value_by_any_key(values, *keys) + if value not in (None, ""): + return value + return 0 if value_type in {"int", "double"} else "" + + +def _plate_type_ex_xml(plate_type: str, values: Dict[str, Any]) -> str: + soap_encoding_style = "{http://schemas.xmlsoap.org/soap/envelope/}encodingStyle" + encoding = "http://schemas.xmlsoap.org/soap/encoding/" + root = ET.Element("PlateTypeEx", {soap_encoding_style: encoding}) + normalized = _plate_type_ex_values_from_rpc_values(values) + normalized["Name"] = plate_type + for field, value_type in _PLATE_TYPE_EX_FIELD_TYPES: + value = _plate_type_ex_value(normalized, field, value_type) + child = ET.SubElement( + root, + field, + { + soap_encoding_style: encoding, + "type": f"xsd:{value_type}", + }, + ) + child.text = "" if value is None else str(value) + return ET.tostring(root, encoding="unicode", short_empty_elements=False) + + +def _validate_echo_plate_dimensions(plate: Plate, info: EchoPlateInfo, side: str) -> None: + if info.rows <= 0 or info.columns <= 0: + raise EchoCommandError( + "ResolveEchoPlateType", + f"Echo {side} plate type {info.name!r} did not report usable Rows/Columns.", + ) + if info.columns != plate.num_items_x or info.rows != plate.num_items_y: + raise EchoCommandError( + "ResolveEchoPlateType", + f"PLR {side} plate {plate.name!r} dimensions are " + f"{plate.num_items_x} columns x {plate.num_items_y} rows, but Echo plate type " + f"{info.name!r} is {info.columns} columns x {info.rows} rows.", + ) + + +def create_plate_from_echo_info(info: EchoPlateInfo, name: Optional[str] = None) -> Plate: + """Create a minimal PLR plate from Echo catalog geometry. + + The generated plate is suitable for Echo transfer planning. It is not a + manufacturer-precise labware definition. + """ + + if info.rows <= 0 or info.columns <= 0: + raise ValueError("EchoPlateInfo must include positive rows and columns.") + plate_name = name or re.sub(r"\W+", "_", info.name).strip("_") or "echo_plate" + size_x = 127.76 + size_y = 85.48 + size_z = 14.0 + spacing_x = size_x / info.columns + spacing_y = size_y / info.rows + well_size = min(spacing_x, spacing_y) * 0.65 + return Plate( + name=plate_name, + size_x=size_x, + size_y=size_y, + size_z=size_z, + model=info.name, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=info.columns, + num_items_y=info.rows, + dx=spacing_x / 2, + dy=spacing_y / 2, + dz=0, + item_dx=spacing_x, + item_dy=spacing_y, + size_x=well_size, + size_y=well_size, + size_z=size_z, + ), + ) + + +def _resolve_well_reference(plate: Plate, well: Union[str, Well], role: str) -> Well: + if isinstance(well, Well): + if well.parent is not plate: + raise ValueError(f"{role} well {well.name!r} is not on plate {plate.name!r}.") + return well + return plate.get_well(str(well)) + + +def _is_plate_type_present(plate_type: Optional[str]) -> bool: + if plate_type is None or plate_type == "": + return False + return plate_type.lower() != "none" + + +def _make_transfer_protocol_xml( + transfers: Sequence[tuple[str, str, float]], protocol_name: str +) -> str: + protocol = ET.Element("Protocol", {"Name": protocol_name}) + ET.SubElement(protocol, "Name") + layout = ET.SubElement(protocol, "Layout") + for source_identifier, destination_identifier, volume_nl in transfers: + ET.SubElement( + layout, + "wp", + { + "n": source_identifier, + "dn": destination_identifier, + "v": _format_transfer_volume_nl(volume_nl), + }, + ) + return '' + ET.tostring( + protocol, + encoding="unicode", + short_empty_elements=True, + ) + + +def build_echo_transfer_plan( + source_plate: Plate, + destination_plate: Plate, + transfers: Sequence[Union[EchoPlannedTransfer, Tuple[Union[str, Well], Union[str, Well], float]]], + *, + source_plate_type: Optional[str] = None, + destination_plate_type: Optional[str] = None, + protocol_name: str = "transfer", + volume_unit: str = "nL", +) -> EchoTransferPlan: + """Build Echo protocol XML and source plate map from PLR plates and wells.""" + + planned_transfers: list[EchoPlannedTransfer] = [] + protocol_transfers: list[tuple[str, str, float]] = [] + for transfer in transfers: + if isinstance(transfer, EchoPlannedTransfer): + planned = transfer + if planned.source.parent is not source_plate: + raise ValueError(f"Source well {planned.source.name!r} is not on {source_plate.name!r}.") + if planned.destination.parent is not destination_plate: + raise ValueError( + f"Destination well {planned.destination.name!r} is not on {destination_plate.name!r}." + ) + else: + source_ref, destination_ref, volume = transfer + source = _resolve_well_reference(source_plate, source_ref, "Source") + destination = _resolve_well_reference(destination_plate, destination_ref, "Destination") + planned = EchoPlannedTransfer( + source=source, + destination=destination, + volume_nl=_normalize_volume_nl(float(volume), volume_unit), + ) + source_identifier = planned.source_identifier + destination_identifier = planned.destination_identifier + _validate_transfer_volume_nl( + planned.volume_nl, + f"{source_identifier}->{destination_identifier}", + ) + planned_transfers.append(planned) + protocol_transfers.append((source_identifier, destination_identifier, planned.volume_nl)) + + if not planned_transfers: + raise ValueError("At least one transfer is required.") + + source_type = _resolve_plate_type(source_plate, source_plate_type, "Source") + destination_type = _resolve_plate_type(destination_plate, destination_plate_type, "Destination") + source_wells = tuple( + dict.fromkeys(source for source, _destination, _volume in protocol_transfers) + ) + return EchoTransferPlan( + source_plate=source_plate, + destination_plate=destination_plate, + source_plate_type=source_type, + destination_plate_type=destination_type, + protocol_name=protocol_name, + transfers=tuple(planned_transfers), + protocol_xml=_make_transfer_protocol_xml(protocol_transfers, protocol_name), + plate_map=EchoPlateMap(plate_type=source_type, well_identifiers=source_wells), + ) + + +def _coerce_bool(value: Any) -> Optional[bool]: + if isinstance(value, bool): + return value + if isinstance(value, int) and not isinstance(value, bool) and value in (0, 1): + return bool(value) + return None + + +def _coerce_int(value: Any) -> Optional[int]: + return value if isinstance(value, int) and not isinstance(value, bool) else None + + +def _infer_access_open(value: Any, position: Optional[int]) -> Optional[bool]: + explicit = _coerce_bool(value) + if explicit is not None: + return explicit + if position == -1: + return True + if position in (0, 1): + return False + return None + + +def _infer_access_closed(value: Any, position: Optional[int]) -> Optional[bool]: + explicit = _coerce_bool(value) + if explicit is not None: + return explicit + if position in (0, 1): + return True + if position == -1: + return False + return None + + +def _infer_door_open( + value: Any, + source_access_open: Optional[bool], + destination_access_open: Optional[bool], +) -> Optional[bool]: + explicit = _coerce_bool(value) + if explicit is not None: + return explicit + if source_access_open is True or destination_access_open is True: + return True + if source_access_open is False and destination_access_open is False: + return False + return None + + +def _infer_door_closed( + value: Any, + source_access_closed: Optional[bool], + destination_access_closed: Optional[bool], +) -> Optional[bool]: + explicit = _coerce_bool(value) + if explicit is not None: + return explicit + if source_access_closed is True and destination_access_closed is True: + return True + if source_access_closed is False or destination_access_closed is False: + return False + return None + + +def _resolve_timeout(timeout: Optional[float], default_timeout: float) -> float: + return default_timeout if timeout is None else timeout + + +def _format_bool(value: bool) -> str: + return "True" if value else "False" + + +def _param_type_and_value(value: Any) -> Tuple[str, str]: + if isinstance(value, bool): + return "boolean", _format_bool(value) + if isinstance(value, int) and not isinstance(value, bool): + return "int", str(value) + if isinstance(value, float): + return "double", str(value) + return "string", str(value) + + +def _unlock_already_released(status: Optional[str]) -> bool: + normalized = (status or "").strip().lower() + return "does not own the lock" in normalized or "not locked" in normalized + + +def _survey_xml_from_values(values: Dict[str, Any]) -> Optional[str]: + for value in values.values(): + if not isinstance(value, str): + continue + normalized = html.unescape(value).strip() + if " Dict[str, Any]: + preview: Dict[str, Any] = {} + for key, value in values.items(): + if isinstance(value, str): + text = html.unescape(value) + if len(text) > max_chars: + preview[key] = { + "type": "str", + "length": len(text), + "head": text[:max_chars], + "tail": text[-max_chars:], + } + else: + preview[key] = text + elif isinstance(value, list): + preview[key] = { + "type": "list", + "length": len(value), + "itemTypes": [type(item).__name__ for item in value[:5]], + } + else: + preview[key] = value + return preview + + +def _is_probably_gzip(payload: bytes) -> bool: + return len(payload) >= 2 and payload[:2] == b"\x1f\x8b" + + +def _gzip_stream_complete(payload: bytes) -> bool: + return _split_complete_gzip_body(payload) is not None + + +def _split_complete_gzip_body(payload: bytes) -> Optional[Tuple[bytes, bytes]]: + if not _is_probably_gzip(payload): + return payload, b"" + decompressor = zlib.decompressobj(16 + zlib.MAX_WBITS) + try: + decompressor.decompress(payload) + except zlib.error: + return None + if not decompressor.eof: + return None + body_length = len(payload) - len(decompressor.unused_data) + return payload[:body_length], decompressor.unused_data + + +_XML_TAG_RE = re.compile(r"<(/?)([A-Za-z_][\w:.-]*)([^<>]*)>") + + +def _repair_partial_xml_document(text: str) -> Optional[str]: + candidate = text.strip() + if not candidate: + return None + lower = candidate.lower() + if "" in lower or "" in lower: + return candidate + + last_tag_end = candidate.rfind(">") + if last_tag_end == -1: + return None + candidate = candidate[: last_tag_end + 1] + + stack: list[str] = [] + for match in _XML_TAG_RE.finditer(candidate): + full_tag = match.group(0) + if full_tag.startswith("" for tag_name in reversed(stack)) + try: + ET.fromstring(repaired) + except ET.ParseError: + return None + return repaired + + +def _is_gzip_protocol_error(error: BaseException) -> bool: + if isinstance(error, (EOFError, OSError, zlib.error)): + return True + if not isinstance(error, EchoProtocolError): + return False + message = str(error).lower() + return "gzip" in message or "compressed file ended" in message or "complete gzip body" in message + + +def _first_result_value(result: _RpcResult) -> Any: + for key, value in result.values.items(): + if key in {"SUCCEEDED", "Status"}: + continue + return value + return None + + +def _name_list_from_value(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] + if not isinstance(value, str): + return [] if value in (None, "") else [str(value)] + + normalized = html.unescape(value).strip() + if not normalized: + return [] + + if normalized.startswith("<"): + wrapped = normalized + if not normalized.startswith(""): + wrapped = f"{normalized}" + try: + root = ET.fromstring(wrapped) + except ET.ParseError: + pass + else: + items: list[str] = [] + for element in root.iter(): + if element is root: + continue + text = (element.text or "").strip() + if text: + items.append(text) + continue + for attribute_name in ("name", "Name", "value", "Value"): + attribute_value = element.attrib.get(attribute_name) + if attribute_value: + items.append(attribute_value.strip()) + break + return [item for item in items if item] + + return [part.strip() for part in normalized.replace(";", ",").split(",") if part.strip()] + + +def _embedded_xml_from_values(values: Dict[str, Any], marker: str) -> Optional[str]: + marker_lower = marker.lower() + for value in values.values(): + if not isinstance(value, str): + continue + normalized = html.unescape(value).strip() + if marker_lower in normalized.lower(): + return normalized + return None + + +def _parse_echo_transfer_report( + report_xml: Optional[str], + *, + raw: Dict[str, Any], + succeeded: Optional[bool], + status: Optional[str], +) -> EchoTransferResult: + result = EchoTransferResult( + report_xml=report_xml, + raw=raw, + succeeded=succeeded, + status=status, + ) + if report_xml in (None, ""): + return result + + normalized_xml = html.unescape(str(report_xml)).strip() + result.report_xml = normalized_xml + try: + root = ET.fromstring(normalized_xml) + except ET.ParseError as exc: + raise EchoProtocolError("Malformed Echo transfer report XML.") from exc + + result.date = root.attrib.get("date", "") + result.serial_number = root.attrib.get("serial_number", "") + plates = root.findall(".//plateInfo/plate") + if len(plates) >= 1: + result.source_plate_type = plates[0].attrib.get("name", "") or None + if len(plates) >= 2: + result.destination_plate_type = plates[1].attrib.get("name", "") or None + + for well in root.findall(".//printmap/w"): + attributes = {str(key): str(value) for key, value in well.attrib.items()} + result.transfers.append( + EchoTransferredWell( + source_identifier=attributes.get("n", ""), + source_row=_int_or_zero(attributes.get("r")), + source_column=_int_or_zero(attributes.get("c")), + destination_identifier=attributes.get("dn", ""), + destination_row=_int_or_zero(attributes.get("dr")), + destination_column=_int_or_zero(attributes.get("dc")), + requested_volume_nl=_float_or_none(attributes.get("vt")), + actual_volume_nl=_float_or_none(attributes.get("avt")), + current_volume_nl=_float_or_none(attributes.get("cvl")), + starting_volume_nl=_float_or_none(attributes.get("vl")), + timestamp=attributes.get("t", ""), + fluid=attributes.get("fld", ""), + fluid_units=attributes.get("fldu", ""), + composition=_float_or_none(attributes.get("fc")), + fluid_thickness=_float_or_none(attributes.get("ft")), + reason=attributes.get("reason", ""), + raw_attributes=attributes, + ) + ) + + for well in root.findall(".//skippedwells/w"): + attributes = {str(key): str(value) for key, value in well.attrib.items()} + result.skipped.append( + EchoSkippedWell( + source_identifier=attributes.get("n", ""), + source_row=_int_or_zero(attributes.get("r")), + source_column=_int_or_zero(attributes.get("c")), + destination_identifier=attributes.get("dn", ""), + destination_row=_int_or_zero(attributes.get("dr")), + destination_column=_int_or_zero(attributes.get("dc")), + requested_volume_nl=_float_or_none(attributes.get("vt")), + reason=attributes.get("reason", ""), + raw_attributes=attributes, + ) + ) + + return result + + +async def _call_operator_pause( + callback: Optional[OperatorPause], + message: str, +) -> None: + if callback is None: + return + result = callback(message) + if inspect.isawaitable(result): + await result + + +def _preflight_transfer_volume_tracking(plan: EchoTransferPlan) -> None: + if not does_volume_tracking(): + return + + source_totals: Dict[Well, float] = {} + destination_totals: Dict[Well, float] = {} + for transfer in plan.transfers: + source_totals[transfer.source] = source_totals.get(transfer.source, 0.0) + transfer.volume_ul + destination_totals[transfer.destination] = ( + destination_totals.get(transfer.destination, 0.0) + transfer.volume_ul + ) + + for well, volume_ul in source_totals.items(): + if well.tracker.is_disabled: + continue + if (volume_ul - well.tracker.get_used_volume()) > 1e-6: + raise EchoCommandError( + "TransferWells", + f"Not enough liquid in {well.get_identifier()}: " + f"{volume_ul}uL > {well.tracker.get_used_volume()}uL.", + ) + + for well, volume_ul in destination_totals.items(): + if well.tracker.is_disabled: + continue + if (volume_ul - well.tracker.get_free_volume()) > 1e-6: + raise EchoCommandError( + "TransferWells", + f"Not enough space in {well.get_identifier()}: " + f"{volume_ul}uL > {well.tracker.get_free_volume()}uL.", + ) + + +def _apply_transfer_volume_tracking(plan: EchoTransferPlan, result: EchoTransferResult) -> int: + if not does_volume_tracking(): + return 0 + + planned_by_pair = { + (transfer.source_identifier, transfer.destination_identifier): transfer + for transfer in plan.transfers + } + touched: set[Well] = set() + updates = 0 + try: + for transferred in result.transfers: + planned = planned_by_pair.get( + (transferred.source_identifier, transferred.destination_identifier) + ) + if planned is None: + continue + volume_nl = transferred.tracker_volume_nl + if volume_nl is None: + continue + volume_ul = _nl_to_ul(volume_nl) + if not planned.source.tracker.is_disabled: + planned.source.tracker.remove_liquid(volume_ul) + touched.add(planned.source) + if not planned.destination.tracker.is_disabled: + planned.destination.tracker.add_liquid(volume_ul) + touched.add(planned.destination) + updates += 1 + except Exception: + for well in touched: + if not well.tracker.is_disabled: + well.tracker.rollback() + raise + + for well in touched: + if not well.tracker.is_disabled: + well.tracker.commit() + return updates + + +def _soap_fault_status(root: ET.Element) -> Optional[str]: + body = next((node for node in root if _local_name(node.tag) == "Body"), None) + if body is None or len(body) == 0: + return None + fault = next((node for node in body if _local_name(node.tag) == "Fault"), None) + if fault is None: + return None + fault_string = next( + ( + child.text for child in fault.iter() if _local_name(child.tag) == "faultstring" and child.text + ), + "", + ) + return f"SOAP Fault: {fault_string}" if fault_string else "SOAP Fault" + + +def _print_options_xml(options: EchoTransferPrintOptions) -> str: + root = ET.Element( + "PrintOptions", + { + "xmlns:SOAP-ENV": "http://schemas.xmlsoap.org/soap/envelope/", + "xmlns:xsd": "http://www.w3.org/2001/XMLSchema", + "SOAP-ENV:encodingStyle": "http://schemas.xmlsoap.org/soap/encoding/", + }, + ) + for name, value_type, value in options.to_params(): + child = ET.SubElement( + root, + name, + { + "SOAP-ENV:encodingStyle": "http://schemas.xmlsoap.org/soap/encoding/", + "type": f"xsd:{value_type}", + }, + ) + child.text = value + return ET.tostring(root, encoding="unicode", short_empty_elements=True) + + +def _strip_fragment_namespace_expansions(element: ET.Element) -> ET.Element: + soap_encoding_style = "{http://schemas.xmlsoap.org/soap/envelope/}encodingStyle" + for node in element.iter(): + encoding_style = node.attrib.pop(soap_encoding_style, None) + if encoding_style is not None: + attributes = {"SOAP-ENV:encodingStyle": encoding_style} + attributes.update(node.attrib) + node.attrib.clear() + node.attrib.update(attributes) + return element + + +def _dump_malformed_xml_response( + method: str, + *, + payload_bytes: bytes, + body_text: str, +) -> tuple[str, str] | None: + dump_dir = (os.getenv("PYLABROBOT_ECHO_DEBUG_DIR") or "").strip() + if not dump_dir: + return None + try: + os.makedirs(dump_dir, exist_ok=True) + stamp = time.strftime("%Y%m%dT%H%M%S") + safe_method = "".join(char.lower() if char.isalnum() else "-" for char in method).strip("-") + base_name = f"{stamp}-{os.getpid()}-{safe_method or 'response'}" + raw_path = os.path.join(dump_dir, f"{base_name}.body.bin") + text_path = os.path.join(dump_dir, f"{base_name}.body.txt") + with open(raw_path, "wb") as raw_file: + raw_file.write(payload_bytes) + with open(text_path, "w", encoding="utf-8", errors="replace") as text_file: + text_file.write(body_text) + return raw_path, text_path + except OSError: + logger.exception("Failed to write malformed Echo XML response dump for %s", method) + return None + + +def _parse_event_from_message(message: _HttpMessage) -> EchoEvent: + body_text = message.decoded_body() + try: + root = ET.fromstring(body_text) + except ET.ParseError as exc: + raise EchoProtocolError("Malformed XML in Echo event payload.") from exc + + body = next((node for node in root if _local_name(node.tag) == "Body"), None) + if body is None or len(body) == 0: + raise EchoProtocolError("SOAP body missing from Echo event payload.") + + outer = body[0] + if _local_name(outer.tag) != "handleEvent" or len(outer) == 0: + raise EchoProtocolError("Unexpected Echo event payload.") + + event_element = outer[0] + values = {_local_name(child.tag): _element_value(child) for child in event_element} + return EchoEvent( + event_id=str(values.get("id")) if values.get("id") not in (None, "") else None, + source=str(values.get("source", "")), + payload=str(values.get("payload", "")), + timestamp=str(values.get("timestamp")) if values.get("timestamp") not in (None, "") else None, + raw=values, + ) + + +class EchoEventStream: + """Persistent 8010 callback stream.""" + + def __init__( + self, + driver: "EchoDriver", + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, + ): + self._driver = driver + self._reader = reader + self._writer = writer + self._closed = False + self._buffer = bytearray() + + async def __aenter__(self) -> "EchoEventStream": + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + await self.close() + + async def read_event(self, timeout: Optional[float] = None) -> EchoEvent: + message = await self._driver._read_http_message( + self._reader, + timeout=timeout, + buffer=self._buffer, + ) + return _parse_event_from_message(message) + + async def iter_events(self, timeout: Optional[float] = None) -> AsyncIterator[EchoEvent]: + """Yield events from the stream until the connection closes or the caller stops iteration.""" + while True: + yield await self.read_event(timeout=timeout) + + async def read_events( + self, + *, + max_events: int, + timeout: Optional[float] = None, + ) -> list[EchoEvent]: + events: list[EchoEvent] = [] + for _ in range(max_events): + events.append(await self.read_event(timeout=timeout)) + return events + + async def close(self) -> None: + if self._closed: + return + self._closed = True + self._writer.close() + await self._writer.wait_closed() + + +class EchoDriver(Driver): + """Driver for Labcyte Echo Medman access-control RPCs.""" + + def __init__( + self, + host: str, + rpc_port: int = DEFAULT_RPC_PORT, + event_port: int = DEFAULT_EVENT_PORT, + timeout: float = DEFAULT_TIMEOUT, + app_name: str = "PyLabRobot Echo", + owner: Optional[str] = None, + token: Optional[str] = None, + token_slot_a: int = DEFAULT_SLOT_A, + token_slot_b: int = DEFAULT_SLOT_B, + client_version: str = "3.1.0", + protocol_version: str = "3.1", + ): + super().__init__() + self.host = host + self.rpc_port = rpc_port + self.event_port = event_port + self.timeout = timeout + self.app_name = app_name + self.owner = owner + self._token = token + self.token_slot_a = token_slot_a + self.token_slot_b = token_slot_b + self.client_version = client_version + self.protocol_version = protocol_version + self._rpc_lock = asyncio.Lock() + self._lock_held = False + + @property + def token(self) -> str: + if self._token is None: + raise RuntimeError("Echo driver is not set up; call setup() first") + return self._token + + @staticmethod + def build_token( + instrument_host: str, + slot_a: int = DEFAULT_SLOT_A, + slot_b: int = DEFAULT_SLOT_B, + epoch: Optional[int] = None, + pid: Optional[int] = None, + ) -> str: + resolved_host = instrument_host + try: + resolved_host = socket.gethostbyname(instrument_host) + except OSError: + pass + + if epoch is None: + epoch = int(time.time()) + if pid is None: + pid = os.getpid() + return f"{resolved_host}:{slot_a}:{slot_b}:{epoch}:{pid}" + + async def setup(self, backend_params: Optional[BackendParams] = None): + del backend_params + if self._token is None: + self._token = self.build_token( + self.host, + slot_a=self.token_slot_a, + slot_b=self.token_slot_b, + ) + + async def stop(self): + if self._lock_held: + try: + await self.unlock() + except Exception as exc: # pragma: no cover - best-effort cleanup + logger.warning("Failed to unlock Echo during stop: %s", exc) + + async def open_event_stream(self, timeout: Optional[float] = None) -> EchoEventStream: + request_timeout = _resolve_timeout(timeout, self.timeout) + reader, writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.event_port), + timeout=request_timeout, + ) + try: + writer.write(self._make_event_registration_request()) + await asyncio.wait_for(writer.drain(), timeout=request_timeout) + except Exception: + writer.close() + await writer.wait_closed() + raise + return EchoEventStream(self, reader, writer) + + def serialize(self) -> dict: + return { + **super().serialize(), + "host": self.host, + "rpc_port": self.rpc_port, + "event_port": self.event_port, + "timeout": self.timeout, + "app_name": self.app_name, + "owner": self.owner, + "token": self._token, + "token_slot_a": self.token_slot_a, + "token_slot_b": self.token_slot_b, + "client_version": self.client_version, + "protocol_version": self.protocol_version, + } + + async def read_events( + self, + *, + max_events: int, + timeout: Optional[float] = None, + ) -> list[EchoEvent]: + async with await self.open_event_stream(timeout=timeout) as stream: + return await stream.read_events(max_events=max_events, timeout=timeout) + + async def get_instrument_info(self) -> EchoInstrumentInfo: + result = await self._rpc("GetInstrumentInfo") + self._ensure_success("GetInstrumentInfo", result) + return EchoInstrumentInfo( + serial_number=str(result.values.get("SerialNumber", "")), + instrument_name=str(result.values.get("InstrumentName", "")), + ip_address=str(result.values.get("IPAddress", "")), + software_version=str(result.values.get("SoftwareVersion", "")), + boot_time=str(result.values.get("BootTime", "")), + instrument_status=str(result.values.get("InstrumentStatus", "")), + model=str(result.values.get("Model", "")), + raw=result.values, + ) + + async def get_dio(self) -> Dict[str, Any]: + result = await self._rpc("GetDIO") + self._ensure_success("GetDIO", result) + return result.values + + async def get_dio_ex(self) -> Dict[str, Any]: + result = await self._rpc("GetDIOEx") + self._ensure_success("GetDIOEx", result) + return result.values + + async def get_dio_ex2(self) -> Dict[str, Any]: + result = await self._rpc("GetDIOEx2") + self._ensure_success("GetDIOEx2", result) + return result.values + + async def get_echo_configuration( + self, + config_xml: str = DEFAULT_ECHO_CONFIGURATION_QUERY, + ) -> str: + result = await self._rpc( + "GetEchoConfiguration", + (("xmlEchoConfig", "string", config_xml),), + ) + self._ensure_success("GetEchoConfiguration", result) + value = result.values.get("xmlEchoConfig", _first_result_value(result)) + return "" if value in (None, "") else str(value) + + async def get_power_calibration(self) -> Dict[str, Any]: + result = await self._rpc("GetPwrCal") + self._ensure_success("GetPwrCal", result) + return result.values + + async def get_echo_power_calibration(self) -> EchoPowerCalibration: + """Return typed power calibration values from ``GetPwrCal``.""" + return _power_calibration_from_values(await self.get_power_calibration()) + + async def get_access_state(self) -> PlateAccessState: + raw = await self.get_dio_ex2() + source_plate_position = _coerce_int(raw.get("SPP")) + destination_plate_position = _coerce_int(raw.get("DPP")) + source_access_open = _infer_access_open(raw.get("LSO"), source_plate_position) + source_access_closed = _infer_access_closed(raw.get("LSI"), source_plate_position) + destination_access_open = _infer_access_open(None, destination_plate_position) + destination_access_closed = _infer_access_closed(None, destination_plate_position) + return PlateAccessState( + source_access_open=source_access_open, + source_access_closed=source_access_closed, + destination_access_open=destination_access_open, + destination_access_closed=destination_access_closed, + door_open=_infer_door_open(raw.get("DFO"), source_access_open, destination_access_open), + door_closed=_infer_door_closed( + raw.get("DFC"), + source_access_closed, + destination_access_closed, + ), + source_plate_position=source_plate_position, + destination_plate_position=destination_plate_position, + raw=raw, + ) + + async def get_current_source_plate_type(self) -> Optional[str]: + result = await self._rpc("GetCurrentSrcPlateType") + self._ensure_success("GetCurrentSrcPlateType", result) + value = _first_result_value(result) + return None if value in (None, "") else str(value) + + async def get_current_destination_plate_type(self) -> Optional[str]: + result = await self._rpc("GetCurrentDstPlateType") + self._ensure_success("GetCurrentDstPlateType", result) + value = _first_result_value(result) + return None if value in (None, "") else str(value) + + async def is_source_plate_present(self) -> bool: + plate_type = await self.get_current_source_plate_type() + return _is_plate_type_present(plate_type) + + async def is_destination_plate_present(self) -> bool: + plate_type = await self.get_current_destination_plate_type() + return _is_plate_type_present(plate_type) + + async def get_destination_plate_offset(self) -> Any: + result = await self._rpc("GetDstPlateOffset") + self._ensure_success("GetDstPlateOffset", result) + return _first_result_value(result) + + async def get_all_source_plate_names(self) -> list[str]: + result = await self._rpc("GetAllSrcPlateNames") + self._ensure_success("GetAllSrcPlateNames", result) + return _name_list_from_value(_first_result_value(result)) + + async def get_all_destination_plate_names(self) -> list[str]: + result = await self._rpc("GetAllDestPlateNames") + self._ensure_success("GetAllDestPlateNames", result) + return _name_list_from_value(_first_result_value(result)) + + async def get_plate_info(self, plate_type_ex: str) -> Dict[str, Any]: + result = await self._rpc( + "GetPlateInfoEx", + (("PlateTypeEx", "string", plate_type_ex),), + ) + self._ensure_success("GetPlateInfoEx", result) + return _plate_type_ex_values_from_rpc_values(result.values) + + async def get_echo_plate_info(self, plate_type: str) -> EchoPlateInfo: + """Return typed Echo catalog metadata for a registered plate type.""" + return _plate_info_from_values(plate_type, await self.get_plate_info(plate_type)) + + async def set_plate_info_ex(self, plate_type: str, values: Dict[str, Any]) -> None: + """Create or update an Echo plate definition through ``SetPlateInfoEx``. + + The payload shape was captured from Echo Client Utility. Use the higher-level + destination helpers for normal PLR workflows. + """ + result = await self._rpc( + "SetPlateInfoEx", + ( + ("PlateTypeEx", "string", plate_type), + ("PlateTypeEx", "xml_element", _plate_type_ex_xml(plate_type, values)), + ), + ) + self._ensure_success("SetPlateInfoEx", result) + + async def remove_plate_info(self, plate_type: str) -> None: + """Remove an Echo plate definition through ``RemovePlateInfo``.""" + result = await self._rpc( + "RemovePlateInfo", + (("PlateType", "string", plate_type),), + ) + self._ensure_success("RemovePlateInfo", result) + + async def clone_destination_plate_definition( + self, + base_plate_type: str, + new_plate_type: str, + ) -> EchoPlateInfo: + """Clone an existing destination plate definition under a new destination name.""" + catalog = await self.get_echo_plate_catalog() + if base_plate_type not in catalog.destination: + valid_names = _format_plate_catalog_names(catalog.destination.keys()) + raise EchoCommandError( + "SetPlateInfoEx", + f"Base destination plate type {base_plate_type!r} is not registered. " + f"Valid destination plate types: {valid_names}.", + ) + if new_plate_type in catalog.source or new_plate_type in catalog.destination: + raise EchoCommandError( + "SetPlateInfoEx", + f"Echo plate type {new_plate_type!r} is already registered.", + ) + + values = await self.get_plate_info(base_plate_type) + await self.set_plate_info_ex(new_plate_type, values) + updated_catalog = await self.get_echo_plate_catalog() + if new_plate_type not in updated_catalog.destination: + raise EchoCommandError( + "SetPlateInfoEx", + f"Echo accepted {new_plate_type!r}, but it did not appear in the destination catalog.", + ) + return updated_catalog.destination[new_plate_type] + + async def delete_destination_plate_definition(self, plate_type: str) -> bool: + """Delete a destination plate definition and verify it leaves the destination catalog.""" + catalog = await self.get_echo_plate_catalog() + if plate_type in catalog.source: + raise EchoCommandError( + "RemovePlateInfo", + f"Refusing to delete source plate type {plate_type!r} through the destination helper.", + ) + if plate_type not in catalog.destination: + valid_names = _format_plate_catalog_names(catalog.destination.keys()) + raise EchoCommandError( + "RemovePlateInfo", + f"Destination plate type {plate_type!r} is not registered. " + f"Valid destination plate types: {valid_names}.", + ) + await self.remove_plate_info(plate_type) + updated_catalog = await self.get_echo_plate_catalog() + return plate_type not in updated_catalog.destination + + async def get_echo_plate_catalog(self) -> EchoPlateCatalog: + """Read the source and destination plate catalogs registered on the Echo.""" + source_names = await self.get_all_source_plate_names() + destination_names = await self.get_all_destination_plate_names() + source: Dict[str, EchoPlateInfo] = {} + destination: Dict[str, EchoPlateInfo] = {} + for plate_type in source_names: + source[plate_type] = await self.get_echo_plate_info(plate_type) + for plate_type in destination_names: + destination[plate_type] = await self.get_echo_plate_info(plate_type) + return EchoPlateCatalog(source=source, destination=destination) + + async def resolve_echo_plate_type( + self, + plate: Plate, + side: str, + plate_type: Optional[str] = None, + ) -> EchoResolvedPlateType: + """Resolve and validate a PLR plate against the Echo instrument catalog.""" + return self._resolve_echo_plate_type_from_catalog( + plate, + side, + plate_type, + await self.get_echo_plate_catalog(), + ) + + def _resolve_echo_plate_type_from_catalog( + self, + plate: Plate, + side: str, + plate_type: Optional[str], + catalog: EchoPlateCatalog, + ) -> EchoResolvedPlateType: + normalized_side = _normalize_plate_side(side) + side_catalog = catalog.for_side(normalized_side) + candidate = plate_type or plate.model + derived_from = "explicit" if plate_type is not None else "plate.model" + if candidate in (None, ""): + valid_names = _format_plate_catalog_names(side_catalog.keys()) + raise EchoCommandError( + "ResolveEchoPlateType", + f"No Echo {normalized_side} plate type was supplied and PLR plate {plate.name!r} " + f"has no model. Pass {normalized_side}_plate_type explicitly. Valid Echo " + f"{normalized_side} plate types: {valid_names}.", + ) + if candidate not in side_catalog: + valid_names = _format_plate_catalog_names(side_catalog.keys()) + raise EchoCommandError( + "ResolveEchoPlateType", + f"Echo {normalized_side} plate type {candidate!r} is not registered on this " + f"instrument. Pass {normalized_side}_plate_type with one of: {valid_names}.", + ) + info = side_catalog[candidate] + _validate_echo_plate_dimensions(plate, info, normalized_side) + return EchoResolvedPlateType( + side=normalized_side, + plate_type=candidate, + requested_plate_type=plate_type, + derived_from=derived_from, + info=info, + ) + + async def _require_registered_echo_plate_type( + self, + plate_type: str, + side: str, + ) -> EchoPlateInfo: + normalized_side = _normalize_plate_side(side) + catalog = await self.get_echo_plate_catalog() + side_catalog = catalog.for_side(normalized_side) + if plate_type not in side_catalog: + valid_names = _format_plate_catalog_names(side_catalog.keys()) + raise EchoCommandError( + "ResolveEchoPlateType", + f"Echo {normalized_side} plate type {plate_type!r} is not registered on this " + f"instrument. Pass one of: {valid_names}.", + ) + return side_catalog[plate_type] + + async def get_plate_insert(self, plate_type: str) -> Any: + result = await self._rpc( + "GetPlateInsert", + (("PlateType", "string", plate_type),), + ) + self._ensure_success("GetPlateInsert", result) + return _first_result_value(result) + + async def get_current_plate_insert(self) -> Any: + result = await self._rpc("GetCurrentPlateInsert") + self._ensure_success("GetCurrentPlateInsert", result) + return _first_result_value(result) + + async def get_all_plate_inserts(self) -> list[str]: + result = await self._rpc("GetAllPlateInserts") + self._ensure_success("GetAllPlateInserts", result) + return _name_list_from_value(result.values.get("InsertName", _first_result_value(result))) + + async def get_coupling_fluid_sound_velocity(self) -> Optional[float]: + result = await self._rpc("GetCouplingFluidSoundVelocity") + self._ensure_success("GetCouplingFluidSoundVelocity", result) + return _float_or_none( + _value_by_any_key(result.values, "CouplingFluidSoundVelocity", "Value") + ) + + async def get_focus_tof(self) -> Optional[float]: + result = await self._rpc("GetTOFFocus") + self._ensure_success("GetTOFFocus", result) + return _float_or_none(_value_by_any_key(result.values, "TOFFocus", "FocusTOF", "Value")) + + async def set_focus_tof(self, value: float) -> None: + self._require_lock("SetTOFFocus") + result = await self._rpc( + "SetTOFFocus", + (("TOFFocus", "string", _format_numeric_string(value)),), + ) + self._ensure_success("SetTOFFocus", result) + + async def get_duo_focus_tof(self) -> Tuple[Optional[float], Optional[float]]: + result = await self._rpc("GetDuoTOFFocus") + self._ensure_success("GetDuoTOFFocus", result) + values = _float_values(_value_by_any_key(result.values, "TOFFocus", "DuoFocusTOF", "Value")) + first = values[0] if len(values) >= 1 else None + second = values[1] if len(values) >= 2 else None + return first, second + + async def set_duo_focus_tof(self, first: float, second: float) -> None: + self._require_lock("SetDuoTOFFocus") + result = await self._rpc( + "SetDuoTOFFocus", + ( + ("TOFFocus", "string", _format_numeric_string(first)), + ("TOFFocus", "string", _format_numeric_string(second)), + ), + ) + self._ensure_success("SetDuoTOFFocus", result) + + async def get_scan_positions(self) -> EchoScanPositions: + result = await self._rpc("GetScanPositions") + self._ensure_success("GetScanPositions", result) + return _scan_positions_from_values(result.values) + + async def get_calibration_plate_names(self) -> list[str]: + result = await self._rpc("GetCalPlateNames") + self._ensure_success("GetCalPlateNames", result) + return _name_list_from_value(result.values.get("PlateType", _first_result_value(result))) + + async def get_focus_state(self) -> EchoFocusState: + """Read focus, sound-velocity, scanner, and power-calibration state.""" + tof_focus = await self.get_focus_tof() + duo_tof_focus = await self.get_duo_focus_tof() + coupling_fluid_sound_velocity = await self.get_coupling_fluid_sound_velocity() + scan_positions = await self.get_scan_positions() + power_calibration = await self.get_echo_power_calibration() + return EchoFocusState( + tof_focus=tof_focus, + duo_tof_focus=duo_tof_focus, + coupling_fluid_sound_velocity=coupling_fluid_sound_velocity, + scan_positions=scan_positions, + power_calibration=power_calibration, + ) + + async def calibrate_power( + self, + timeout: Optional[float] = None, + ) -> EchoPowerCalibrationResult: + self._require_lock("CalibratePower") + result = await self._rpc("CalibratePower", timeout=timeout) + self._ensure_success("CalibratePower", result) + return _power_calibration_result_from_values(result.values) + + async def commit_power_calibration( + self, + amp_feedback: float, + pulse_energy: float, + vpp: float, + timeout: Optional[float] = None, + ) -> None: + self._require_lock("CommitPwrCal") + result = await self._rpc( + "CommitPwrCal", + ( + ("AmpFeedback", "double", _format_numeric_string(amp_feedback)), + ("PulseEnergy", "double", _format_numeric_string(pulse_energy)), + ("Vpp", "double", _format_numeric_string(vpp)), + ), + timeout=timeout, + ) + self._ensure_success("CommitPwrCal", result) + + async def retract_source_gripper_for_scan_calibration( + self, + barcode_location: str = "Right-Side", + timeout: Optional[float] = None, + ) -> None: + self._require_lock("RetractSrcGripper4ScanCal") + result = await self._rpc( + "RetractSrcGripper4ScanCal", + (("BarCodeLocation", "string", barcode_location),), + timeout=timeout, + ) + self._ensure_success("RetractSrcGripper4ScanCal", result) + + async def retract_destination_gripper_for_scan_calibration( + self, + barcode_location: str = "Right-Side", + timeout: Optional[float] = None, + ) -> None: + self._require_lock("RetractDstGripper4ScanCal") + result = await self._rpc( + "RetractDstGripper4ScanCal", + (("BarCodeLocation", "string", barcode_location),), + timeout=timeout, + ) + self._ensure_success("RetractDstGripper4ScanCal", result) + + async def calibrate_scanner( + self, + barcode_location: str = "Right-Side", + timeout: Optional[float] = None, + ) -> EchoScannerCalibrationResult: + self._require_lock("CalibrateScanner") + result = await self._rpc( + "CalibrateScanner", + (("BarCodeLocation", "string", barcode_location),), + timeout=timeout, + ) + self._ensure_success("CalibrateScanner", result) + barcode = _value_by_any_key(result.values, "BarCode", "Barcode") + return EchoScannerCalibrationResult( + barcode=str(barcode) if barcode not in (None, "") else None, + status=str(result.status or ""), + raw=result.values, + ) + + async def cancel_scanner_calibration(self, timeout: Optional[float] = None) -> None: + self._require_lock("CancelCalibrateScanner") + result = await self._rpc("CancelCalibrateScanner", timeout=timeout) + self._ensure_success("CancelCalibrateScanner", result) + + async def focal_sweep(self, params: EchoFocalSweepParams) -> Dict[str, Any]: + self._require_lock("FocalSweep") + result = await self._rpc( + "FocalSweep", + ( + ("PlateType", "string", params.plate_type), + ("WellRow", "int", str(params.well_row)), + ("WellCol", "int", str(params.well_column)), + ("StartToF", "double", _format_numeric_string(params.start_tof)), + ("StopToF", "double", _format_numeric_string(params.stop_tof)), + ("IncrZ", "double", _format_numeric_string(params.increment_z)), + ("StartZ", "double", _format_numeric_string(params.start_z)), + ("StopZ", "double", _format_numeric_string(params.stop_z)), + ("Feature", "int", str(params.feature)), + ), + timeout=params.timeout, + ) + self._ensure_success("FocalSweep", result) + return result.values + + async def get_all_protocol_names(self) -> list[str]: + result = await self._rpc("GetAllProtocolNames") + self._ensure_success("GetAllProtocolNames", result) + return _name_list_from_value(result.values.get("ProtocolName", _first_result_value(result))) + + async def get_protocol(self, name: Optional[str] = None) -> Dict[str, Any]: + params: Tuple[Tuple[str, str, str], ...] = () + if name: + params = (("ProtocolName", "string", name),) + result = await self._rpc("GetProtocol", params) + self._ensure_success("GetProtocol", result) + return result.values + + async def get_instrument_lock_state(self, lock_id: Optional[str] = None) -> Dict[str, Any]: + params: Tuple[Tuple[str, str, str], ...] = () + if lock_id is not None: + params = (("LockID", "string", lock_id),) + result = await self._rpc("GetInstrumentLockState", params) + # This call sometimes reports not-locked through Status while still being operationally fine. + return result.values + + async def is_storage_mode(self) -> bool: + result = await self._rpc("IsStorageMode") + self._ensure_success("IsStorageMode", result) + return bool(_first_result_value(result)) + + async def has_security_key(self, security_key: str) -> bool: + result = await self._rpc( + "HasSecurityKey", + (("HasSecurityKeyStg", "string", security_key),), + ) + self._ensure_success("HasSecurityKey", result) + return bool(_first_result_value(result)) + + async def retrieve_parameter(self, param: str) -> Any: + result = await self._rpc( + "RetrieveParameter", + (("Param", "string", param),), + ) + self._ensure_success("RetrieveParameter", result) + return _first_result_value(result) + + async def store_parameter(self, param: str, value: Any) -> None: + value_type, value_text = _param_type_and_value(value) + result = await self._rpc( + "StoreParameter", + ( + ("Param", "string", param), + ("Value", value_type, value_text), + ), + ) + self._ensure_success("StoreParameter", result) + + async def get_fluid_info(self, fluid_type: str) -> EchoFluidInfo: + result = await self._rpc( + "GetFluidInfo", + (("FluidType", "string", fluid_type),), + ) + self._ensure_success("GetFluidInfo", result) + return EchoFluidInfo( + name=str(result.values.get("FluidName") or result.values.get("Name") or fluid_type), + description=str(result.values.get("Description", "")), + fc_min=_float_or_none(result.values.get("FCMin")), + fc_max=_float_or_none(result.values.get("FCMax")), + fc_units=str(result.values.get("FCUnits", "")), + raw=result.values, + ) + + async def get_all_fluid_types(self) -> list[EchoFluidInfo]: + result = await self._rpc("GetAllFluidTypes") + self._ensure_success("GetAllFluidTypes", result) + return _fluid_infos_from_values(result.values) + + async def get_fluids_for_plate(self, plate_type: str) -> list[EchoFluidInfo]: + result = await self._rpc( + "GetFluidsForPlate", + (("PlateType", "string", plate_type),), + ) + self._ensure_success("GetFluidsForPlate", result) + return _fluid_infos_from_values(result.values) + + async def get_transfer_volume_max_nl(self, plate_type: str) -> Any: + result = await self._rpc( + "GetTransferVolMaximumNl", + (("Value", "string", plate_type),), + ) + self._ensure_success("GetTransferVolMaximumNl", result) + return _first_result_value(result) + + async def get_transfer_volume_min_nl(self, plate_type: str) -> Any: + result = await self._rpc( + "GetTransferVolMinimumNl", + (("Value", "string", plate_type),), + ) + self._ensure_success("GetTransferVolMinimumNl", result) + return _first_result_value(result) + + async def get_transfer_volume_increment_nl(self, plate_type: str) -> Any: + result = await self._rpc( + "GetTransferVolIncrNl", + (("Value", "string", plate_type),), + ) + self._ensure_success("GetTransferVolIncrNl", result) + return _first_result_value(result) + + async def get_transfer_volume_resolution_nl(self, plate_type: str) -> Any: + result = await self._rpc( + "GetTransferVolResolutionNl", + (("Value", "string", plate_type),), + ) + self._ensure_success("GetTransferVolResolutionNl", result) + value = _first_result_value(result) + return _float_or_none(value) if value not in (None, "") else value + + async def check_source_plate_insert_compatibility(self, plate_type: str) -> Dict[str, Any]: + result = await self._rpc( + "CheckSrcPlateInsertCompatibility", + (("PlateType", "string", plate_type),), + ) + self._ensure_success("CheckSrcPlateInsertCompatibility", result) + return result.values + + async def lock(self, app: Optional[str] = None, owner: Optional[str] = None) -> None: + result = await self._rpc( + "LockInstrument", + ( + ("App", "string", app or self.app_name), + ("Owner", "string", owner or self.owner or f"{self.host}\\PyLabRobot"), + ("LockID", "string", self.token), + ), + ) + self._ensure_success("LockInstrument", result) + self._lock_held = True + + async def begin_session(self) -> Any: + self._require_lock("BeginSession") + result = await self._rpc("BeginSession") + self._ensure_success("BeginSession", result) + return _first_result_value(result) + + async def end_session(self) -> Any: + self._require_lock("EndSession") + result = await self._rpc("EndSession") + self._ensure_success("EndSession", result) + return _first_result_value(result) + + async def unlock(self) -> None: + if not self._lock_held: + return + + result = await self._rpc( + "UnlockInstrument", + (("LockID", "string", self.token),), + ) + if result.succeeded is False and _unlock_already_released(result.status): + logger.warning("UnlockInstrument reported stale local lock state: %s", result.status) + self._lock_held = False + return + self._ensure_success("UnlockInstrument", result) + self._lock_held = False + + async def open_source_plate(self, timeout: Optional[float] = None) -> None: + self._require_lock("PresentSrcPlateGripper") + result = await self._rpc("PresentSrcPlateGripper", timeout=timeout) + self._ensure_success("PresentSrcPlateGripper", result) + + async def close_source_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: Optional[float] = None, + ) -> Optional[str]: + self._require_lock("RetractSrcPlateGripper") + result = await self._rpc( + "RetractSrcPlateGripper", + self._make_retract_params(plate_type, barcode_location, barcode), + timeout=_resolve_timeout( + timeout, + DEFAULT_LOADED_RETRACT_TIMEOUT if plate_type is not None else self.timeout, + ), + ) + self._ensure_success("RetractSrcPlateGripper", result) + barcode_value = result.values.get("BarCode") + return None if barcode_value in (None, "") else str(barcode_value) + + async def open_destination_plate(self, timeout: Optional[float] = None) -> None: + self._require_lock("PresentDstPlateGripper") + result = await self._rpc("PresentDstPlateGripper", timeout=timeout) + self._ensure_success("PresentDstPlateGripper", result) + + async def close_destination_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: Optional[float] = None, + ) -> Optional[str]: + self._require_lock("RetractDstPlateGripper") + result = await self._rpc( + "RetractDstPlateGripper", + self._make_retract_params(plate_type, barcode_location, barcode), + timeout=_resolve_timeout( + timeout, + DEFAULT_LOADED_RETRACT_TIMEOUT if plate_type is not None else self.timeout, + ), + ) + self._ensure_success("RetractDstPlateGripper", result) + barcode_value = result.values.get("BarCode") + return None if barcode_value in (None, "") else str(barcode_value) + + async def close_door(self, timeout: Optional[float] = None) -> None: + self._require_lock("CloseDoor") + result = await self._rpc("CloseDoor", timeout=timeout) + self._ensure_success("CloseDoor", result) + + async def open_door(self, timeout: Optional[float] = None) -> None: + self._require_lock("OpenDoor") + result = await self._rpc("OpenDoor", timeout=timeout) + self._ensure_success("OpenDoor", result) + + async def home_axes(self, timeout: Optional[float] = None) -> None: + self._require_lock("HomeAxes") + result = await self._rpc("HomeAxes", timeout=_resolve_timeout(timeout, DEFAULT_HOME_TIMEOUT)) + self._ensure_success("HomeAxes", result) + + async def set_pump_direction(self, normal: bool = True, timeout: Optional[float] = None) -> None: + self._require_lock("SetPumpDir") + result = await self._rpc( + "SetPumpDir", + (("Value", "boolean", _format_bool(normal)),), + timeout=timeout, + ) + self._ensure_success("SetPumpDir", result) + + async def enable_bubbler_pump( + self, + enabled: bool = True, + timeout: Optional[float] = None, + ) -> None: + self._require_lock("EnableBubblerPump") + result = await self._rpc( + "EnableBubblerPump", + (("Value", "boolean", _format_bool(enabled)),), + timeout=timeout, + ) + self._ensure_success("EnableBubblerPump", result) + + async def actuate_bubbler_nozzle( + self, + up: bool, + timeout: Optional[float] = None, + ) -> None: + self._require_lock("ActuateBubblerNozzle") + result = await self._rpc( + "ActuateBubblerNozzle", + (("Value", "boolean", _format_bool(up)),), + timeout=timeout, + ) + self._ensure_success("ActuateBubblerNozzle", result) + + async def raise_coupling_fluid(self, timeout: Optional[float] = None) -> None: + await self.actuate_bubbler_nozzle(True, timeout=timeout) + + async def lower_coupling_fluid(self, timeout: Optional[float] = None) -> None: + await self.actuate_bubbler_nozzle(False, timeout=timeout) + + async def enable_vacuum_nozzle( + self, + enabled: bool, + timeout: Optional[float] = None, + ) -> None: + self._require_lock("EnableVacuumNozzle") + result = await self._rpc( + "EnableVacuumNozzle", + (("Value", "boolean", _format_bool(enabled)),), + timeout=timeout, + ) + self._ensure_success("EnableVacuumNozzle", result) + + async def actuate_vacuum_nozzle( + self, + engage: bool, + timeout: Optional[float] = None, + ) -> None: + self._require_lock("ActuateVacuumNozzle") + result = await self._rpc( + "ActuateVacuumNozzle", + (("Value", "boolean", _format_bool(engage)),), + timeout=timeout, + ) + self._ensure_success("ActuateVacuumNozzle", result) + + async def actuate_ionizer(self, enabled: bool, timeout: Optional[float] = None) -> None: + self._require_lock("ActuateIonizer") + result = await self._rpc( + "ActuateIonizer", + (("Value", "boolean", _format_bool(enabled)),), + timeout=timeout, + ) + self._ensure_success("ActuateIonizer", result) + + async def set_barcodes_check(self, enabled: bool) -> None: + self._require_lock("SetBarcodesCheck") + result = await self._rpc( + "SetBarcodesCheck", + (("DoBarcodesCheck", "boolean", _format_bool(enabled)),), + ) + self._ensure_success("SetBarcodesCheck", result) + + async def set_plate_map(self, plate_map: EchoPlateMap) -> None: + self._require_lock("SetPlateMap") + result = await self._rpc( + "SetPlateMap", + (("xmlPlateMap", "string", plate_map.to_xml()),), + ) + self._ensure_success("SetPlateMap", result) + + async def set_survey_data(self, survey_xml: str) -> None: + self._require_lock("SetSurveyData") + result = await self._rpc( + "SetSurveyData", + (("PlateSurveyData", "string", survey_xml),), + ) + self._ensure_success("SetSurveyData", result) + + async def survey_plate(self, params: EchoSurveyParams) -> Optional[EchoSurveyData]: + self._require_lock("PlateSurvey") + result = await self._rpc( + "PlateSurvey", + ( + ("PlateType", "string", params.plate_type), + ("StartRow", "int", str(params.start_row)), + ("StartCol", "int", str(params.start_col)), + ("NumRows", "int", str(params.num_rows)), + ("NumCols", "int", str(params.num_cols)), + ("Save", "boolean", "True" if params.save else "False"), + ("CheckSrc", "boolean", "True" if params.check_source else "False"), + ), + timeout=_resolve_timeout(params.timeout, DEFAULT_SURVEY_TIMEOUT), + ) + self._ensure_success("PlateSurvey", result) + survey_xml = _survey_xml_from_values(result.values) + if survey_xml is None: + logger.warning( + "PlateSurvey response did not include survey XML: %r", + _preview_rpc_values(result.values), + ) + return EchoSurveyData.from_xml(survey_xml) if survey_xml is not None else None + + async def get_survey_data(self) -> EchoSurveyData: + result = await self._rpc("GetSurveyData") + self._ensure_success("GetSurveyData", result) + survey_xml = _survey_xml_from_values(result.values) + if survey_xml is None: + logger.warning( + "GetSurveyData response did not include survey XML: %r", + _preview_rpc_values(result.values), + ) + raise EchoProtocolError("Survey XML missing from GetSurveyData response.") + return EchoSurveyData.from_xml(survey_xml) + + async def dry_plate(self, params: Optional[EchoDryPlateParams] = None) -> None: + self._require_lock("DryPlate") + params = params or EchoDryPlateParams() + result = await self._rpc( + "DryPlate", + (("Type", "string", params.mode.value),), + timeout=_resolve_timeout(params.timeout, DEFAULT_DRY_TIMEOUT), + ) + self._ensure_success("DryPlate", result) + + async def survey_source_plate( + self, + plate_map: EchoPlateMap, + survey: EchoSurveyParams, + *, + fetch_saved_data: bool = True, + dry_after: bool = False, + dry: Optional[EchoDryPlateParams] = None, + source_plate: Optional[Plate] = None, + update_volume_trackers: bool = True, + ) -> EchoSurveyRunResult: + await self.set_plate_map(plate_map) + saved_data = None + try: + response_data = await self.survey_plate(survey) + except EchoProtocolError as exc: + if not fetch_saved_data or not survey.save or not _is_gzip_protocol_error(exc): + raise + logger.warning( + "PlateSurvey returned an incomplete gzip response after the survey run; " + "recovering by reading the saved survey data." + ) + response_data = None + if fetch_saved_data and survey.save: + saved_data_error: BaseException | None = None + for attempt in range(2): + try: + saved_data = await self.get_survey_data() + saved_data_error = None + break + except (EchoProtocolError, EOFError, OSError, zlib.error) as exc: + saved_data_error = exc + if not _is_gzip_protocol_error(exc) or attempt > 0: + break + logger.warning( + "Retrying GetSurveyData after gzip decode failure: %s", + exc, + ) + await asyncio.sleep(0.5) + if saved_data_error is not None: + if response_data is None: + raise saved_data_error + logger.warning( + "Using PlateSurvey response data because GetSurveyData failed: %s", + saved_data_error, + ) + if update_volume_trackers and does_volume_tracking() and source_plate is not None: + data = saved_data or response_data + if data is not None: + data.apply_volumes_to_plate(source_plate) + dry_mode = None + if dry_after: + dry = dry or EchoDryPlateParams() + await self.dry_plate(dry) + dry_mode = dry.mode + return EchoSurveyRunResult( + response_data=response_data, + saved_data=saved_data, + dry_mode=dry_mode, + ) + + async def do_well_transfer( + self, + protocol_xml: str, + print_options: Optional[EchoTransferPrintOptions] = None, + timeout: Optional[float] = None, + ) -> EchoTransferResult: + self._require_lock("DoWellTransfer") + options = print_options or EchoTransferPrintOptions() + result = await self._rpc( + "DoWellTransfer", + ( + ("ProtocolName", "string", protocol_xml), + ("PrintOptions", "xml_element", _print_options_xml(options)), + ), + timeout=timeout, + ) + self._ensure_success("DoWellTransfer", result) + return _parse_echo_transfer_report( + _embedded_xml_from_values(result.values, " EchoTransferPlan: + return build_echo_transfer_plan( + source_plate, + destination_plate, + transfers, + source_plate_type=source_plate_type, + destination_plate_type=destination_plate_type, + protocol_name=protocol_name, + volume_unit=volume_unit, + ) + + async def transfer_wells( + self, + source_plate: Plate, + destination_plate: Plate, + transfers: Sequence[ + Union[EchoPlannedTransfer, Tuple[Union[str, Well], Union[str, Well], float]] + ], + *, + source_plate_type: Optional[str] = None, + destination_plate_type: Optional[str] = None, + protocol_name: str = "transfer", + volume_unit: str = "nL", + do_survey: bool = True, + close_door_before_transfer: bool = True, + print_options: Optional[EchoTransferPrintOptions] = None, + timeout: Optional[float] = None, + survey_timeout: Optional[float] = None, + update_volume_trackers: bool = True, + ) -> EchoTransferResult: + self._require_lock("TransferWells") + catalog = await self.get_echo_plate_catalog() + source_resolved = self._resolve_echo_plate_type_from_catalog( + source_plate, + "source", + source_plate_type, + catalog, + ) + destination_resolved = self._resolve_echo_plate_type_from_catalog( + destination_plate, + "destination", + destination_plate_type, + catalog, + ) + plan = self.build_transfer_plan( + source_plate, + destination_plate, + transfers, + source_plate_type=source_resolved.plate_type, + destination_plate_type=destination_resolved.plate_type, + protocol_name=protocol_name, + volume_unit=volume_unit, + ) + + await self.get_current_source_plate_type() + await self.get_current_destination_plate_type() + await self.retrieve_parameter("Client_IgnoreDestPlateSensor") + await self.retrieve_parameter("Client_IgnoreSourcePlateSensor") + await self.set_plate_map(plan.plate_map) + await self.get_plate_info(plan.source_plate_type) + + if close_door_before_transfer: + await self.close_door() + + if do_survey: + max_source_row = max(transfer.source.get_row() for transfer in plan.transfers) + survey_data = await self.survey_plate( + EchoSurveyParams( + plate_type=plan.source_plate_type, + start_row=0, + start_col=0, + num_rows=max_source_row + 1, + num_cols=source_resolved.info.columns, + save=True, + check_source=False, + timeout=survey_timeout, + ) + ) + if update_volume_trackers and does_volume_tracking() and survey_data is not None: + survey_data.apply_volumes_to_plate(source_plate) + + if update_volume_trackers: + _preflight_transfer_volume_tracking(plan) + + await self.get_dio_ex2() + await self.get_dio() + + result = await self.do_well_transfer( + plan.protocol_xml, + print_options + or EchoTransferPrintOptions( + save_survey=True, + save_print=True, + ), + timeout=_resolve_timeout(timeout, DEFAULT_TRANSFER_TIMEOUT), + ) + if update_volume_trackers: + _apply_transfer_volume_tracking(plan, result) + return result + + async def transfer( + self, + transfers: Sequence[EchoTransferInput], + *, + source_plate_type: Optional[str] = None, + destination_plate_type: Optional[str] = None, + protocol_name: str = "transfer", + volume_unit: str = "nL", + do_survey: bool = True, + close_door_before_transfer: bool = True, + print_options: Optional[EchoTransferPrintOptions] = None, + timeout: Optional[float] = None, + survey_timeout: Optional[float] = None, + update_volume_trackers: bool = True, + ) -> EchoTransferResult: + """Plan and execute Echo transfers from PLR wells. + + This is the highest-level transfer entry point: each transfer contains real PLR + source and destination wells, so the source and destination plates are inferred + from the well parents. Use ``transfer_wells`` when the caller only has well + identifiers and wants to pass the plates explicitly. + """ + source_plate, destination_plate = _infer_transfer_plates(transfers) + return await self.transfer_wells( + source_plate, + destination_plate, + transfers, + source_plate_type=source_plate_type, + destination_plate_type=destination_plate_type, + protocol_name=protocol_name, + volume_unit=volume_unit, + do_survey=do_survey, + close_door_before_transfer=close_door_before_transfer, + print_options=print_options, + timeout=timeout, + survey_timeout=survey_timeout, + update_volume_trackers=update_volume_trackers, + ) + + async def load_source_plate( + self, + plate_type: str, + *, + barcode_location: str = "Right-Side", + barcode: str = "", + operator_pause: Optional[OperatorPause] = None, + open_door_first: bool = True, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> EchoPlateWorkflowResult: + self._require_lock("LoadSourcePlate") + await self._require_registered_echo_plate_type(plate_type, "source") + if open_door_first: + await self.open_door() + await self.open_source_plate(timeout=present_timeout) + await _call_operator_pause(operator_pause, "source plate presented") + await self.get_power_calibration() + await self.get_plate_info(plate_type) + await self.get_current_source_plate_type() + barcode_result = await self.close_source_plate( + plate_type=plate_type, + barcode_location=barcode_location, + barcode=barcode, + timeout=retract_timeout, + ) + await self.retrieve_parameter("Client_IgnoreSourcePlateSensor") + current_plate_type = await self.get_current_source_plate_type() + dio = await self.get_dio_ex2() + return EchoPlateWorkflowResult( + side="source", + plate_type=plate_type, + plate_present=_is_plate_type_present(current_plate_type), + barcode=barcode_result or "", + current_plate_type=current_plate_type, + dio=dio, + ) + + async def load_destination_plate( + self, + plate_type: str, + *, + barcode_location: str = "Right-Side", + barcode: str = "", + operator_pause: Optional[OperatorPause] = None, + open_door_first: bool = True, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> EchoPlateWorkflowResult: + self._require_lock("LoadDestinationPlate") + await self._require_registered_echo_plate_type(plate_type, "destination") + if open_door_first: + await self.open_door() + await self.open_destination_plate(timeout=present_timeout) + await _call_operator_pause(operator_pause, "destination plate presented") + await self.get_power_calibration() + await self.get_plate_info(plate_type) + barcode_result = await self.close_destination_plate( + plate_type=plate_type, + barcode_location=barcode_location, + barcode=barcode, + timeout=retract_timeout, + ) + await self.retrieve_parameter("Client_IgnoreDestPlateSensor") + current_plate_type = await self.get_current_destination_plate_type() + dio = await self.get_dio_ex2() + return EchoPlateWorkflowResult( + side="destination", + plate_type=plate_type, + plate_present=_is_plate_type_present(current_plate_type), + barcode=barcode_result or "", + current_plate_type=current_plate_type, + dio=dio, + ) + + async def eject_source_plate( + self, + *, + operator_pause: Optional[OperatorPause] = None, + open_door_first: bool = False, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> EchoPlateWorkflowResult: + self._require_lock("EjectSourcePlate") + if open_door_first: + await self.open_door() + await self.open_source_plate(timeout=present_timeout) + await _call_operator_pause(operator_pause, "source plate presented for removal") + barcode_result = await self.close_source_plate(timeout=retract_timeout) + current_plate_type = await self.get_current_source_plate_type() + dio = await self.get_dio_ex2() + return EchoPlateWorkflowResult( + side="source", + plate_type=None, + plate_present=_is_plate_type_present(current_plate_type), + barcode=barcode_result or "", + current_plate_type=current_plate_type, + dio=dio, + ) + + async def eject_destination_plate( + self, + *, + operator_pause: Optional[OperatorPause] = None, + open_door_first: bool = False, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> EchoPlateWorkflowResult: + self._require_lock("EjectDestinationPlate") + if open_door_first: + await self.open_door() + await self.open_destination_plate(timeout=present_timeout) + await _call_operator_pause(operator_pause, "destination plate presented for removal") + barcode_result = await self.close_destination_plate(timeout=retract_timeout) + current_plate_type = await self.get_current_destination_plate_type() + dio = await self.get_dio_ex2() + return EchoPlateWorkflowResult( + side="destination", + plate_type=None, + plate_present=_is_plate_type_present(current_plate_type), + barcode=barcode_result or "", + current_plate_type=current_plate_type, + dio=dio, + ) + + async def eject_all_plates( + self, + *, + operator_pause: Optional[OperatorPause] = None, + close_door_after: bool = True, + open_door_first: bool = False, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> Tuple[EchoPlateWorkflowResult, EchoPlateWorkflowResult]: + source_result = await self.eject_source_plate( + operator_pause=operator_pause, + open_door_first=open_door_first, + present_timeout=present_timeout, + retract_timeout=retract_timeout, + ) + destination_result = await self.eject_destination_plate( + operator_pause=operator_pause, + open_door_first=False, + present_timeout=present_timeout, + retract_timeout=retract_timeout, + ) + if close_door_after: + await self.close_door() + return source_result, destination_result + + def _make_retract_params( + self, + plate_type: Optional[str], + barcode_location: Optional[str], + barcode: str, + ) -> Tuple[Tuple[str, str, str], ...]: + return ( + ("PlateType", "string", plate_type or "None"), + ("BarCodeLocation", "string", barcode_location or "None"), + ("BarCode", "string", barcode), + ) + + def _require_lock(self, method: str) -> None: + if not self._lock_held: + raise EchoCommandError(method, "An active lock is required for motion commands.") + + def _ensure_success(self, method: str, result: _RpcResult) -> None: + if result.succeeded is False: + raise EchoCommandError(method, result.status) + + async def _rpc( + self, + method: str, + params: Iterable[Tuple[str, str, str]] = (), + timeout: Optional[float] = None, + ) -> _RpcResult: + envelope = self._make_soap_envelope(method, params) + message = await self._send_request( + port=self.rpc_port, + host_header=self.token, + body_text=envelope, + timeout=timeout, + ) + return self._parse_rpc_result(method, message) + + async def _send_request( + self, + port: int, + host_header: str, + body_text: str, + timeout: Optional[float] = None, + ) -> _HttpMessage: + request_timeout = _resolve_timeout(timeout, self.timeout) + body_bytes = gzip.compress(body_text.encode("utf-8")) + request = ( + "POST /Medman HTTP/1.1\n" + f"Host: {host_header}\n" + f"Client: {self.client_version}\n" + f"Protocol: {self.protocol_version}\n" + 'Content-Type: text/xml; charset="utf-8"\n' + f"Content-Length: {len(body_bytes)}\n" + 'SOAPAction: "Some-URI"\r\n' + "\r\n" + ).encode("ascii") + body_bytes + + async with self._rpc_lock: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(self.host, port), + timeout=request_timeout, + ) + try: + writer.write(request) + await asyncio.wait_for(writer.drain(), timeout=request_timeout) + return await self._read_http_message(reader, timeout=request_timeout) + finally: + writer.close() + await writer.wait_closed() + + def _make_event_registration_request(self) -> bytes: + body_bytes = gzip.compress(f"add{self.token}".encode("utf-8")) + return ( + "POST /Medman HTTP/1.1\n" + f"Host: {self.token}\n" + f"Client: {self.client_version}\n" + f"Protocol: {self.protocol_version}\n" + 'Content-Type: text/xml; charset="utf-8"\n' + f"Content-Length: {len(body_bytes)}\n" + 'SOAPAction: "Some-URI"\r\n' + "\r\n" + ).encode("ascii") + body_bytes + + async def _read_http_message( + self, + reader: asyncio.StreamReader, + timeout: Optional[float] = None, + buffer: Optional[bytearray] = None, + ) -> _HttpMessage: + read_timeout = _resolve_timeout(timeout, self.timeout) + data = bytearray(buffer or b"") + if buffer is not None: + buffer.clear() + while HTTP_HEADER_END not in data: + chunk = await asyncio.wait_for(reader.read(4096), timeout=read_timeout) + if not chunk: + raise EchoProtocolError("Connection closed before headers arrived.") + data.extend(chunk) + + header_blob, rest = bytes(data).split(HTTP_HEADER_END, 1) + header_lines = header_blob.decode("iso-8859-1").split("\r\n") + start_line = header_lines[0] + headers: Dict[str, str] = {} + for line in header_lines[1:]: + if not line or ":" not in line: + continue + key, value = line.split(":", 1) + headers[key.strip().lower()] = value.strip() + + content_length_header = headers.get("content-length") + try: + content_length = int(content_length_header) if content_length_header is not None else None + except ValueError: + content_length = None + + if content_length is not None and content_length > 0: + framed = await self._read_exact( + reader, + content_length, + initial=rest, + timeout=read_timeout, + ) + body = framed[:content_length] + extra = framed[content_length:] + else: + body = rest + extra = b"" + + if _is_probably_gzip(body) and not _gzip_stream_complete(body): + body, extra = await self._read_until_complete_gzip_body( + reader, + initial=body + extra, + advertised_length=content_length, + timeout=read_timeout, + ) + if buffer is not None and extra: + buffer.extend(extra) + return _HttpMessage(start_line=start_line, headers=headers, body=body) + + async def _read_exact( + self, + reader: asyncio.StreamReader, + want: int, + initial: bytes = b"", + timeout: Optional[float] = None, + ) -> bytes: + read_timeout = _resolve_timeout(timeout, self.timeout) + data = bytearray(initial) + while len(data) < want: + chunk = await asyncio.wait_for(reader.read(want - len(data)), timeout=read_timeout) + if not chunk: + raise EchoProtocolError("Connection closed before full body arrived.") + data.extend(chunk) + return bytes(data) + + async def _read_until_complete_gzip_body( + self, + reader: asyncio.StreamReader, + *, + initial: bytes, + advertised_length: Optional[int], + timeout: Optional[float] = None, + ) -> Tuple[bytes, bytes]: + read_timeout = _resolve_timeout(timeout, self.timeout) + data = bytearray(initial) + while True: + split = _split_complete_gzip_body(bytes(data)) + if split is not None: + body, extra = split + break + chunk = await asyncio.wait_for(reader.read(4096), timeout=read_timeout) + if not chunk: + advertised = "unknown" if advertised_length is None else str(advertised_length) + raise EchoProtocolError( + "Connection closed before complete gzip body arrived " + f"(advertised {advertised} bytes, received {len(data)} bytes)." + ) + data.extend(chunk) + + if advertised_length is not None and len(body) != advertised_length: + logger.warning( + "Echo response gzip body exceeded advertised Content-Length: header=%s actual=%s", + advertised_length, + len(body), + ) + return body, extra + + def _parse_rpc_result(self, method: str, message: _HttpMessage) -> _RpcResult: + payload_bytes = message.decoded_body_bytes() + body_text = payload_bytes.decode("utf-8", errors="replace") + try: + root = ET.fromstring(body_text) + except ET.ParseError as exc: + dump_paths = _dump_malformed_xml_response( + method, + payload_bytes=payload_bytes, + body_text=body_text, + ) + details = "" + if dump_paths is not None: + raw_path, text_path = dump_paths + details = f" Saved raw body to {raw_path} and decoded body to {text_path}." + raise EchoProtocolError(f"Malformed XML in {method} response.{details}") from exc + + fault_status = _soap_fault_status(root) + if fault_status is not None: + return _RpcResult( + method=method, + values={"SUCCEEDED": False, "Status": fault_status}, + succeeded=False, + status=fault_status, + ) + + payload = self._extract_payload_element(root) + values: Dict[str, Any] = {} + for child in payload: + key = _local_name(child.tag) + value = _element_value(child) + if key in values: + existing_value = values[key] + if isinstance(existing_value, list): + existing_value.append(value) + else: + values[key] = [existing_value, value] + else: + values[key] = value + + succeeded_value = values.get("SUCCEEDED") + return _RpcResult( + method=method, + values=values, + succeeded=succeeded_value if isinstance(succeeded_value, bool) else None, + status=str(values["Status"]) if "Status" in values else None, + ) + + def _extract_payload_element(self, root: ET.Element) -> ET.Element: + body = next((node for node in root if _local_name(node.tag) == "Body"), None) + if body is None or len(body) == 0: + raise EchoProtocolError("SOAP body missing from response.") + outer = body[0] + if len(outer) == 0: + return outer + return outer[0] + + def _make_soap_envelope( + self, + method: str, + params: Iterable[Tuple[str, str, str]], + ) -> str: + envelope = ET.Element( + "SOAP-ENV:Envelope", + { + "xmlns:SOAP-ENV": "http://schemas.xmlsoap.org/soap/envelope/", + "xmlns:xsd": "http://www.w3.org/2001/XMLSchema", + "SOAP-ENV:encodingStyle": "http://schemas.xmlsoap.org/soap/encoding/", + "xmlns:SOAPSDK1": "http://www.w3.org/2001/XMLSchema", + "xmlns:SOAPSDK2": "http://www.w3.org/2001/XMLSchema-instance", + "xmlns:SOAPSDK3": "http://schemas.xmlsoap.org/soap/encoding/", + }, + ) + body = ET.SubElement( + envelope, + "SOAP-ENV:Body", + {"SOAP-ENV:encodingStyle": "http://schemas.xmlsoap.org/soap/encoding/"}, + ) + method_el = ET.SubElement( + body, + method, + {"SOAP-ENV:encodingStyle": "http://schemas.xmlsoap.org/soap/encoding/"}, + ) + for name, value_type, value in params: + if value_type == "xml_element": + try: + param = _strip_fragment_namespace_expansions(ET.fromstring(value)) + except ET.ParseError as exc: + raise EchoProtocolError(f"Invalid XML payload for {method}.{name}.") from exc + method_el.append(param) + continue + param = ET.SubElement( + method_el, + name, + { + "SOAP-ENV:encodingStyle": "http://schemas.xmlsoap.org/soap/encoding/", + "type": f"xsd:{value_type}", + }, + ) + param.text = value + + return '' + ET.tostring( + envelope, + encoding="unicode", + short_empty_elements=True, + ) + + +class EchoPlateAccessBackend(PlateAccessBackend): + """Plate-access backend backed by the Echo Medman protocol.""" + + def __init__(self, driver: EchoDriver): + self.driver = driver + + async def lock(self, app: Optional[str] = None, owner: Optional[str] = None) -> None: + await self.driver.lock(app=app, owner=owner) + + async def unlock(self) -> None: + await self.driver.unlock() + + async def get_access_state(self) -> PlateAccessState: + return await self.driver.get_access_state() + + async def open_source_plate(self, timeout: Optional[float] = None) -> None: + await self.driver.open_source_plate(timeout=timeout) + + async def close_source_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: Optional[float] = None, + ) -> Optional[str]: + return await self.driver.close_source_plate( + plate_type=plate_type, + barcode_location=barcode_location, + barcode=barcode, + timeout=timeout, + ) + + async def open_destination_plate(self, timeout: Optional[float] = None) -> None: + await self.driver.open_destination_plate(timeout=timeout) + + async def close_destination_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: Optional[float] = None, + ) -> Optional[str]: + return await self.driver.close_destination_plate( + plate_type=plate_type, + barcode_location=barcode_location, + barcode=barcode, + timeout=timeout, + ) + + async def close_door(self, timeout: Optional[float] = None) -> None: + state = await self.driver.get_access_state() + open_paths = [ + path + for path, is_open in ( + ("source", state.source_access_open), + ("destination", state.destination_access_open), + ) + if is_open + ] + if open_paths: + active_paths = ", ".join(open_paths) + raise EchoCommandError( + "CloseDoor", + f"Cannot close the door while {active_paths} access is still open.", + ) + unknown_paths = [ + path + for path, is_open in ( + ("source", state.source_access_open), + ("destination", state.destination_access_open), + ) + if is_open is None + ] + if unknown_paths: + unknown = ", ".join(unknown_paths) + raise EchoCommandError( + "CloseDoor", + f"Cannot confirm {unknown} access is closed before closing the door.", + ) + await self.driver.close_door(timeout=timeout) + + +class EchoPlatePosition(ResourceHolder): + """A physical Echo source or destination plate position.""" + + def __init__(self, name: str, role: str): + super().__init__( + name=name, + size_x=127.76, + size_y=85.48, + size_z=20.0, + category="labcyte_echo_plate_position", + model=f"labcyte_echo_{role}_position", + child_location=Coordinate.zero(), + ) + self.role = role + + def assign_child_resource( + self, + resource: Resource, + location: Optional[Coordinate] = None, + reassign: bool = True, + ): + if not isinstance(resource, Plate): + raise TypeError("Echo plate positions can only hold PLR Plate resources.") + return super().assign_child_resource(resource, location, reassign) + + @property + def plate(self) -> Optional[Plate]: + resource = self.resource + return resource if isinstance(resource, Plate) else None + + @plate.setter + def plate(self, plate: Optional[Plate]) -> None: + self.resource = plate + + +class Echo(Device): + """Labcyte Echo access-control device frontend.""" + + def __init__( + self, + host: str, + rpc_port: int = DEFAULT_RPC_PORT, + event_port: int = DEFAULT_EVENT_PORT, + timeout: float = DEFAULT_TIMEOUT, + app_name: str = "PyLabRobot Echo", + owner: Optional[str] = None, + token: Optional[str] = None, + ): + driver = EchoDriver( + host=host, + rpc_port=rpc_port, + event_port=event_port, + timeout=timeout, + app_name=app_name, + owner=owner, + token=token, + ) + super().__init__(driver=driver) + self.driver: EchoDriver = driver + self.plate_access = PlateAccess(backend=EchoPlateAccessBackend(driver)) + self._capabilities = [self.plate_access] + self.deck = Resource( + name="labcyte_echo", + size_x=360.0, + size_y=300.0, + size_z=260.0, + category="labcyte_echo", + model="Labcyte Echo", + ) + self.source_position = EchoPlatePosition(name="echo_source_position", role="source") + self.destination_position = EchoPlatePosition( + name="echo_destination_position", + role="destination", + ) + self.deck.assign_child_resource(self.source_position, location=Coordinate(75.0, 120.0, 0.0)) + self.deck.assign_child_resource( + self.destination_position, + location=Coordinate(205.0, 120.0, 0.0), + ) + + @property + def source_plate(self) -> Optional[Plate]: + """Return the PLR plate assigned to the Echo source position.""" + return self.source_position.plate + + @source_plate.setter + def source_plate(self, plate: Optional[Plate]) -> None: + self.source_position.plate = plate + + @property + def destination_plate(self) -> Optional[Plate]: + """Return the PLR plate assigned to the Echo destination position.""" + return self.destination_position.plate + + @destination_plate.setter + def destination_plate(self, plate: Optional[Plate]) -> None: + self.destination_position.plate = plate + + @need_setup_finished + async def get_instrument_info(self) -> EchoInstrumentInfo: + """Return instrument identity and status information.""" + return await self.driver.get_instrument_info() + + @need_setup_finished + async def get_dio(self) -> Dict[str, Any]: + """Return the raw ``GetDIO`` status payload.""" + return await self.driver.get_dio() + + @need_setup_finished + async def get_dio_ex(self) -> Dict[str, Any]: + """Return the raw ``GetDIOEx`` status payload.""" + return await self.driver.get_dio_ex() + + @need_setup_finished + async def get_dio_ex2(self) -> Dict[str, Any]: + """Return the raw ``GetDIOEx2`` status payload.""" + return await self.driver.get_dio_ex2() + + @need_setup_finished + async def get_echo_configuration( + self, + config_xml: str = DEFAULT_ECHO_CONFIGURATION_QUERY, + ) -> str: + """Return the raw Echo configuration XML.""" + return await self.driver.get_echo_configuration(config_xml=config_xml) + + @need_setup_finished + async def get_power_calibration(self) -> Dict[str, Any]: + """Return the raw ``GetPwrCal`` payload.""" + return await self.driver.get_power_calibration() + + @need_setup_finished + async def get_echo_power_calibration(self) -> EchoPowerCalibration: + """Return typed power calibration values.""" + return await self.driver.get_echo_power_calibration() + + @need_setup_finished + async def open_event_stream(self, timeout: Optional[float] = None) -> EchoEventStream: + """Open a persistent Medman event stream on port 8010.""" + return await self.driver.open_event_stream(timeout=timeout) + + @need_setup_finished + async def read_events( + self, + *, + max_events: int, + timeout: Optional[float] = None, + ) -> list[EchoEvent]: + """Open an event stream, read a fixed number of events, then close it.""" + return await self.driver.read_events(max_events=max_events, timeout=timeout) + + @need_setup_finished + async def get_access_state(self) -> PlateAccessState: + """Return the current access state.""" + return await self.plate_access.get_access_state() + + @need_setup_finished + async def get_current_source_plate_type(self) -> Optional[str]: + """Return the Echo-reported current source plate type.""" + return await self.driver.get_current_source_plate_type() + + @need_setup_finished + async def get_current_destination_plate_type(self) -> Optional[str]: + """Return the Echo-reported current destination plate type.""" + return await self.driver.get_current_destination_plate_type() + + @need_setup_finished + async def is_source_plate_present(self) -> bool: + """Return whether the Echo has a registered source plate loaded.""" + return await self.driver.is_source_plate_present() + + @need_setup_finished + async def is_destination_plate_present(self) -> bool: + """Return whether the Echo has a registered destination plate loaded.""" + return await self.driver.is_destination_plate_present() + + @need_setup_finished + async def get_destination_plate_offset(self) -> Any: + """Return the raw destination plate offset value.""" + return await self.driver.get_destination_plate_offset() + + @need_setup_finished + async def get_all_source_plate_names(self) -> list[str]: + """Return the Echo catalog of source plate names.""" + return await self.driver.get_all_source_plate_names() + + @need_setup_finished + async def get_all_destination_plate_names(self) -> list[str]: + """Return the Echo catalog of destination plate names.""" + return await self.driver.get_all_destination_plate_names() + + @need_setup_finished + async def get_plate_info(self, plate_type_ex: str) -> Dict[str, Any]: + """Return the raw ``GetPlateInfoEx`` payload.""" + return await self.driver.get_plate_info(plate_type_ex) + + @need_setup_finished + async def get_echo_plate_info(self, plate_type: str) -> EchoPlateInfo: + """Return typed Echo catalog metadata for a registered plate type.""" + return await self.driver.get_echo_plate_info(plate_type) + + @need_setup_finished + async def set_plate_info_ex(self, plate_type: str, values: Dict[str, Any]) -> None: + """Create or update an Echo plate definition through ``SetPlateInfoEx``.""" + await self.driver.set_plate_info_ex(plate_type, values) + + @need_setup_finished + async def remove_plate_info(self, plate_type: str) -> None: + """Remove an Echo plate definition through ``RemovePlateInfo``.""" + await self.driver.remove_plate_info(plate_type) + + @need_setup_finished + async def clone_destination_plate_definition( + self, + base_plate_type: str, + new_plate_type: str, + ) -> EchoPlateInfo: + """Clone an existing destination plate definition under a new destination name.""" + return await self.driver.clone_destination_plate_definition(base_plate_type, new_plate_type) + + @need_setup_finished + async def delete_destination_plate_definition(self, plate_type: str) -> bool: + """Delete a destination plate definition and verify it leaves the catalog.""" + return await self.driver.delete_destination_plate_definition(plate_type) + + @need_setup_finished + async def get_echo_plate_catalog(self) -> EchoPlateCatalog: + """Return the source and destination plate catalogs registered on the Echo.""" + return await self.driver.get_echo_plate_catalog() + + @need_setup_finished + async def resolve_echo_plate_type( + self, + plate: Plate, + side: str, + plate_type: Optional[str] = None, + ) -> EchoResolvedPlateType: + """Resolve and validate a PLR plate against the Echo instrument catalog.""" + return await self.driver.resolve_echo_plate_type(plate, side, plate_type=plate_type) + + @need_setup_finished + async def get_plate_insert(self, plate_type: str) -> Any: + """Return the plate-insert information for the given plate type.""" + return await self.driver.get_plate_insert(plate_type) + + @need_setup_finished + async def get_current_plate_insert(self) -> Any: + """Return the current plate-insert selection.""" + return await self.driver.get_current_plate_insert() + + @need_setup_finished + async def get_all_plate_inserts(self) -> list[str]: + """Return all registered plate inserts.""" + return await self.driver.get_all_plate_inserts() + + @need_setup_finished + async def get_coupling_fluid_sound_velocity(self) -> Optional[float]: + """Return the Echo coupling-fluid sound velocity.""" + return await self.driver.get_coupling_fluid_sound_velocity() + + @need_setup_finished + async def get_focus_tof(self) -> Optional[float]: + """Return the Echo focus time-of-flight value.""" + return await self.driver.get_focus_tof() + + @need_setup_finished + async def set_focus_tof(self, value: float) -> None: + """Set the Echo focus time-of-flight value.""" + await self.driver.set_focus_tof(value) + + @need_setup_finished + async def get_duo_focus_tof(self) -> Tuple[Optional[float], Optional[float]]: + """Return the Echo duo focus time-of-flight values.""" + return await self.driver.get_duo_focus_tof() + + @need_setup_finished + async def set_duo_focus_tof(self, first: float, second: float) -> None: + """Set the Echo duo focus time-of-flight values.""" + await self.driver.set_duo_focus_tof(first, second) + + @need_setup_finished + async def get_scan_positions(self) -> EchoScanPositions: + """Return scanner calibration position flags.""" + return await self.driver.get_scan_positions() + + @need_setup_finished + async def get_calibration_plate_names(self) -> list[str]: + """Return Echo calibration plate type names.""" + return await self.driver.get_calibration_plate_names() + + @need_setup_finished + async def get_focus_state(self) -> EchoFocusState: + """Return read-side Echo focus and calibration state.""" + return await self.driver.get_focus_state() + + @need_setup_finished + async def calibrate_power( + self, + timeout: Optional[float] = None, + ) -> EchoPowerCalibrationResult: + """Run low-level Echo power calibration.""" + return await self.driver.calibrate_power(timeout=timeout) + + @need_setup_finished + async def commit_power_calibration( + self, + amp_feedback: float, + pulse_energy: float, + vpp: float, + timeout: Optional[float] = None, + ) -> None: + """Commit low-level Echo power calibration values.""" + await self.driver.commit_power_calibration( + amp_feedback=amp_feedback, + pulse_energy=pulse_energy, + vpp=vpp, + timeout=timeout, + ) + + @need_setup_finished + async def retract_source_gripper_for_scan_calibration( + self, + barcode_location: str = "Right-Side", + timeout: Optional[float] = None, + ) -> None: + """Retract the source gripper using the scanner-calibration path.""" + await self.driver.retract_source_gripper_for_scan_calibration( + barcode_location=barcode_location, + timeout=timeout, + ) + + @need_setup_finished + async def retract_destination_gripper_for_scan_calibration( + self, + barcode_location: str = "Right-Side", + timeout: Optional[float] = None, + ) -> None: + """Retract the destination gripper using the scanner-calibration path.""" + await self.driver.retract_destination_gripper_for_scan_calibration( + barcode_location=barcode_location, + timeout=timeout, + ) + + @need_setup_finished + async def calibrate_scanner( + self, + barcode_location: str = "Right-Side", + timeout: Optional[float] = None, + ) -> EchoScannerCalibrationResult: + """Run low-level Echo scanner calibration.""" + return await self.driver.calibrate_scanner( + barcode_location=barcode_location, + timeout=timeout, + ) + + @need_setup_finished + async def cancel_scanner_calibration(self, timeout: Optional[float] = None) -> None: + """Cancel scanner calibration in progress.""" + await self.driver.cancel_scanner_calibration(timeout=timeout) + + @need_setup_finished + async def focal_sweep(self, params: EchoFocalSweepParams) -> Dict[str, Any]: + """Run the low-level Echo ``FocalSweep`` calibration RPC.""" + return await self.driver.focal_sweep(params) + + @need_setup_finished + async def get_all_protocol_names(self) -> list[str]: + """Return the Echo protocol-name catalog.""" + return await self.driver.get_all_protocol_names() + + @need_setup_finished + async def get_protocol(self, name: Optional[str] = None) -> Dict[str, Any]: + """Return the raw ``GetProtocol`` payload.""" + return await self.driver.get_protocol(name=name) + + @need_setup_finished + async def get_instrument_lock_state(self, lock_id: Optional[str] = None) -> Dict[str, Any]: + """Return the raw ``GetInstrumentLockState`` payload.""" + return await self.driver.get_instrument_lock_state(lock_id=lock_id) + + @need_setup_finished + async def is_storage_mode(self) -> bool: + """Return whether the Echo reports being in storage mode.""" + return await self.driver.is_storage_mode() + + @need_setup_finished + async def has_security_key(self, security_key: str) -> bool: + """Return whether the requested security key is present.""" + return await self.driver.has_security_key(security_key) + + @need_setup_finished + async def retrieve_parameter(self, param: str) -> Any: + """Retrieve a named Echo parameter.""" + return await self.driver.retrieve_parameter(param) + + @need_setup_finished + async def store_parameter(self, param: str, value: Any) -> None: + """Store a named Echo parameter.""" + await self.driver.store_parameter(param, value) + + @need_setup_finished + async def get_fluid_info(self, fluid_type: str) -> EchoFluidInfo: + """Return fluid metadata for a known Echo fluid type.""" + return await self.driver.get_fluid_info(fluid_type) + + @need_setup_finished + async def get_all_fluid_types(self) -> list[EchoFluidInfo]: + """Return all Echo fluid types.""" + return await self.driver.get_all_fluid_types() + + @need_setup_finished + async def get_fluids_for_plate(self, plate_type: str) -> list[EchoFluidInfo]: + """Return Echo fluid types compatible with the requested plate.""" + return await self.driver.get_fluids_for_plate(plate_type) + + @need_setup_finished + async def get_transfer_volume_max_nl(self, plate_type: str) -> Any: + """Return the maximum transfer volume, in nL, for the given plate type.""" + return await self.driver.get_transfer_volume_max_nl(plate_type) + + @need_setup_finished + async def get_transfer_volume_min_nl(self, plate_type: str) -> Any: + """Return the minimum transfer volume, in nL, for the given plate type.""" + return await self.driver.get_transfer_volume_min_nl(plate_type) + + @need_setup_finished + async def get_transfer_volume_increment_nl(self, plate_type: str) -> Any: + """Return the transfer increment, in nL, for the given plate type.""" + return await self.driver.get_transfer_volume_increment_nl(plate_type) + + @need_setup_finished + async def get_transfer_volume_resolution_nl(self, plate_type: str) -> Any: + """Return the transfer resolution, in nL, for the given plate type.""" + return await self.driver.get_transfer_volume_resolution_nl(plate_type) + + @need_setup_finished + async def check_source_plate_insert_compatibility(self, plate_type: str) -> Dict[str, Any]: + """Return the raw source plate insert compatibility result.""" + return await self.driver.check_source_plate_insert_compatibility(plate_type) + + @need_setup_finished + async def lock(self, app: Optional[str] = None, owner: Optional[str] = None) -> None: + """Lock the Echo for exclusive control.""" + await self.plate_access.lock(app=app, owner=owner) + + @need_setup_finished + async def begin_session(self) -> Any: + """Begin an Echo session and return the session identifier when present.""" + return await self.driver.begin_session() + + @need_setup_finished + async def end_session(self) -> Any: + """End the current Echo session.""" + return await self.driver.end_session() + + @need_setup_finished + async def unlock(self) -> None: + """Release the Echo lock held by this client.""" + await self.plate_access.unlock() + + @need_setup_finished + async def open_source_plate( + self, + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Present the source-side access path and return the final access state.""" + return await self.plate_access.open_source_plate(timeout=timeout, poll_interval=poll_interval) + + @need_setup_finished + async def close_source_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Retract the source-side access path and return the final access state.""" + return await self.plate_access.close_source_plate( + plate_type=plate_type, + barcode_location=barcode_location, + barcode=barcode, + timeout=timeout, + poll_interval=poll_interval, + ) + + @need_setup_finished + async def open_destination_plate( + self, + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Present the destination-side access path and return the final access state.""" + return await self.plate_access.open_destination_plate( + timeout=timeout, + poll_interval=poll_interval, + ) + + @need_setup_finished + async def close_destination_plate( + self, + plate_type: Optional[str] = None, + barcode_location: Optional[str] = None, + barcode: str = "", + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Retract the destination-side access path and return the final access state.""" + return await self.plate_access.close_destination_plate( + plate_type=plate_type, + barcode_location=barcode_location, + barcode=barcode, + timeout=timeout, + poll_interval=poll_interval, + ) + + @need_setup_finished + async def close_door( + self, + timeout: float = 30.0, + poll_interval: float = 0.1, + ) -> PlateAccessState: + """Close the Echo door and return the final access state.""" + return await self.plate_access.close_door(timeout=timeout, poll_interval=poll_interval) + + @need_setup_finished + async def open_door(self, timeout: Optional[float] = None) -> None: + """Open the Echo door.""" + await self.driver.open_door(timeout=timeout) + + @need_setup_finished + async def home_axes(self, timeout: Optional[float] = None) -> None: + """Home all Echo axes.""" + await self.driver.home_axes(timeout=timeout) + + @need_setup_finished + async def set_pump_direction( + self, + normal: bool = True, + timeout: Optional[float] = None, + ) -> None: + """Set the coupling-fluid pump direction.""" + await self.driver.set_pump_direction(normal=normal, timeout=timeout) + + @need_setup_finished + async def enable_bubbler_pump( + self, + enabled: bool = True, + timeout: Optional[float] = None, + ) -> None: + """Enable or disable the coupling-fluid bubbler pump.""" + await self.driver.enable_bubbler_pump(enabled=enabled, timeout=timeout) + + @need_setup_finished + async def actuate_bubbler_nozzle( + self, + up: bool, + timeout: Optional[float] = None, + ) -> None: + """Raise or lower the coupling-fluid bubbler nozzle.""" + await self.driver.actuate_bubbler_nozzle(up=up, timeout=timeout) + + @need_setup_finished + async def raise_coupling_fluid(self, timeout: Optional[float] = None) -> None: + """Raise the coupling fluid.""" + await self.driver.raise_coupling_fluid(timeout=timeout) + + @need_setup_finished + async def lower_coupling_fluid(self, timeout: Optional[float] = None) -> None: + """Lower the coupling fluid.""" + await self.driver.lower_coupling_fluid(timeout=timeout) + + @need_setup_finished + async def enable_vacuum_nozzle( + self, + enabled: bool, + timeout: Optional[float] = None, + ) -> None: + """Enable or disable the vacuum pump/nozzle control.""" + await self.driver.enable_vacuum_nozzle(enabled=enabled, timeout=timeout) + + @need_setup_finished + async def actuate_vacuum_nozzle( + self, + engage: bool, + timeout: Optional[float] = None, + ) -> None: + """Engage or release the vacuum nozzle mechanism.""" + await self.driver.actuate_vacuum_nozzle(engage=engage, timeout=timeout) + + @need_setup_finished + async def actuate_ionizer(self, enabled: bool, timeout: Optional[float] = None) -> None: + """Enable or disable the ionizer.""" + await self.driver.actuate_ionizer(enabled=enabled, timeout=timeout) + + @need_setup_finished + async def set_plate_map(self, plate_map: EchoPlateMap) -> None: + """Upload an Echo source plate map.""" + await self.driver.set_plate_map(plate_map) + + @need_setup_finished + async def set_barcodes_check(self, enabled: bool) -> None: + """Enable or disable barcode checking.""" + await self.driver.set_barcodes_check(enabled) + + @need_setup_finished + async def set_survey_data(self, survey_xml: str) -> None: + """Upload survey XML via ``SetSurveyData``.""" + await self.driver.set_survey_data(survey_xml) + + @need_setup_finished + async def survey_plate(self, params: EchoSurveyParams) -> Optional[EchoSurveyData]: + """Run ``PlateSurvey`` and return any survey XML included in the response.""" + return await self.driver.survey_plate(params) + + @need_setup_finished + async def get_survey_data(self) -> EchoSurveyData: + """Return the last saved Echo survey dataset.""" + return await self.driver.get_survey_data() + + @need_setup_finished + async def dry_plate(self, params: Optional[EchoDryPlateParams] = None) -> None: + """Run ``DryPlate`` with the requested mode.""" + await self.driver.dry_plate(params) + + @need_setup_finished + async def survey_source_plate( + self, + plate_map: EchoPlateMap, + survey: EchoSurveyParams, + *, + fetch_saved_data: bool = True, + dry_after: bool = False, + dry: Optional[EchoDryPlateParams] = None, + source_plate: Optional[Plate] = None, + update_volume_trackers: bool = True, + ) -> EchoSurveyRunResult: + """Run the Echo source-plate survey workflow without changing access state.""" + return await self.driver.survey_source_plate( + plate_map, + survey, + fetch_saved_data=fetch_saved_data, + dry_after=dry_after, + dry=dry, + source_plate=source_plate, + update_volume_trackers=update_volume_trackers, + ) + + def build_transfer_plan( + self, + source_plate: Plate, + destination_plate: Plate, + transfers: Sequence[ + Union[EchoPlannedTransfer, Tuple[Union[str, Well], Union[str, Well], float]] + ], + *, + source_plate_type: Optional[str] = None, + destination_plate_type: Optional[str] = None, + protocol_name: str = "transfer", + volume_unit: str = "nL", + ) -> EchoTransferPlan: + """Build Echo protocol XML and source plate map from PLR resources.""" + return self.driver.build_transfer_plan( + source_plate, + destination_plate, + transfers, + source_plate_type=source_plate_type, + destination_plate_type=destination_plate_type, + protocol_name=protocol_name, + volume_unit=volume_unit, + ) + + @need_setup_finished + async def do_well_transfer( + self, + protocol_xml: str, + print_options: Optional[EchoTransferPrintOptions] = None, + timeout: Optional[float] = None, + ) -> EchoTransferResult: + """Run ``DoWellTransfer`` with an embedded protocol XML document.""" + return await self.driver.do_well_transfer( + protocol_xml=protocol_xml, + print_options=print_options, + timeout=timeout, + ) + + @need_setup_finished + async def transfer_wells( + self, + source_plate: Plate, + destination_plate: Plate, + transfers: Sequence[ + Union[EchoPlannedTransfer, Tuple[Union[str, Well], Union[str, Well], float]] + ], + *, + source_plate_type: Optional[str] = None, + destination_plate_type: Optional[str] = None, + protocol_name: str = "transfer", + volume_unit: str = "nL", + do_survey: bool = True, + close_door_before_transfer: bool = True, + print_options: Optional[EchoTransferPrintOptions] = None, + timeout: Optional[float] = None, + survey_timeout: Optional[float] = None, + update_volume_trackers: bool = True, + ) -> EchoTransferResult: + """Plan and execute Echo transfers from PLR plate wells.""" + return await self.driver.transfer_wells( + source_plate, + destination_plate, + transfers, + source_plate_type=source_plate_type, + destination_plate_type=destination_plate_type, + protocol_name=protocol_name, + volume_unit=volume_unit, + do_survey=do_survey, + close_door_before_transfer=close_door_before_transfer, + print_options=print_options, + timeout=timeout, + survey_timeout=survey_timeout, + update_volume_trackers=update_volume_trackers, + ) + + @need_setup_finished + async def transfer( + self, + transfers: Sequence[EchoTransferInput], + *, + source_plate_type: Optional[str] = None, + destination_plate_type: Optional[str] = None, + protocol_name: str = "transfer", + volume_unit: str = "nL", + do_survey: bool = True, + close_door_before_transfer: bool = True, + print_options: Optional[EchoTransferPrintOptions] = None, + timeout: Optional[float] = None, + survey_timeout: Optional[float] = None, + update_volume_trackers: bool = True, + ) -> EchoTransferResult: + """Plan and execute Echo transfers from PLR wells, inferring plates from well parents.""" + return await self.driver.transfer( + transfers, + source_plate_type=source_plate_type, + destination_plate_type=destination_plate_type, + protocol_name=protocol_name, + volume_unit=volume_unit, + do_survey=do_survey, + close_door_before_transfer=close_door_before_transfer, + print_options=print_options, + timeout=timeout, + survey_timeout=survey_timeout, + update_volume_trackers=update_volume_trackers, + ) + + @need_setup_finished + async def load_source_plate( + self, + plate_type: str, + *, + barcode_location: str = "Right-Side", + barcode: str = "", + operator_pause: Optional[OperatorPause] = None, + open_door_first: bool = True, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> EchoPlateWorkflowResult: + """Run the source plate load workflow.""" + return await self.driver.load_source_plate( + plate_type, + barcode_location=barcode_location, + barcode=barcode, + operator_pause=operator_pause, + open_door_first=open_door_first, + present_timeout=present_timeout, + retract_timeout=retract_timeout, + ) + + @need_setup_finished + async def load_destination_plate( + self, + plate_type: str, + *, + barcode_location: str = "Right-Side", + barcode: str = "", + operator_pause: Optional[OperatorPause] = None, + open_door_first: bool = True, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> EchoPlateWorkflowResult: + """Run the destination plate load workflow.""" + return await self.driver.load_destination_plate( + plate_type, + barcode_location=barcode_location, + barcode=barcode, + operator_pause=operator_pause, + open_door_first=open_door_first, + present_timeout=present_timeout, + retract_timeout=retract_timeout, + ) + + @need_setup_finished + async def eject_source_plate( + self, + *, + operator_pause: Optional[OperatorPause] = None, + open_door_first: bool = False, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> EchoPlateWorkflowResult: + """Run the source plate eject workflow.""" + return await self.driver.eject_source_plate( + operator_pause=operator_pause, + open_door_first=open_door_first, + present_timeout=present_timeout, + retract_timeout=retract_timeout, + ) + + @need_setup_finished + async def eject_destination_plate( + self, + *, + operator_pause: Optional[OperatorPause] = None, + open_door_first: bool = False, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> EchoPlateWorkflowResult: + """Run the destination plate eject workflow.""" + return await self.driver.eject_destination_plate( + operator_pause=operator_pause, + open_door_first=open_door_first, + present_timeout=present_timeout, + retract_timeout=retract_timeout, + ) + + @need_setup_finished + async def eject_all_plates( + self, + *, + operator_pause: Optional[OperatorPause] = None, + close_door_after: bool = True, + open_door_first: bool = False, + present_timeout: Optional[float] = None, + retract_timeout: Optional[float] = None, + ) -> Tuple[EchoPlateWorkflowResult, EchoPlateWorkflowResult]: + """Eject source, then destination, and optionally close the door.""" + return await self.driver.eject_all_plates( + operator_pause=operator_pause, + close_door_after=close_door_after, + open_door_first=open_door_first, + present_timeout=present_timeout, + retract_timeout=retract_timeout, + ) diff --git a/pylabrobot/labcyte/echo_live_tests.py b/pylabrobot/labcyte/echo_live_tests.py new file mode 100644 index 00000000000..5df25b74632 --- /dev/null +++ b/pylabrobot/labcyte/echo_live_tests.py @@ -0,0 +1,71 @@ +"""Opt-in live validation tests for Labcyte Echo 650 instruments. + +These tests require real hardware and are skipped unless PYLABROBOT_ECHO_HOST is set. +They intentionally avoid plate motion unless PYLABROBOT_ECHO_VALIDATE_MOTION=1 is set. +""" + +import os +import unittest + +from pylabrobot.labcyte import Echo, EchoCommandError + + +ECHO_HOST = os.environ.get("PYLABROBOT_ECHO_HOST") +EXPECTED_MODEL = os.environ.get("PYLABROBOT_ECHO_EXPECTED_MODEL", "Echo 650") +VALIDATE_MOTION = os.environ.get("PYLABROBOT_ECHO_VALIDATE_MOTION") == "1" + + +@unittest.skipUnless(ECHO_HOST, "Set PYLABROBOT_ECHO_HOST to run Echo live validation.") +class TestEcho650LiveValidation(unittest.IsolatedAsyncioTestCase): + """Live smoke tests for the Medman surface used by the Echo backend.""" + + async def asyncSetUp(self) -> None: + assert ECHO_HOST is not None + self.echo = Echo(host=ECHO_HOST, timeout=10.0) + await self.echo.setup() + + async def asyncTearDown(self) -> None: + await self.echo.stop() + + async def test_identity_configuration_and_state(self) -> None: + info = await self.echo.get_instrument_info() + self.assertEqual(info.model, EXPECTED_MODEL) + self.assertTrue(info.serial_number) + + config_xml = await self.echo.get_echo_configuration() + self.assertIn("<", config_xml) + + state = await self.echo.get_access_state() + self.assertIsInstance(state.raw, dict) + + async def test_plate_catalogs_and_protocol_catalog_are_readable(self) -> None: + source_plate_types = await self.echo.get_all_source_plate_names() + destination_plate_types = await self.echo.get_all_destination_plate_names() + protocol_names = await self.echo.get_all_protocol_names() + + self.assertGreater(len(source_plate_types), 0) + self.assertGreater(len(destination_plate_types), 0) + self.assertIsInstance(protocol_names, list) + + async def test_event_channel_is_connectable(self) -> None: + events = await self.echo.read_events(max_events=1, timeout=0.5) + self.assertIsInstance(events, list) + + @unittest.skipUnless( + VALIDATE_MOTION, + "Set PYLABROBOT_ECHO_VALIDATE_MOTION=1 to run live door motion validation.", + ) + async def test_lock_and_door_cycle(self) -> None: + await self.echo.lock() + try: + await self.echo.open_door(timeout=10.0) + opened = await self.echo.get_access_state() + self.assertTrue(opened.door_open) + + await self.echo.close_door(timeout=10.0) + closed = await self.echo.get_access_state() + self.assertTrue(closed.door_closed) + except EchoCommandError: + raise + finally: + await self.echo.unlock() diff --git a/pylabrobot/labcyte/echo_tests.py b/pylabrobot/labcyte/echo_tests.py new file mode 100644 index 00000000000..9b28098768a --- /dev/null +++ b/pylabrobot/labcyte/echo_tests.py @@ -0,0 +1,2428 @@ +# mypy: disable-error-code="arg-type,func-returns-value,method-assign,union-attr" +import gzip +import html +import unittest +import xml.etree.ElementTree as ET +from unittest.mock import AsyncMock, call, patch + +from pylabrobot.capabilities.plate_access import PlateAccessState +from pylabrobot.labcyte.echo import ( + DEFAULT_DRY_TIMEOUT, + DEFAULT_HOME_TIMEOUT, + DEFAULT_LOADED_RETRACT_TIMEOUT, + DEFAULT_SURVEY_TIMEOUT, + Echo, + EchoCommandError, + EchoDriver, + EchoDryPlateMode, + EchoDryPlateParams, + EchoEvent, + EchoFocalSweepParams, + EchoFocusState, + EchoFluidInfo, + EchoPlateAccessBackend, + EchoPlateCatalog, + EchoPlateInfo, + EchoPlateMap, + EchoPlateWorkflowResult, + EchoPowerCalibration, + EchoPowerCalibrationResult, + EchoProtocolError, + EchoResolvedPlateType, + EchoScanPositions, + EchoScannerCalibrationResult, + EchoSurveyData, + EchoSurveyParams, + EchoSurveyRunResult, + EchoTransferredWell, + _HttpMessage, + _RpcResult, + EchoTransferPrintOptions, + EchoTransferResult, + build_echo_transfer_plan, + create_plate_from_echo_info, +) +from pylabrobot.resources import Plate, Resource, Well, create_ordered_items_2d, set_volume_tracking + + +def _soap_response( + inner_xml: str, + *, + content_length_override: int | None = None, + include_content_length: bool = True, +) -> bytes: + body = ( + '' + "' + '' + f"{inner_xml}" + "" + "" + ).encode("utf-8") + gz_body = gzip.compress(body) + content_length = len(gz_body) if content_length_override is None else content_length_override + header_lines = [ + "HTTP/1.1 200 OK", + "Server: Echo® Liquid Handler-3.1.1", + "Protocol: 3.1", + 'Content-Type: text/xml; charset="utf-8"', + ] + if include_content_length: + header_lines.append(f"Content-Length: {content_length}") + headers = ("\r\n".join(header_lines) + "\r\n\r\n").encode("iso-8859-1") + return headers + gz_body + + +def _soap_request(inner_xml: str) -> bytes: + body = ( + '' + "' + '' + f"{inner_xml}" + "" + "" + ).encode("utf-8") + gz_body = gzip.compress(body) + headers = ( + "POST /Medman HTTP/1.1\r\n" + "Host: event-stream\r\n" + 'Content-Type: text/xml; charset="utf-8"\r\n' + f"Content-Length: {len(gz_body)}\r\n" + "\r\n" + ).encode("iso-8859-1") + return headers + gz_body + + +class _FakeReader: + def __init__(self, payload: bytes): + self.payload = payload + + async def read(self, num_bytes: int) -> bytes: + chunk = self.payload[:num_bytes] + self.payload = self.payload[num_bytes:] + return chunk + + +class _FakeWriter: + def __init__(self): + self.buffer = bytearray() + self.closed = False + + def write(self, data: bytes) -> None: + self.buffer.extend(data) + + async def drain(self) -> None: + return None + + def close(self) -> None: + self.closed = True + + async def wait_closed(self) -> None: + return None + + +class _StubWell: + def __init__(self, identifier: str): + self._identifier = identifier + + def get_identifier(self) -> str: + return self._identifier + + +class _StubPlate: + def __init__(self, identifiers: list[str]): + self._wells = {identifier: _StubWell(identifier) for identifier in identifiers} + + def get_all_items(self): + return list(self._wells.values()) + + def get_well(self, identifier: str): + return self._wells[identifier] + + +def _make_plate(name: str, model: str) -> Plate: + return Plate( + name=name, + size_x=12, + size_y=8, + size_z=4, + model=model, + ordered_items=create_ordered_items_2d( + Well, + num_items_x=3, + num_items_y=2, + dx=0, + dy=0, + dz=0, + item_dx=1, + item_dy=1, + size_x=1, + size_y=1, + size_z=1, + max_volume=100, + ), + ) + + +def _make_echo_plate_info(name: str, *, rows: int = 2, columns: int = 3) -> EchoPlateInfo: + return EchoPlateInfo( + name=name, + rows=rows, + columns=columns, + well_capacity=65.0, + fluid="DMSO", + plate_format=f"{rows * columns}", + usage="Source", + barcode_location="Right-Side", + raw={"Rows": rows, "Columns": columns}, + ) + + +class TestEchoDriver(unittest.IsolatedAsyncioTestCase): + def setUp(self): + self.driver = EchoDriver(host="echo.local", timeout=1.0) + + def tearDown(self): + set_volume_tracking(False) + + async def test_setup_builds_token_from_resolved_ip(self): + with ( + patch("pylabrobot.labcyte.echo.socket.gethostbyname", return_value="192.168.0.25"), + patch("pylabrobot.labcyte.echo.time.time", return_value=1775092000), + patch("pylabrobot.labcyte.echo.os.getpid", return_value=4242), + ): + await self.driver.setup() + + self.assertEqual(self.driver.token, "192.168.0.25:15588:8240:1775092000:4242") + + async def test_get_instrument_info_parses_response_and_preserves_framing(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "" + "True" + "OK" + "E6XX-20044" + "E6XX-20044" + "192.168.0.25" + "3.1.1" + "2026-03-31_16-06-34" + "Normal" + "Echo 650" + "" + "" + ) + ) + + async def fake_open_connection(host: str, port: int): + self.assertEqual(host, "echo.local") + self.assertEqual(port, 8000) + return fake_reader, fake_writer + + with patch("pylabrobot.labcyte.echo.asyncio.open_connection", side_effect=fake_open_connection): + info = await self.driver.get_instrument_info() + + self.assertEqual(info.model, "Echo 650") + self.assertEqual(info.serial_number, "E6XX-20044") + self.assertEqual(info.software_version, "3.1.1") + request = bytes(fake_writer.buffer) + self.assertIn(b"POST /Medman HTTP/1.1\nHost: ", request) + self.assertIn(b'Content-Type: text/xml; charset="utf-8"\n', request) + self.assertIn(b'SOAPAction: "Some-URI"\r\n\r\n', request) + payload = gzip.decompress(request.split(b"\r\n\r\n", 1)[1]).decode("utf-8") + root = ET.fromstring(payload) + self.assertEqual(root.tag, "{http://schemas.xmlsoap.org/soap/envelope/}Envelope") + + async def test_get_echo_configuration_returns_raw_config(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "TrueOK" + "" + "<Configuration internal="true"></Configuration>" + "" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + config = await self.driver.get_echo_configuration() + + payload = gzip.decompress(bytes(fake_writer.buffer).split(b"\r\n\r\n", 1)[1]).decode("utf-8") + self.assertIn("') + + async def test_get_dio_ex_returns_raw_payload(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="GetDIOEx", + values={"MAP": True, "FSS": False}, + succeeded=None, + status=None, + ) + ) + + values = await self.driver.get_dio_ex() + + self.assertEqual(values, {"MAP": True, "FSS": False}) + self.driver._rpc.assert_awaited_once_with("GetDIOEx") + + async def test_get_echo_power_calibration_parses_payload(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="GetPwrCal", + values={ + "SUCCEEDED": True, + "Status": "OK", + "PwrCal": ( + '1.60000002384186' + '1.02825951576233' + '1.01453804969788' + '1' + ), + }, + succeeded=True, + status="OK", + ) + ) + + calibration = await self.driver.get_echo_power_calibration() + + self.assertEqual( + calibration, + EchoPowerCalibration( + amplitude=1.60000002384186, + reference_energy=1.02825951576233, + amp_feedback=1.01453804969788, + system_gain=1.0, + raw={ + "SUCCEEDED": True, + "Status": "OK", + "PwrCal": ( + '1.60000002384186' + '1.02825951576233' + '1.01453804969788' + '1' + ), + }, + ), + ) + + async def test_get_focus_state_fetches_read_side_focus_payloads(self): + await self.driver.setup() + self.driver.get_focus_tof = AsyncMock(return_value=35.1115) + self.driver.get_duo_focus_tof = AsyncMock(return_value=(0.0, 35.1115)) + self.driver.get_coupling_fluid_sound_velocity = AsyncMock(return_value=1488.3) + scan_positions = EchoScanPositions(left_up=True, right_up=False) + power_calibration = EchoPowerCalibration( + amplitude=1.6, + reference_energy=1.0, + amp_feedback=1.01, + system_gain=1.0, + ) + self.driver.get_scan_positions = AsyncMock(return_value=scan_positions) + self.driver.get_echo_power_calibration = AsyncMock(return_value=power_calibration) + + state = await self.driver.get_focus_state() + + self.assertEqual( + state, + EchoFocusState( + tof_focus=35.1115, + duo_tof_focus=(0.0, 35.1115), + coupling_fluid_sound_velocity=1488.3, + scan_positions=scan_positions, + power_calibration=power_calibration, + ), + ) + + async def test_focus_tof_methods_parse_and_serialize_echo_string_values(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + side_effect=[ + _RpcResult( + method="GetTOFFocus", + values={"SUCCEEDED": True, "Status": "OK", "TOFFocus": "35.1115"}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="GetDuoTOFFocus", + values={"SUCCEEDED": True, "Status": "OK", "TOFFocus": ["0", "35.1115"]}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="SetTOFFocus", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="SetDuoTOFFocus", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ), + ] + ) + + self.assertEqual(await self.driver.get_focus_tof(), 35.1115) + self.assertEqual(await self.driver.get_duo_focus_tof(), (0.0, 35.1115)) + with self.assertRaisesRegex(EchoCommandError, "active lock"): + await self.driver.set_focus_tof(35.1115) + self.driver._lock_held = True + await self.driver.set_focus_tof(35.1115) + await self.driver.set_duo_focus_tof(0, 35.1115) + + self.assertEqual( + self.driver._rpc.await_args_list[2].args, + ("SetTOFFocus", (("TOFFocus", "string", "35.1115"),)), + ) + self.assertEqual( + self.driver._rpc.await_args_list[3].args, + ( + "SetDuoTOFFocus", + (("TOFFocus", "string", "0"), ("TOFFocus", "string", "35.1115")), + ), + ) + + async def test_scan_positions_and_cal_plate_names_parse_payloads(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + side_effect=[ + _RpcResult( + method="GetScanPositions", + values={ + "SUCCEEDED": True, + "Status": "OK", + "ScanPositions": ( + 'True' + 'False' + 'False' + 'True' + 'True' + 'False' + ), + }, + succeeded=True, + status="OK", + ), + _RpcResult( + method="GetCalPlateNames", + values={ + "SUCCEEDED": True, + "Status": "Unspecified Error.", + "PlateType": ["InternalRegistration", "Labcyte_Reference_Plate"], + }, + succeeded=True, + status="Unspecified Error.", + ), + ] + ) + + scan_positions = await self.driver.get_scan_positions() + names = await self.driver.get_calibration_plate_names() + + self.assertEqual(scan_positions.left_up, True) + self.assertEqual(scan_positions.left_down, False) + self.assertEqual(scan_positions.right_down, True) + self.assertEqual(names, ["InternalRegistration", "Labcyte_Reference_Plate"]) + + async def test_get_access_state_parses_known_signals(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "" + "0" + "-1" + "True" + "False" + "True" + "False" + "" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + state = await self.driver.get_access_state() + + self.assertEqual(state.source_plate_position, -1) + self.assertEqual(state.destination_plate_position, 0) + self.assertTrue(state.source_access_open) + self.assertFalse(state.source_access_closed) + self.assertFalse(state.destination_access_open) + self.assertTrue(state.destination_access_closed) + self.assertTrue(state.door_open) + self.assertFalse(state.door_closed) + + async def test_get_access_state_infers_destination_from_position(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "" + "-1" + "0" + "1" + "0" + "" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + state = await self.driver.get_access_state() + + self.assertFalse(state.source_access_open) + self.assertTrue(state.source_access_closed) + self.assertTrue(state.destination_access_open) + self.assertFalse(state.destination_access_closed) + self.assertTrue(state.door_open) + self.assertFalse(state.door_closed) + + async def test_read_events_parses_handle_event_payloads(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_request( + "" + "7" + "Logger" + "DoWellTransfer() START" + "2026-04-13T17:02:12" + "" + ) + ) + + async def fake_open_connection(host: str, port: int): + self.assertEqual(host, "echo.local") + self.assertEqual(port, 8010) + return fake_reader, fake_writer + + with patch("pylabrobot.labcyte.echo.asyncio.open_connection", side_effect=fake_open_connection): + events = await self.driver.read_events(max_events=1, timeout=1.0) + + self.assertEqual( + events, + [ + EchoEvent( + event_id="7", + source="Logger", + payload="DoWellTransfer() START", + timestamp="2026-04-13T17:02:12", + raw={ + "id": 7, + "source": "Logger", + "payload": "DoWellTransfer() START", + "timestamp": "2026-04-13T17:02:12", + }, + ) + ], + ) + registration = bytes(fake_writer.buffer) + self.assertIn(b"POST /Medman HTTP/1.1\nHost: ", registration) + self.assertIn(b"add", gzip.decompress(registration.split(b"\r\n\r\n", 1)[1])) + self.assertTrue(fake_writer.closed) + + async def test_read_events_buffers_overread_callback_frames(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_request( + "" + "7000" + "Logger" + "first" + "1" + "" + ) + + _soap_request( + "" + "7001" + "Logger" + "second" + "2" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + events = await self.driver.read_events(max_events=2, timeout=1.0) + + self.assertEqual([event.payload for event in events], ["first", "second"]) + self.assertEqual([event.event_id for event in events], ["7000", "7001"]) + self.assertTrue(fake_writer.closed) + + async def test_lock_and_unlock_toggle_driver_state(self): + await self.driver.setup() + responses = [ + _soap_response( + "" + "TrueSession is locked. Lock count: 1" + "1775092000" + "" + ), + _soap_response( + "" + "TrueOK" + "" + ), + ] + + async def fake_open_connection(*_args, **_kwargs): + return _FakeReader(responses.pop(0)), _FakeWriter() + + with patch("pylabrobot.labcyte.echo.asyncio.open_connection", side_effect=fake_open_connection): + await self.driver.lock() + self.assertTrue(self.driver._lock_held) + await self.driver.unlock() + self.assertFalse(self.driver._lock_held) + + async def test_unlock_tolerates_stale_local_lock_state(self): + await self.driver.setup() + self.driver._lock_held = True + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="UnlockInstrument", + values={"SUCCEEDED": False, "Status": "Caller does not own the lock"}, + succeeded=False, + status="Caller does not own the lock", + ) + ) + + await self.driver.unlock() + + self.driver._rpc.assert_awaited_once_with( + "UnlockInstrument", + (("LockID", "string", self.driver.token),), + ) + self.assertFalse(self.driver._lock_held) + + async def test_motion_requires_lock(self): + await self.driver.setup() + with self.assertRaises(EchoCommandError): + await self.driver.open_source_plate() + + async def test_close_source_plate_uses_empty_retract_defaults(self): + await self.driver.setup() + self.driver._lock_held = True + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "TrueOK" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + await self.driver.close_source_plate() + + request = bytes(fake_writer.buffer) + payload = gzip.decompress(request.split(b"\r\n\r\n", 1)[1]).decode("utf-8") + self.assertIn("None", payload) + self.assertIn("None", payload) + self.assertIn("" + "TrueOK" + "" + ) + ) + plate_map = EchoPlateMap(plate_type="384PP_DMSO2", well_identifiers=("A1", "B2")) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + await self.driver.set_plate_map(plate_map) + + payload = gzip.decompress(bytes(fake_writer.buffer).split(b"\r\n\r\n", 1)[1]).decode("utf-8") + self.assertIn('<PlateMap p="384PP_DMSO2">', payload) + self.assertIn('<Well n="A1" r="0" c="0"', payload) + self.assertIn('<Well n="B2" r="1" c="1"', payload) + + async def test_survey_plate_uses_default_survey_timeout(self): + await self.driver.setup() + self.driver._lock_held = True + params = EchoSurveyParams(plate_type="384PP_DMSO2", num_rows=16, num_cols=24) + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="PlateSurvey", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ) + ) + + result = await self.driver.survey_plate(params) + + self.assertIsNone(result) + self.driver._rpc.assert_awaited_once_with( + "PlateSurvey", + ( + ("PlateType", "string", "384PP_DMSO2"), + ("StartRow", "int", "0"), + ("StartCol", "int", "0"), + ("NumRows", "int", "16"), + ("NumCols", "int", "24"), + ("Save", "boolean", "True"), + ("CheckSrc", "boolean", "False"), + ), + timeout=DEFAULT_SURVEY_TIMEOUT, + ) + + async def test_survey_plate_parses_nested_survey_xml(self): + await self.driver.setup() + self.driver._lock_held = True + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "True" + "OK" + "" + '' + '' + '' + "" + "" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + data = await self.driver.survey_plate( + EchoSurveyParams(plate_type="384PP_DMSO2", num_rows=16, num_cols=24) + ) + + assert data is not None + self.assertEqual(data.plate_type, "384PP_DMSO2") + self.assertEqual(data.barcode, "1234567890") + self.assertEqual(data.raw_attributes["barcode"], "1234567890") + self.assertEqual([well.identifier for well in data.wells], ["A1", "B2"]) + self.assertEqual(data.wells[0].raw_attributes["comp"], "1.83") + self.assertIn("" + "True" + "OK" + "" + '' + '' + '' + "" + "" + "" + ) + response = _soap_response(inner_xml) + _, gz_body = response.split(b"\r\n\r\n", 1) + advertised_length = max(1, len(gz_body) - 8) + response = _soap_response(inner_xml, content_length_override=advertised_length) + fake_writer = _FakeWriter() + fake_reader = _FakeReader(response) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + data = await self.driver.survey_plate( + EchoSurveyParams(plate_type="384PP_DMSO2", num_rows=16, num_cols=24) + ) + + assert data is not None + self.assertEqual(data.plate_type, "384PP_DMSO2") + self.assertEqual([well.identifier for well in data.wells], ["A1", "B2"]) + + async def test_survey_plate_reads_missing_content_length_until_gzip_complete(self): + await self.driver.setup() + self.driver._lock_held = True + wells = "".join( + f'' + for row in range(16) + for column in range(24) + ) + inner_xml = ( + "" + "True" + "OK" + "" + f'{wells}' + "" + "" + ) + response = _soap_response(inner_xml, include_content_length=False) + self.assertGreater(len(response), 4096) + fake_writer = _FakeWriter() + fake_reader = _FakeReader(response) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + data = await self.driver.survey_plate( + EchoSurveyParams(plate_type="384PP_DMSO2", num_rows=16, num_cols=24) + ) + + assert data is not None + self.assertEqual(data.plate_type, "384PP_DMSO2") + self.assertEqual(len(data.wells), 384) + + def test_decoded_body_rejects_incomplete_gzip(self): + compressed = gzip.compress(b"") + message = _HttpMessage( + start_line="HTTP/1.1 200 OK", + headers={"content-length": str(len(compressed) - 8)}, + body=compressed[:-8], + ) + + with self.assertRaisesRegex(EchoProtocolError, "Incomplete gzip-compressed Echo HTTP body"): + message.decoded_body() + + async def test_get_survey_data_parses_escaped_survey_xml(self): + await self.driver.setup() + survey_xml = ( + '' + ) + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "True" + "OK" + f"{html.escape(survey_xml)}" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + data = await self.driver.get_survey_data() + + self.assertEqual(data.plate_type, "384PP_DMSO2") + self.assertEqual(len(data.wells), 1) + self.assertEqual(data.wells[0].identifier, "A1") + self.assertEqual(data.wells[0].row, 0) + self.assertEqual(data.wells[0].column, 0) + + async def test_dry_plate_uses_selected_mode_and_timeout(self): + await self.driver.setup() + self.driver._lock_held = True + params = EchoDryPlateParams(mode=EchoDryPlateMode.TWO_PASS, timeout=45.0) + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="DryPlate", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ) + ) + + await self.driver.dry_plate(params) + + self.driver._rpc.assert_awaited_once_with( + "DryPlate", + (("Type", "string", "TWO_PASS"),), + timeout=45.0, + ) + + async def test_dry_plate_uses_default_timeout(self): + await self.driver.setup() + self.driver._lock_held = True + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="DryPlate", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ) + ) + + await self.driver.dry_plate() + + self.driver._rpc.assert_awaited_once_with( + "DryPlate", + (("Type", "string", "TWO_PASS"),), + timeout=DEFAULT_DRY_TIMEOUT, + ) + + async def test_low_level_actuator_commands_serialize_expected_rpc(self): + await self.driver.setup() + self.driver._lock_held = True + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="AnyCommand", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ) + ) + + await self.driver.home_axes() + await self.driver.open_door(timeout=2.0) + await self.driver.set_pump_direction(False, timeout=3.0) + await self.driver.enable_bubbler_pump(True) + await self.driver.actuate_bubbler_nozzle(False, timeout=4.0) + await self.driver.raise_coupling_fluid(timeout=5.0) + await self.driver.lower_coupling_fluid() + await self.driver.enable_vacuum_nozzle(True) + await self.driver.actuate_vacuum_nozzle(False) + await self.driver.actuate_ionizer(True, timeout=6.0) + + self.driver._rpc.assert_has_awaits( + [ + call("HomeAxes", timeout=DEFAULT_HOME_TIMEOUT), + call("OpenDoor", timeout=2.0), + call("SetPumpDir", (("Value", "boolean", "False"),), timeout=3.0), + call("EnableBubblerPump", (("Value", "boolean", "True"),), timeout=None), + call("ActuateBubblerNozzle", (("Value", "boolean", "False"),), timeout=4.0), + call("ActuateBubblerNozzle", (("Value", "boolean", "True"),), timeout=5.0), + call("ActuateBubblerNozzle", (("Value", "boolean", "False"),), timeout=None), + call("EnableVacuumNozzle", (("Value", "boolean", "True"),), timeout=None), + call("ActuateVacuumNozzle", (("Value", "boolean", "False"),), timeout=None), + call("ActuateIonizer", (("Value", "boolean", "True"),), timeout=6.0), + ] + ) + + async def test_soap_fault_raises_command_error_with_fault_string(self): + await self.driver.setup() + self.driver._lock_held = True + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "Server" + "MM1302001: Unknown Source Plate, inset" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + with self.assertRaises(EchoCommandError) as ctx: + await self.driver.actuate_vacuum_nozzle(True) + + self.assertIn("ActuateVacuumNozzle failed", str(ctx.exception)) + self.assertIn("MM1302001", str(ctx.exception)) + + async def test_get_current_plate_type_helpers_return_strings(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + side_effect=[ + _RpcResult( + method="GetCurrentSrcPlateType", + values={"SUCCEEDED": True, "Status": "OK", "GetCurrentSrcPlateType": "384PP_DMSO2"}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="GetCurrentDstPlateType", + values={"SUCCEEDED": True, "Status": "OK", "GetCurrentDstPlateType": "1536LDV_Dest"}, + succeeded=True, + status="OK", + ), + ] + ) + + source = await self.driver.get_current_source_plate_type() + destination = await self.driver.get_current_destination_plate_type() + + self.assertEqual(source, "384PP_DMSO2") + self.assertEqual(destination, "1536LDV_Dest") + + async def test_plate_presence_helpers_treat_none_as_empty(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + side_effect=[ + _RpcResult( + method="GetCurrentSrcPlateType", + values={"SUCCEEDED": True, "Status": "OK", "PlateType": "None"}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="GetCurrentDstPlateType", + values={"SUCCEEDED": True, "Status": "OK", "PlateType": "1536LDV_Dest"}, + succeeded=True, + status="OK", + ), + ] + ) + + self.assertFalse(await self.driver.is_source_plate_present()) + self.assertTrue(await self.driver.is_destination_plate_present()) + + async def test_get_all_destination_plate_names_parses_nested_xml(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "True" + "OK" + "1536LDV_DestCorning 3764 Black Clear Bottom" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + names = await self.driver.get_all_destination_plate_names() + + self.assertEqual(names, ["1536LDV_Dest", "Corning 3764 Black Clear Bottom"]) + + async def test_get_echo_plate_info_parses_typed_payload(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="GetPlateInfoEx", + values={ + "SUCCEEDED": True, + "Status": "OK", + "Rows": "16", + "Columns": "24", + "WellCapacity": "65.0", + "Fluid": "DMSO", + "PlateFormat": "384", + "PlateUsage": "Source", + "BarcodeLoc": "Right-Side", + }, + succeeded=True, + status="OK", + ) + ) + + info = await self.driver.get_echo_plate_info("384PP_DMSO2") + + self.assertEqual(info.name, "384PP_DMSO2") + self.assertEqual(info.rows, 16) + self.assertEqual(info.columns, 24) + self.assertEqual(info.well_capacity, 65.0) + self.assertEqual(info.fluid, "DMSO") + self.assertEqual(info.barcode_location, "Right-Side") + self.assertEqual(info.raw["Rows"], "16") + + async def test_get_plate_info_flattens_nested_plate_type_ex_payload(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="GetPlateInfoEx", + values={ + "SUCCEEDED": True, + "Status": "OK", + "PlateTypeEx": ( + '384PP_Dest' + '16' + '24' + '50' + '384PP_Dest' + 'DEST' + ), + }, + succeeded=True, + status="OK", + ) + ) + + values = await self.driver.get_plate_info("384PP_Dest") + info = await self.driver.get_echo_plate_info("384PP_Dest") + + self.assertEqual(values["Name"], "384PP_Dest") + self.assertEqual(values["Rows"], 16) + self.assertEqual(values["Columns"], 24) + self.assertEqual(info.name, "384PP_Dest") + self.assertEqual(info.rows, 16) + self.assertEqual(info.columns, 24) + self.assertEqual(info.plate_format, "384PP_Dest") + + async def test_get_echo_plate_catalog_fetches_source_and_destination_info(self): + await self.driver.setup() + self.driver.get_all_source_plate_names = AsyncMock(return_value=["SRC_A"]) + self.driver.get_all_destination_plate_names = AsyncMock(return_value=["DST_A"]) + self.driver.get_echo_plate_info = AsyncMock( + side_effect=[ + _make_echo_plate_info("SRC_A"), + _make_echo_plate_info("DST_A"), + ] + ) + + catalog = await self.driver.get_echo_plate_catalog() + + self.assertEqual(set(catalog.source), {"SRC_A"}) + self.assertEqual(set(catalog.destination), {"DST_A"}) + self.assertEqual(catalog.source["SRC_A"].columns, 3) + self.assertEqual(catalog.destination["DST_A"].rows, 2) + + async def test_set_plate_info_ex_serializes_captured_payload_shape(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="SetPlateInfoEx", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ) + ) + + await self.driver.set_plate_info_ex( + "CODEX_TMP_DST", + { + "Name": "384PP_Dest", + "Mfg": "Labcyte", + "Rows": 16, + "Columns": 24, + "WellCapacity": 50, + "BarcodeLoc": "Left-Side", + "PlateFormat": "384PP_Dest", + "PlateUsage": "DEST", + }, + ) + + self.driver._rpc.assert_awaited_once() + method, params = self.driver._rpc.await_args.args + self.assertEqual(method, "SetPlateInfoEx") + self.assertEqual(params[0], ("PlateTypeEx", "string", "CODEX_TMP_DST")) + self.assertEqual(params[1][0], "PlateTypeEx") + self.assertEqual(params[1][1], "xml_element") + plate_type_ex = ET.fromstring(params[1][2]) + values = {child.tag.rsplit("}", 1)[-1]: child.text or "" for child in plate_type_ex} + self.assertEqual(values["Name"], "CODEX_TMP_DST") + self.assertEqual(values["Mfg"], "Labcyte") + self.assertEqual(values["Rows"], "16") + self.assertEqual(values["Columns"], "24") + self.assertEqual(values["PlateUsage"], "DEST") + + async def test_clone_destination_plate_definition_uses_direct_set_plate_info_ex(self): + await self.driver.setup() + base_info = _make_echo_plate_info("384PP_Dest") + cloned_info = _make_echo_plate_info("CODEX_TMP_DST") + self.driver.get_echo_plate_catalog = AsyncMock( + side_effect=[ + EchoPlateCatalog(source={}, destination={"384PP_Dest": base_info}), + EchoPlateCatalog( + source={}, + destination={"384PP_Dest": base_info, "CODEX_TMP_DST": cloned_info}, + ), + ] + ) + raw_values = {"Name": "384PP_Dest", "Rows": 2, "Columns": 3, "PlateUsage": "DEST"} + self.driver.get_plate_info = AsyncMock(return_value=raw_values) + self.driver.set_plate_info_ex = AsyncMock() + + info = await self.driver.clone_destination_plate_definition( + "384PP_Dest", + "CODEX_TMP_DST", + ) + + self.assertEqual(info.name, "CODEX_TMP_DST") + self.driver.get_plate_info.assert_awaited_once_with("384PP_Dest") + self.driver.set_plate_info_ex.assert_awaited_once_with("CODEX_TMP_DST", raw_values) + + async def test_delete_destination_plate_definition_removes_and_verifies(self): + await self.driver.setup() + dest_info = _make_echo_plate_info("CODEX_TMP_DST") + self.driver.get_echo_plate_catalog = AsyncMock( + side_effect=[ + EchoPlateCatalog(source={}, destination={"CODEX_TMP_DST": dest_info}), + EchoPlateCatalog(source={}, destination={}), + ] + ) + self.driver.remove_plate_info = AsyncMock() + + deleted = await self.driver.delete_destination_plate_definition("CODEX_TMP_DST") + + self.assertTrue(deleted) + self.driver.remove_plate_info.assert_awaited_once_with("CODEX_TMP_DST") + + async def test_delete_destination_plate_definition_refuses_source_type(self): + await self.driver.setup() + src_info = _make_echo_plate_info("384PP_DMSO2") + self.driver.get_echo_plate_catalog = AsyncMock( + return_value=EchoPlateCatalog(source={"384PP_DMSO2": src_info}, destination={}) + ) + self.driver.remove_plate_info = AsyncMock() + + with self.assertRaisesRegex(EchoCommandError, "Refusing to delete source"): + await self.driver.delete_destination_plate_definition("384PP_DMSO2") + + self.driver.remove_plate_info.assert_not_awaited() + + async def test_resolve_echo_plate_type_accepts_explicit_registered_type(self): + await self.driver.setup() + plate = _make_plate("source", "WrongLocalModel") + info = _make_echo_plate_info("SRC_A") + self.driver.get_echo_plate_catalog = AsyncMock( + return_value=EchoPlateCatalog(source={"SRC_A": info}, destination={}) + ) + + resolved = await self.driver.resolve_echo_plate_type( + plate, + "source", + plate_type="SRC_A", + ) + + self.assertEqual( + resolved, + EchoResolvedPlateType( + side="source", + plate_type="SRC_A", + requested_plate_type="SRC_A", + derived_from="explicit", + info=info, + ), + ) + + async def test_resolve_echo_plate_type_uses_plate_model_when_registered(self): + await self.driver.setup() + plate = _make_plate("source", "SRC_A") + info = _make_echo_plate_info("SRC_A") + self.driver.get_echo_plate_catalog = AsyncMock( + return_value=EchoPlateCatalog(source={"SRC_A": info}, destination={}) + ) + + resolved = await self.driver.resolve_echo_plate_type(plate, "src") + + self.assertEqual(resolved.plate_type, "SRC_A") + self.assertEqual(resolved.derived_from, "plate.model") + + async def test_resolve_echo_plate_type_rejects_missing_catalog_type(self): + await self.driver.setup() + plate = _make_plate("source", "NOT_REGISTERED") + self.driver.get_echo_plate_catalog = AsyncMock( + return_value=EchoPlateCatalog( + source={"SRC_A": _make_echo_plate_info("SRC_A")}, + destination={}, + ) + ) + + with self.assertRaisesRegex(EchoCommandError, "NOT_REGISTERED.*SRC_A"): + await self.driver.resolve_echo_plate_type(plate, "source") + + async def test_resolve_echo_plate_type_rejects_dimension_mismatch(self): + await self.driver.setup() + plate = _make_plate("source", "SRC_A") + self.driver.get_echo_plate_catalog = AsyncMock( + return_value=EchoPlateCatalog( + source={"SRC_A": _make_echo_plate_info("SRC_A", rows=16, columns=24)}, + destination={}, + ) + ) + + with self.assertRaisesRegex(EchoCommandError, "3 columns x 2 rows.*24 columns x 16 rows"): + await self.driver.resolve_echo_plate_type(plate, "source") + + async def test_get_all_protocol_names_preserves_duplicate_tags(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "True" + "OK" + "baseline" + "dose-response" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + names = await self.driver.get_all_protocol_names() + + self.assertEqual(names, ["baseline", "dose-response"]) + + async def test_get_protocol_and_power_calibration_return_raw_payloads(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + side_effect=[ + _RpcResult( + method="GetProtocol", + values={"SUCCEEDED": True, "Status": "OK", "Protocol": ""}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="GetPwrCal", + values={"SUCCEEDED": True, "Status": "OK", "PwrCal": "current"}, + succeeded=True, + status="OK", + ), + ] + ) + + protocol = await self.driver.get_protocol("baseline") + power_calibration = await self.driver.get_power_calibration() + + self.driver._rpc.assert_has_awaits( + [ + call("GetProtocol", (("ProtocolName", "string", "baseline"),)), + call("GetPwrCal"), + ] + ) + self.assertEqual(protocol["Protocol"], "") + self.assertEqual(power_calibration["PwrCal"], "current") + + async def test_get_fluid_info_parses_response(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="GetFluidInfo", + values={ + "SUCCEEDED": True, + "Status": "OK", + "FluidName": "DMSO", + "Description": "Dimethyl sulfoxide", + "FCMin": "0.0", + "FCMax": "100.0", + "FCUnits": "%", + }, + succeeded=True, + status="OK", + ) + ) + + info = await self.driver.get_fluid_info("DMSO") + + self.driver._rpc.assert_awaited_once_with( + "GetFluidInfo", + (("FluidType", "string", "DMSO"),), + ) + self.assertEqual( + info, + EchoFluidInfo( + name="DMSO", + description="Dimethyl sulfoxide", + fc_min=0.0, + fc_max=100.0, + fc_units="%", + raw={ + "SUCCEEDED": True, + "Status": "OK", + "FluidName": "DMSO", + "Description": "Dimethyl sulfoxide", + "FCMin": "0.0", + "FCMax": "100.0", + "FCUnits": "%", + }, + ), + ) + + async def test_get_all_fluid_types_and_fluids_for_plate_parse_repeated_payload(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + side_effect=[ + _RpcResult( + method="GetAllFluidTypes", + values={ + "SUCCEEDED": True, + "Status": "OK", + "FluidType": [ + 'DMSO' + 'Dimethyl sulfoxide' + '70' + '100' + 'Percent', + 'AQ' + 'Aqueous' + '100' + '100' + 'Percent', + ], + }, + succeeded=True, + status="OK", + ), + _RpcResult( + method="GetFluidsForPlate", + values={ + "SUCCEEDED": True, + "Status": "OK", + "FluidType": ( + 'DMSO' + 'Dimethyl sulfoxide' + '70' + '100' + 'Percent' + ), + }, + succeeded=True, + status="OK", + ), + ] + ) + + all_fluids = await self.driver.get_all_fluid_types() + plate_fluids = await self.driver.get_fluids_for_plate("384PP_DMSO2") + + self.assertEqual([fluid.name for fluid in all_fluids], ["DMSO", "AQ"]) + self.assertEqual(all_fluids[0].fc_min, 70.0) + self.assertEqual(plate_fluids[0].name, "DMSO") + self.driver._rpc.assert_has_awaits( + [ + call("GetAllFluidTypes"), + call("GetFluidsForPlate", (("PlateType", "string", "384PP_DMSO2"),)), + ] + ) + + async def test_plate_insert_and_transfer_resolution_helpers(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + side_effect=[ + _RpcResult( + method="GetAllPlateInserts", + values={"SUCCEEDED": True, "Status": "OK", "InsertName": ["Universal Insert"]}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="GetTransferVolResolutionNl", + values={"SUCCEEDED": True, "Status": "OK", "Value": "2.5"}, + succeeded=True, + status="OK", + ), + ] + ) + + inserts = await self.driver.get_all_plate_inserts() + resolution = await self.driver.get_transfer_volume_resolution_nl("384PP_DMSO2") + + self.assertEqual(inserts, ["Universal Insert"]) + self.assertEqual(resolution, 2.5) + self.driver._rpc.assert_has_awaits( + [ + call("GetAllPlateInserts"), + call("GetTransferVolResolutionNl", (("Value", "string", "384PP_DMSO2"),)), + ] + ) + + async def test_calibration_methods_require_lock_and_serialize_expected_rpc(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + side_effect=[ + _RpcResult( + method="CalibratePower", + values={ + "SUCCEEDED": True, + "Status": "OK", + "AmpFeedback": "1.01", + "PulseEnergy": "1.02", + "Vpp": "1.03", + }, + succeeded=True, + status="OK", + ), + _RpcResult( + method="CommitPwrCal", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="RetractSrcGripper4ScanCal", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="RetractDstGripper4ScanCal", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="CalibrateScanner", + values={"SUCCEEDED": True, "Status": "OK", "BarCode": "CAL123"}, + succeeded=True, + status="OK", + ), + _RpcResult( + method="CancelCalibrateScanner", + values={"SUCCEEDED": True, "Status": "OK"}, + succeeded=True, + status="OK", + ), + ] + ) + + with self.assertRaisesRegex(EchoCommandError, "active lock"): + await self.driver.calibrate_power() + self.driver._lock_held = True + power = await self.driver.calibrate_power(timeout=30.0) + await self.driver.commit_power_calibration(1.01, 1.02, 1.03, timeout=30.0) + await self.driver.retract_source_gripper_for_scan_calibration("Right-Side") + await self.driver.retract_destination_gripper_for_scan_calibration("Left-Side") + scanner = await self.driver.calibrate_scanner("Right-Side", timeout=60.0) + await self.driver.cancel_scanner_calibration(timeout=5.0) + + self.assertEqual( + power, + EchoPowerCalibrationResult( + amp_feedback=1.01, + pulse_energy=1.02, + vpp=1.03, + status="OK", + raw={ + "SUCCEEDED": True, + "Status": "OK", + "AmpFeedback": "1.01", + "PulseEnergy": "1.02", + "Vpp": "1.03", + }, + ), + ) + self.assertEqual( + scanner, + EchoScannerCalibrationResult( + barcode="CAL123", + status="OK", + raw={"SUCCEEDED": True, "Status": "OK", "BarCode": "CAL123"}, + ), + ) + self.driver._rpc.assert_has_awaits( + [ + call("CalibratePower", timeout=30.0), + call( + "CommitPwrCal", + ( + ("AmpFeedback", "double", "1.01"), + ("PulseEnergy", "double", "1.02"), + ("Vpp", "double", "1.03"), + ), + timeout=30.0, + ), + call( + "RetractSrcGripper4ScanCal", + (("BarCodeLocation", "string", "Right-Side"),), + timeout=None, + ), + call( + "RetractDstGripper4ScanCal", + (("BarCodeLocation", "string", "Left-Side"),), + timeout=None, + ), + call( + "CalibrateScanner", + (("BarCodeLocation", "string", "Right-Side"),), + timeout=60.0, + ), + call("CancelCalibrateScanner", timeout=5.0), + ] + ) + + async def test_focal_sweep_serializes_params(self): + await self.driver.setup() + self.driver._lock_held = True + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="FocalSweep", + values={"SUCCEEDED": True, "Status": "OK", "FocalSweepData": ""}, + succeeded=True, + status="OK", + ) + ) + + values = await self.driver.focal_sweep( + EchoFocalSweepParams( + plate_type="384PP_DMSO2", + well_row=0, + well_column=1, + start_tof=30.0, + stop_tof=40.5, + increment_z=0.5, + start_z=1.0, + stop_z=2.0, + feature=3, + timeout=45.0, + ) + ) + + self.assertEqual(values["FocalSweepData"], "") + self.driver._rpc.assert_awaited_once_with( + "FocalSweep", + ( + ("PlateType", "string", "384PP_DMSO2"), + ("WellRow", "int", "0"), + ("WellCol", "int", "1"), + ("StartToF", "double", "30"), + ("StopToF", "double", "40.5"), + ("IncrZ", "double", "0.5"), + ("StartZ", "double", "1"), + ("StopZ", "double", "2"), + ("Feature", "int", "3"), + ), + timeout=45.0, + ) + + async def test_build_echo_transfer_plan_generates_protocol_and_sparse_plate_map(self): + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + + plan = build_echo_transfer_plan( + source_plate, + destination_plate, + [ + ("A1", "B2", 2.5), + (source_plate.get_well("A2"), destination_plate.get_well("A3"), 5.0), + ], + protocol_name="dose", + ) + + self.assertEqual(plan.source_plate_type, "384PP_DMSO2") + self.assertEqual(plan.destination_plate_type, "1536LDV_Dest") + self.assertEqual(plan.plate_map.well_identifiers, ("A1", "A2")) + self.assertIn('', plan.protocol_xml) + self.assertIn('', plan.protocol_xml) + self.assertIn('', plan.protocol_xml) + + async def test_build_echo_transfer_plan_accepts_ul_volumes(self): + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + + plan = self.driver.build_transfer_plan( + source_plate, + destination_plate, + [("A1", "B1", 0.005)], + volume_unit="uL", + ) + + self.assertEqual(plan.transfers[0].volume_nl, 5.0) + self.assertIn('', plan.protocol_xml) + + async def test_transfer_protocol_xml_matches_echo_layout_shape(self): + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + + plan = build_echo_transfer_plan( + source_plate, + destination_plate, + [ + ("A1", "B1", 2.5), + ("B2", "A3", 10.0), + ], + protocol_name="parity", + ) + root = ET.fromstring(plan.protocol_xml) + layout = root.find("Layout") + + self.assertEqual(root.tag, "Protocol") + self.assertEqual(root.attrib["Name"], "parity") + self.assertIsNotNone(root.find("Name")) + assert layout is not None + self.assertEqual( + [well.attrib for well in layout.findall("wp")], + [ + {"n": "A1", "dn": "B1", "v": "2.5"}, + {"n": "B2", "dn": "A3", "v": "10"}, + ], + ) + + async def test_transfer_plan_builds_sparse_unique_source_plate_map(self): + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + + plan = build_echo_transfer_plan( + source_plate, + destination_plate, + [ + ("A1", "B1", 2.5), + ("A1", "B2", 2.5), + ("B2", "A3", 5.0), + ], + ) + plate_map_root = ET.fromstring(plan.plate_map.to_xml()) + + self.assertEqual(plan.plate_map.well_identifiers, ("A1", "B2")) + self.assertEqual(plate_map_root.attrib, {"p": "384PP_DMSO2"}) + self.assertEqual( + [well.attrib for well in plate_map_root.findall("./Wells/Well")], + [ + {"n": "A1", "r": "0", "c": "0", "wc": "", "sid": ""}, + {"n": "B2", "r": "1", "c": "1", "wc": "", "sid": ""}, + ], + ) + + async def test_transfer_infers_plates_from_plr_wells(self): + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + transfer_result = EchoTransferResult(report_xml=None, raw={}, status="OK") + self.driver.transfer_wells = AsyncMock(return_value=transfer_result) + + result = await self.driver.transfer( + [ + ( + source_plate.get_well("A1"), + destination_plate.get_well("B1"), + 2.5, + ) + ], + do_survey=False, + ) + + self.assertEqual(result, transfer_result) + self.driver.transfer_wells.assert_awaited_once_with( + source_plate, + destination_plate, + [ + ( + source_plate.get_well("A1"), + destination_plate.get_well("B1"), + 2.5, + ) + ], + source_plate_type=None, + destination_plate_type=None, + protocol_name="transfer", + volume_unit="nL", + do_survey=False, + close_door_before_transfer=True, + print_options=None, + timeout=None, + survey_timeout=None, + update_volume_trackers=True, + ) + + async def test_transfer_rejects_multiple_source_plates(self): + source_plate = _make_plate("source", "384PP_DMSO2") + other_source_plate = _make_plate("other-source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + + with self.assertRaisesRegex(ValueError, "one source plate"): + await self.driver.transfer( + [ + (source_plate.get_well("A1"), destination_plate.get_well("B1"), 2.5), + (other_source_plate.get_well("A1"), destination_plate.get_well("B2"), 2.5), + ], + do_survey=False, + ) + + async def test_store_parameter_serializes_scalar_type(self): + await self.driver.setup() + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "TrueOK" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + await self.driver.store_parameter("Access", 1775092000) + + payload = gzip.decompress(bytes(fake_writer.buffer).split(b"\r\n\r\n", 1)[1]).decode("utf-8") + self.assertIn("Access", payload) + self.assertIn('type="xsd:int"', payload) + self.assertIn(">1775092000", payload) + + async def test_get_instrument_lock_state_returns_raw_payload(self): + await self.driver.setup() + self.driver._rpc = AsyncMock( + return_value=_RpcResult( + method="GetInstrumentLockState", + values={"SUCCEEDED": False, "Status": "Instrument is not locked.", "UnlockInstrument": ""}, + succeeded=False, + status="Instrument is not locked.", + ) + ) + + values = await self.driver.get_instrument_lock_state(lock_id=self.driver.token) + + self.driver._rpc.assert_awaited_once_with( + "GetInstrumentLockState", + (("LockID", "string", self.driver.token),), + ) + self.assertEqual(values["Status"], "Instrument is not locked.") + + async def test_do_well_transfer_uses_nested_print_options_and_parses_report(self): + await self.driver.setup() + self.driver._lock_held = True + fake_writer = _FakeWriter() + fake_reader = _FakeReader( + _soap_response( + "" + "TrueOK" + "" + "<transfer date="2026-04-21" serial_number="E6XX-20044">" + "<plateInfo><plate name="384PP_DMSO2" />" + "<plate name="1536LDV_Dest" /></plateInfo>" + "<printmap>" + "<w n="A1" r="0" c="0" dn="B1" dr="1" dc="0" " + "vt="2.5" avt="2.5" cvl="997.5" vl="1000" " + "fld="DMSO" fldu="%" fc="100" />" + "</printmap>" + "<skippedwells>" + "<w n="A2" r="0" c="1" dn="B2" dr="1" dc="1" " + "vt="5" reason="empty" />" + "</skippedwells>" + "</transfer>" + "" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, fake_writer), + ): + result = await self.driver.do_well_transfer( + "", + EchoTransferPrintOptions(do_plate_survey=True, plate_map=True), + ) + + payload = gzip.decompress(bytes(fake_writer.buffer).split(b"\r\n\r\n", 1)[1]).decode("utf-8") + ET.fromstring(payload) + self.assertIn("', + payload, + ) + self.assertIn( + 'True', + payload, + ) + self.assertIn( + 'True', + payload, + ) + self.assertNotIn("<PrintOptions>", payload) + self.assertIsNotNone(result.report_xml) + self.assertIn("" + "TrueComplete" + "" + "<transfer date="2026-04-22" serial_number="E6XX-20044">" + "<plateInfo><plate name="384PP_DMSO2" />" + "<plate name="1536LDV_Dest" /></plateInfo>" + "<printmap>" + "<w n="A1" r="0" c="0" dn="B1" dr="1" dc="0" " + "vt="2.5" avt="2.5" cvl="997.5" vl="1000" " + "fld="DMSO" fct="42" ft="9.5" t="12:00:00" />" + "<w n="B2" r="1" c="1" dn="C3" dr="2" dc="2" " + "vt="5" avt="4.5" cvl="995" vl="999.5" />" + "</printmap>" + "<skippedwells>" + "<w n="A2" r="0" c="1" dn="B2" dr="1" dc="1" " + "vt="10" reason="Insufficient fluid" />" + "</skippedwells>" + "</transfer>" + "" + "" + ) + ) + + with patch( + "pylabrobot.labcyte.echo.asyncio.open_connection", + return_value=(fake_reader, _FakeWriter()), + ): + result = await self.driver.do_well_transfer("") + + self.assertEqual(result.status, "Complete") + self.assertEqual(result.date, "2026-04-22") + self.assertEqual(result.serial_number, "E6XX-20044") + self.assertEqual(result.source_plate_type, "384PP_DMSO2") + self.assertEqual(result.destination_plate_type, "1536LDV_Dest") + self.assertEqual(len(result.transfers), 2) + self.assertEqual(result.transfers[0].source_identifier, "A1") + self.assertEqual(result.transfers[0].destination_identifier, "B1") + self.assertEqual(result.transfers[0].requested_volume_nl, 2.5) + self.assertEqual(result.transfers[0].actual_volume_nl, 2.5) + self.assertEqual(result.transfers[0].current_volume_nl, 997.5) + self.assertEqual(result.transfers[0].starting_volume_nl, 1000.0) + self.assertEqual(result.transfers[0].fluid_thickness, 9.5) + self.assertEqual(result.transfers[0].timestamp, "12:00:00") + self.assertEqual(result.transfers[1].actual_volume_nl, 4.5) + self.assertEqual(len(result.skipped), 1) + self.assertEqual(result.skipped[0].source_identifier, "A2") + self.assertEqual(result.skipped[0].destination_identifier, "B2") + self.assertEqual(result.skipped[0].requested_volume_nl, 10.0) + self.assertEqual(result.skipped[0].reason, "Insufficient fluid") + + async def test_transfer_wells_updates_volume_trackers_after_successful_report(self): + await self.driver.setup() + self.driver._lock_held = True + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + source_plate.get_well("A1").set_volume(1.0) + destination_plate.get_well("B1").set_volume(0.0) + set_volume_tracking(True) + + self.driver.get_echo_plate_catalog = AsyncMock( + return_value=EchoPlateCatalog( + source={"384PP_DMSO2": _make_echo_plate_info("384PP_DMSO2")}, + destination={"1536LDV_Dest": _make_echo_plate_info("1536LDV_Dest")}, + ) + ) + self.driver.get_current_source_plate_type = AsyncMock(return_value="384PP_DMSO2") + self.driver.get_current_destination_plate_type = AsyncMock(return_value="1536LDV_Dest") + self.driver.retrieve_parameter = AsyncMock(return_value=False) + self.driver.set_plate_map = AsyncMock() + self.driver.get_plate_info = AsyncMock(return_value={}) + self.driver.get_dio_ex2 = AsyncMock(return_value={}) + self.driver.get_dio = AsyncMock(return_value={}) + self.driver.do_well_transfer = AsyncMock( + return_value=EchoTransferResult( + report_xml=None, + raw={}, + succeeded=True, + status="OK", + transfers=[ + EchoTransferredWell( + source_identifier="A1", + source_row=0, + source_column=0, + destination_identifier="B1", + destination_row=1, + destination_column=0, + actual_volume_nl=5.0, + ) + ], + ) + ) + + result = await self.driver.transfer_wells( + source_plate, + destination_plate, + [("A1", "B1", 5.0)], + do_survey=False, + close_door_before_transfer=False, + ) + + self.assertEqual(result.status, "OK") + self.assertAlmostEqual(source_plate.get_well("A1").tracker.get_used_volume(), 0.995) + self.assertAlmostEqual(destination_plate.get_well("B1").tracker.get_used_volume(), 0.005) + self.driver.set_plate_map.assert_awaited_once() + plate_map = self.driver.set_plate_map.await_args.args[0] + self.assertEqual(plate_map.well_identifiers, ("A1",)) + self.driver.do_well_transfer.assert_awaited_once() + protocol_xml = self.driver.do_well_transfer.await_args.args[0] + self.assertIn('', protocol_xml) + + async def test_transfer_wells_uses_echo_catalog_columns_for_survey(self): + await self.driver.setup() + self.driver._lock_held = True + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + source_info = _make_echo_plate_info("384PP_DMSO2", rows=2, columns=3) + destination_info = _make_echo_plate_info("1536LDV_Dest", rows=2, columns=3) + self.driver.get_echo_plate_catalog = AsyncMock( + return_value=EchoPlateCatalog( + source={"384PP_DMSO2": source_info}, + destination={"1536LDV_Dest": destination_info}, + ) + ) + self.driver.get_current_source_plate_type = AsyncMock(return_value="384PP_DMSO2") + self.driver.get_current_destination_plate_type = AsyncMock(return_value="1536LDV_Dest") + self.driver.retrieve_parameter = AsyncMock(return_value=False) + self.driver.set_plate_map = AsyncMock() + self.driver.get_plate_info = AsyncMock(return_value={}) + self.driver.close_door = AsyncMock() + self.driver.survey_plate = AsyncMock(return_value=None) + self.driver.get_dio_ex2 = AsyncMock(return_value={}) + self.driver.get_dio = AsyncMock(return_value={}) + self.driver.do_well_transfer = AsyncMock( + return_value=EchoTransferResult(report_xml=None, raw={}, succeeded=True, status="OK") + ) + + await self.driver.transfer_wells( + source_plate, + destination_plate, + [("A1", "B1", 5.0)], + do_survey=True, + ) + + survey_params = self.driver.survey_plate.await_args.args[0] + self.assertEqual(survey_params.num_cols, source_info.columns) + + async def test_transfer_wells_rejects_unknown_echo_plate_type_before_mutation(self): + await self.driver.setup() + self.driver._lock_held = True + source_plate = _make_plate("source", "NOT_REGISTERED") + destination_plate = _make_plate("destination", "1536LDV_Dest") + self.driver.get_echo_plate_catalog = AsyncMock( + return_value=EchoPlateCatalog( + source={"384PP_DMSO2": _make_echo_plate_info("384PP_DMSO2")}, + destination={"1536LDV_Dest": _make_echo_plate_info("1536LDV_Dest")}, + ) + ) + self.driver.set_plate_map = AsyncMock() + + with self.assertRaisesRegex(EchoCommandError, "NOT_REGISTERED"): + await self.driver.transfer_wells( + source_plate, + destination_plate, + [("A1", "B1", 5.0)], + ) + + self.driver.set_plate_map.assert_not_awaited() + + async def test_survey_source_plate_can_update_source_plate_volumes(self): + await self.driver.setup() + self.driver._lock_held = True + source_plate = _make_plate("source", "384PP_DMSO2") + set_volume_tracking(True) + survey_data = EchoSurveyData.from_xml( + '' + ) + self.driver.set_plate_map = AsyncMock() + self.driver.survey_plate = AsyncMock(return_value=survey_data) + self.driver.get_survey_data = AsyncMock(return_value=survey_data) + self.driver.dry_plate = AsyncMock() + + result = await self.driver.survey_source_plate( + EchoPlateMap(plate_type="384PP_DMSO2", well_identifiers=("A1",)), + EchoSurveyParams(plate_type="384PP_DMSO2", num_rows=1, num_cols=1), + source_plate=source_plate, + ) + + self.assertEqual(result.saved_data, survey_data) + self.assertEqual(source_plate.get_well("A1").tracker.get_used_volume(), 1.0) + + async def test_load_source_plate_sequences_operator_pause_and_barcode(self): + await self.driver.setup() + self.driver._lock_held = True + calls: list[str] = [] + + async def operator_pause(message: str): + calls.append(message) + + self.driver._require_registered_echo_plate_type = AsyncMock( + side_effect=lambda *_args, **_kwargs: calls.append("validate") + ) + self.driver.open_door = AsyncMock(side_effect=lambda *_args, **_kwargs: calls.append("door")) + self.driver.open_source_plate = AsyncMock( + side_effect=lambda *_args, **_kwargs: calls.append("present") + ) + self.driver.get_power_calibration = AsyncMock() + self.driver.get_plate_info = AsyncMock() + self.driver.get_current_source_plate_type = AsyncMock(side_effect=["None", "384PP_DMSO2"]) + self.driver.close_source_plate = AsyncMock(return_value="BC123") + self.driver.retrieve_parameter = AsyncMock() + self.driver.get_dio_ex2 = AsyncMock(return_value={"SPP": -1}) + + result = await self.driver.load_source_plate( + "384PP_DMSO2", + operator_pause=operator_pause, + retract_timeout=45.0, + ) + + self.assertEqual(calls, ["validate", "door", "present", "source plate presented"]) + self.assertTrue(result.plate_present) + self.assertEqual(result.barcode, "BC123") + self.driver.close_source_plate.assert_awaited_once_with( + plate_type="384PP_DMSO2", + barcode_location="Right-Side", + barcode="", + timeout=45.0, + ) + + async def test_load_source_plate_rejects_unknown_type_before_motion(self): + await self.driver.setup() + self.driver._lock_held = True + self.driver._require_registered_echo_plate_type = AsyncMock( + side_effect=EchoCommandError("ResolveEchoPlateType", "Unknown source plate") + ) + self.driver.open_door = AsyncMock() + self.driver.open_source_plate = AsyncMock() + + with self.assertRaisesRegex(EchoCommandError, "Unknown source plate"): + await self.driver.load_source_plate("NOT_REGISTERED") + + self.driver.open_door.assert_not_awaited() + self.driver.open_source_plate.assert_not_awaited() + + async def test_eject_all_plates_ejects_source_before_destination(self): + await self.driver.setup() + self.driver._lock_held = True + calls: list[str] = [] + self.driver.eject_source_plate = AsyncMock( + side_effect=lambda **_kwargs: ( + calls.append("source") + or EchoPlateWorkflowResult(side="source", plate_type=None, plate_present=False) + ) + ) + self.driver.eject_destination_plate = AsyncMock( + side_effect=lambda **_kwargs: ( + calls.append("destination") + or EchoPlateWorkflowResult(side="destination", plate_type=None, plate_present=False) + ) + ) + self.driver.close_door = AsyncMock(side_effect=lambda *_args, **_kwargs: calls.append("door")) + + source_result, destination_result = await self.driver.eject_all_plates() + + self.assertEqual(calls, ["source", "destination", "door"]) + self.assertFalse(source_result.plate_present) + self.assertFalse(destination_result.plate_present) + + +class TestEchoPlateAccessBackend(unittest.IsolatedAsyncioTestCase): + async def test_close_door_rejects_when_access_is_open(self): + driver = EchoDriver(host="192.168.0.25") + backend = EchoPlateAccessBackend(driver) + driver.get_access_state = AsyncMock( + return_value=PlateAccessState(source_access_open=True, source_access_closed=False) + ) + driver.close_door = AsyncMock() + + with self.assertRaises(EchoCommandError): + await backend.close_door() + + driver.close_door.assert_not_awaited() + + async def test_close_door_rejects_when_access_state_is_unknown(self): + driver = EchoDriver(host="192.168.0.25") + backend = EchoPlateAccessBackend(driver) + driver.get_access_state = AsyncMock( + return_value=PlateAccessState( + source_access_open=False, + destination_access_open=None, + ) + ) + driver.close_door = AsyncMock() + + with self.assertRaisesRegex(EchoCommandError, "Cannot confirm destination access"): + await backend.close_door() + + driver.close_door.assert_not_awaited() + + async def test_close_door_sends_rpc_when_access_paths_are_known_closed(self): + driver = EchoDriver(host="192.168.0.25") + backend = EchoPlateAccessBackend(driver) + driver.get_access_state = AsyncMock( + return_value=PlateAccessState( + source_access_open=False, + source_access_closed=True, + destination_access_open=False, + destination_access_closed=True, + ) + ) + driver.close_door = AsyncMock() + + await backend.close_door(timeout=5.0) + + driver.close_door.assert_awaited_once_with(timeout=5.0) + + +class TestEchoPlateMap(unittest.TestCase): + def test_from_plate_uses_canonical_identifiers(self): + plate = _StubPlate(["A1", "B2", "C3"]) + + plate_map = EchoPlateMap.from_plate( + plate, + plate_type="384PP_DMSO2", + wells=["B2", "A1"], + ) + + self.assertEqual(plate_map.plate_type, "384PP_DMSO2") + self.assertEqual(plate_map.well_identifiers, ("B2", "A1")) + + +class TestEchoPlateFactory(unittest.TestCase): + def test_create_plate_from_echo_info_builds_minimal_transfer_plate(self): + info = _make_echo_plate_info("Custom 6", rows=2, columns=3) + + plate = create_plate_from_echo_info(info) + + self.assertEqual(plate.model, "Custom 6") + self.assertEqual(plate.num_items_x, 3) + self.assertEqual(plate.num_items_y, 2) + self.assertIs(plate.get_well("A1").parent, plate) + self.assertIs(plate.get_well("B3").parent, plate) + + +class TestEchoDevice(unittest.IsolatedAsyncioTestCase): + async def test_device_models_source_and_destination_positions_as_plate_holders(self): + echo = Echo(host="192.168.0.25") + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + + echo.source_plate = source_plate + echo.destination_plate = destination_plate + + self.assertEqual(echo.source_position.role, "source") + self.assertEqual(echo.destination_position.role, "destination") + self.assertIs(echo.source_plate, source_plate) + self.assertIs(echo.destination_plate, destination_plate) + self.assertIs(source_plate.parent, echo.source_position) + self.assertIs(destination_plate.parent, echo.destination_position) + self.assertIn(echo.source_position, echo.deck.children) + self.assertIn(echo.destination_position, echo.deck.children) + + echo.source_plate = None + self.assertIsNone(echo.source_plate) + self.assertIsNone(source_plate.parent) + + async def test_echo_plate_positions_only_accept_plates(self): + echo = Echo(host="192.168.0.25") + resource = Resource("not-a-plate", size_x=1, size_y=1, size_z=1) + + with self.assertRaisesRegex(TypeError, "only hold PLR Plate"): + echo.source_position.assign_child_resource(resource) + + async def test_device_delegates_to_driver_and_capability(self): + echo = Echo(host="192.168.0.25") + echo._setup_finished = True + echo.driver.get_instrument_info = AsyncMock() + opened_state = PlateAccessState(source_access_open=True, source_access_closed=False) + echo.plate_access.open_source_plate = AsyncMock(return_value=opened_state) + echo.plate_access.get_access_state = AsyncMock(return_value=PlateAccessState()) + + await echo.get_instrument_info() + returned_opened_state = await echo.open_source_plate(timeout=1.0) + state = await echo.get_access_state() + + echo.driver.get_instrument_info.assert_awaited_once() + echo.plate_access.open_source_plate.assert_awaited_once_with( + timeout=1.0, + poll_interval=0.1, + ) + self.assertIsInstance(state, PlateAccessState) + self.assertTrue(returned_opened_state.source_access_open) + + async def test_device_delegates_low_level_methods_to_driver(self): + echo = Echo(host="192.168.0.25") + echo._setup_finished = True + echo.driver.get_fluid_info = AsyncMock( + return_value=EchoFluidInfo( + name="DMSO", + description="", + fc_min=None, + fc_max=None, + fc_units="", + ) + ) + echo.driver.get_echo_configuration = AsyncMock(return_value="") + echo.driver.get_all_protocol_names = AsyncMock(return_value=["baseline"]) + echo.driver.open_door = AsyncMock() + echo.driver.home_axes = AsyncMock() + echo.driver.actuate_ionizer = AsyncMock() + + fluid = await echo.get_fluid_info("DMSO") + config = await echo.get_echo_configuration() + protocols = await echo.get_all_protocol_names() + await echo.open_door(timeout=1.0) + await echo.home_axes(timeout=2.0) + await echo.actuate_ionizer(True, timeout=3.0) + + self.assertEqual(fluid.name, "DMSO") + self.assertEqual(config, "") + self.assertEqual(protocols, ["baseline"]) + echo.driver.get_fluid_info.assert_awaited_once_with("DMSO") + echo.driver.get_echo_configuration.assert_awaited_once() + echo.driver.get_all_protocol_names.assert_awaited_once() + echo.driver.open_door.assert_awaited_once_with(timeout=1.0) + echo.driver.home_axes.assert_awaited_once_with(timeout=2.0) + echo.driver.actuate_ionizer.assert_awaited_once_with(enabled=True, timeout=3.0) + + async def test_device_survey_helper_delegates_to_driver(self): + echo = Echo(host="192.168.0.25") + echo._setup_finished = True + plate_map = EchoPlateMap(plate_type="384PP_DMSO2", well_identifiers=("A1",)) + survey = EchoSurveyParams(plate_type="384PP_DMSO2", num_rows=16, num_cols=24) + response_data = EchoSurveyData.from_xml( + '' + ) + saved_data = EchoSurveyData.from_xml( + '' + ) + expected = EchoSurveyRunResult( + response_data=response_data, + saved_data=saved_data, + dry_mode=EchoDryPlateMode.TWO_PASS, + ) + echo.driver.survey_source_plate = AsyncMock(return_value=expected) + + result = await echo.survey_source_plate( + plate_map, + survey, + fetch_saved_data=True, + dry_after=True, + ) + + self.assertEqual(result, expected) + echo.driver.survey_source_plate.assert_awaited_once_with( + plate_map, + survey, + fetch_saved_data=True, + dry_after=True, + dry=None, + source_plate=None, + update_volume_trackers=True, + ) + + async def test_device_transfer_and_plate_workflows_delegate_to_driver(self): + echo = Echo(host="192.168.0.25") + echo._setup_finished = True + source_plate = _make_plate("source", "384PP_DMSO2") + destination_plate = _make_plate("destination", "1536LDV_Dest") + transfer_result = EchoTransferResult(report_xml=None, raw={}, status="OK") + load_result = EchoPlateWorkflowResult( + side="source", + plate_type="384PP_DMSO2", + plate_present=True, + ) + echo.driver.transfer_wells = AsyncMock(return_value=transfer_result) + echo.driver.transfer = AsyncMock(return_value=transfer_result) + echo.driver.load_source_plate = AsyncMock(return_value=load_result) + + returned_transfer = await echo.transfer_wells( + source_plate, + destination_plate, + [("A1", "B1", 2.5)], + do_survey=False, + ) + returned_inferred_transfer = await echo.transfer( + [(source_plate.get_well("A1"), destination_plate.get_well("B1"), 2.5)], + do_survey=False, + ) + returned_load = await echo.load_source_plate("384PP_DMSO2") + + self.assertEqual(returned_transfer, transfer_result) + self.assertEqual(returned_inferred_transfer, transfer_result) + self.assertEqual(returned_load, load_result) + echo.driver.transfer_wells.assert_awaited_once() + echo.driver.transfer.assert_awaited_once_with( + [(source_plate.get_well("A1"), destination_plate.get_well("B1"), 2.5)], + source_plate_type=None, + destination_plate_type=None, + protocol_name="transfer", + volume_unit="nL", + do_survey=False, + close_door_before_transfer=True, + print_options=None, + timeout=None, + survey_timeout=None, + update_volume_trackers=True, + ) + echo.driver.load_source_plate.assert_awaited_once_with( + "384PP_DMSO2", + barcode_location="Right-Side", + barcode="", + operator_pause=None, + open_door_first=True, + present_timeout=None, + retract_timeout=None, + ) + + async def test_stop_unlocks_held_lock(self): + echo = Echo(host="192.168.0.25") + echo._setup_finished = True + echo.driver._lock_held = True + echo.driver.unlock = AsyncMock() + + await echo.stop() + + echo.driver.unlock.assert_awaited_once() + + +if __name__ == "__main__": + unittest.main()