From 73b1d62ed42520375ca50f43350581a423d3b3cf Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 8 May 2026 18:17:20 -0700 Subject: [PATCH 01/10] Add direct serial Agilent PlateLoc driver --- docs/user_guide/agilent/index.md | 2 + .../agilent/plateloc/hello-world.md | 85 +++++ pylabrobot/agilent/__init__.py | 7 + pylabrobot/agilent/plateloc/__init__.py | 8 + pylabrobot/agilent/plateloc/plateloc.py | 327 ++++++++++++++++++ pylabrobot/agilent/plateloc/plateloc_tests.py | 172 +++++++++ 6 files changed, 601 insertions(+) create mode 100644 docs/user_guide/agilent/plateloc/hello-world.md create mode 100644 pylabrobot/agilent/plateloc/__init__.py create mode 100644 pylabrobot/agilent/plateloc/plateloc.py create mode 100644 pylabrobot/agilent/plateloc/plateloc_tests.py diff --git a/docs/user_guide/agilent/index.md b/docs/user_guide/agilent/index.md index 31b65a9b774..d9baefcb4a1 100644 --- a/docs/user_guide/agilent/index.md +++ b/docs/user_guide/agilent/index.md @@ -4,5 +4,7 @@ :maxdepth: 1 benchcel/hello-world +biotek/index +plateloc/hello-world vspin/index ``` diff --git a/docs/user_guide/agilent/plateloc/hello-world.md b/docs/user_guide/agilent/plateloc/hello-world.md new file mode 100644 index 00000000000..3784e8bb7d1 --- /dev/null +++ b/docs/user_guide/agilent/plateloc/hello-world.md @@ -0,0 +1,85 @@ +# Agilent PlateLoc + +The Agilent PlateLoc is controlled through PLR's `Sealer` capability with a direct RS-232 serial +driver. It does not require Agilent ActiveX, VWorks, or vendor server software. Install the +optional serial dependency before connecting: + +```bash +pip install "pylabrobot[serial]" +``` + +```python +from pylabrobot.agilent import PlateLoc + +plateloc = PlateLoc(name="plateloc", port="COM6") + +await plateloc.setup() +await plateloc.driver.set_sealing_temperature(175) +await plateloc.driver.set_sealing_time(0.5) +await plateloc.stop() +``` + +The device also exposes the standard sealer capability: + +```python +await plateloc.sealer.seal(temperature=175, duration=1.5) +await plateloc.sealer.open() +await plateloc.sealer.close() +``` + +`sealer.open()` and `sealer.close()` move the stage and wait for the default stage-settle delay. +`sealer.seal()` starts a sealing cycle after writing the requested temperature and time. + +## Serial command profile + +The decoded direct protocol uses `19200 8N1` and carriage-return-terminated ASCII frames with +two-letter command codes plus payloads. Temperature and time payloads use the firmware's fractional +setpoint convention: the digits after the decimal point are the integer controller value. + +| Operation | Frame | +|---|---| +| Set sealing temperature | `ST 0.{temperature_celsius:03d}\r` | +| Set sealing time | `SS 0.{seconds_x10:02d}\r` | +| Start cycle | `GO 00\r` | +| Stop cycle | `AC 00\r` | +| Move stage out | `SO 00\r` | +| Move stage in | `SI 00\r` | +| Apply seal | `AS 00\r` | +| Clear error | `CL 00\r` | +| Check cycle complete | `CC 00\r` | + +For example, `set_sealing_temperature(175)` writes `ST 0.175\r`, `set_sealing_temperature(30)` +writes `ST 0.030\r`, `set_sealing_time(0.5)` writes `SS 0.05\r`, and `set_sealing_time(1.2)` +writes `SS 0.12\r`. + +Negative acknowledgements are parsed as `NK(message)` and raised as `PlateLocError`. Some +valid firmware commands reply with single-carriage-return acknowledgements such as `SOAK\r`. The +cycle-complete command returns `True` for `CCAK\r` and `False` for `CCNK\r`. + +You can still override command codes or serial settings with `PlateLocSerialProfile` while keeping +the same PLR frontend: + +```python +from pylabrobot.agilent import PlateLoc, PlateLocSerialProfile + +profile = PlateLocSerialProfile( + baudrate=19200, + stage_move_delay=6, + commands={ + "set_sealing_temperature": "ST", + "set_sealing_time": "SS", + "start_cycle": "GO", + "move_stage_out": "SO", + "move_stage_in": "SI", + }, +) + +plateloc = PlateLoc(name="plateloc", port="COM6", profile=profile) +``` + +## Troubleshooting + +The PlateLoc RS-232 connector is not VGA and is not USB TTL. Use a USB-to-RS-232 adapter plus the +correct DB9 cable for the instrument. If the port opens but every command times out, verify the +PlateLoc is powered, the rear serial cable is seated, and the cable wiring matches the instrument +requirement. Some setups require a null-modem DB9 adapter rather than a straight-through cable. diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index 73b4637c62a..df45420e50f 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -6,4 +6,11 @@ CytationImagingConfig, SynergyH1, ) +from .plateloc import ( + PlateLoc, + PlateLocDriver, + PlateLocError, + PlateLocSealerBackend, + PlateLocSerialProfile, +) from .vspin import Access2, Access2Driver, VSpin diff --git a/pylabrobot/agilent/plateloc/__init__.py b/pylabrobot/agilent/plateloc/__init__.py new file mode 100644 index 00000000000..cef9dbbd61e --- /dev/null +++ b/pylabrobot/agilent/plateloc/__init__.py @@ -0,0 +1,8 @@ +from .plateloc import ( + DEFAULT_PLATELOC_COMMANDS, + PlateLoc, + PlateLocDriver, + PlateLocError, + PlateLocSealerBackend, + PlateLocSerialProfile, +) diff --git a/pylabrobot/agilent/plateloc/plateloc.py b/pylabrobot/agilent/plateloc/plateloc.py new file mode 100644 index 00000000000..ae6aa8c8970 --- /dev/null +++ b/pylabrobot/agilent/plateloc/plateloc.py @@ -0,0 +1,327 @@ +from __future__ import annotations + +import asyncio +import contextlib +import dataclasses +import logging +import re +import time +from typing import Mapping, Optional + +from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.sealing import Sealer, SealerBackend +from pylabrobot.device import Device, Driver +from pylabrobot.io.serial import Serial + +try: + import serial as _serial # noqa: F401 + + HAS_SERIAL = True +except ImportError as e: + HAS_SERIAL = False + _SERIAL_IMPORT_ERROR = e + +logger = logging.getLogger(__name__) + + +DEFAULT_PLATELOC_COMMANDS: Mapping[str, str] = { + "set_sealing_temperature": "ST", + "set_sealing_time": "SS", + "move_stage_out": "SO", + "move_stage_in": "SI", + "start_cycle": "GO", + "stop_cycle": "AC", + "apply_seal": "AS", + "clear_error": "CL", + "check_cycle_complete": "CC", +} + +_ACK_RE = re.compile(r"^\s*(?PAC|AS|CC|CL|GO|SI|SO|SS|ST)(?P[AN])K(?:\((?P.*)\))?\s*$") + + +class PlateLocError(RuntimeError): + """Raised when PlateLoc communication or protocol handling fails.""" + + +@dataclasses.dataclass(frozen=True) +class PlateLocSerialProfile: + """Serial settings and command codes for a PlateLoc controller. + + The decoded low-level protocol uses two-letter command codes followed by a payload and a + carriage return. Setpoint payloads are encoded as a decimal fraction whose fractional digits + hold the integer setpoint, for example ``ST 0.175`` for 175 C and ``SS 0.12`` for 1.2 s. + """ + + baudrate: int = 19200 + bytesize: int = 8 + parity: str = "N" + stopbits: int = 1 + timeout: float = 1 + write_timeout: float = 1 + rtscts: bool = False + dsrdtr: bool = False + xonxoff: bool = False + read_delay: float = 0.05 + ack_timeout: float = 10 + response_timeout: float = 2 + stage_move_delay: float = 6 + command_terminator: str = "\r" + response_terminator: bytes = b"\r" + commands: Mapping[str, str] = dataclasses.field( + default_factory=lambda: dict(DEFAULT_PLATELOC_COMMANDS) + ) + + def format_command(self, command: str, payload: str = "00") -> bytes: + code = self.commands.get(command) + if code is None: + raise PlateLocError(f"No PlateLoc serial command configured for {command!r}.") + return f"{code} {payload}{self.command_terminator}".encode("ascii") + + def serialize(self) -> dict: + return { + "baudrate": self.baudrate, + "bytesize": self.bytesize, + "parity": self.parity, + "stopbits": self.stopbits, + "timeout": self.timeout, + "write_timeout": self.write_timeout, + "rtscts": self.rtscts, + "dsrdtr": self.dsrdtr, + "xonxoff": self.xonxoff, + "read_delay": self.read_delay, + "ack_timeout": self.ack_timeout, + "response_timeout": self.response_timeout, + "stage_move_delay": self.stage_move_delay, + "command_terminator": self.command_terminator, + "response_terminator": self.response_terminator.decode("latin1"), + "commands": dict(self.commands), + } + + @classmethod + def deserialize(cls, data: dict) -> "PlateLocSerialProfile": + data = data.copy() + if "response_terminator" in data: + data["response_terminator"] = data["response_terminator"].encode("latin1") + return cls(**data) + + +class PlateLocDriver(Driver): + """Direct serial driver for the Agilent PlateLoc thermal microplate sealer.""" + + def __init__( + self, + port: Optional[str] = None, + vid: Optional[int] = None, + pid: Optional[int] = None, + profile: Optional[PlateLocSerialProfile | dict] = None, + timeout: float = 30, + serial_cls=Serial, + ) -> None: + super().__init__() + if serial_cls is Serial and not HAS_SERIAL: + raise RuntimeError( + "pyserial is not installed. Install with: pip install pylabrobot[serial]. " + f"Import error: {_SERIAL_IMPORT_ERROR}" + ) + if isinstance(profile, dict): + profile = PlateLocSerialProfile.deserialize(profile) + self.profile = profile or PlateLocSerialProfile() + self.timeout = timeout + self.io = serial_cls( + human_readable_device_name="Agilent PlateLoc Sealer", + port=port, + vid=vid, + pid=pid, + baudrate=self.profile.baudrate, + bytesize=self.profile.bytesize, + parity=self.profile.parity, + stopbits=self.profile.stopbits, + write_timeout=self.profile.write_timeout, + timeout=self.profile.timeout, + rtscts=self.profile.rtscts, + dsrdtr=self.profile.dsrdtr, + xonxoff=self.profile.xonxoff, + ) + + @property + def port(self) -> str: + return self.io.port + + async def setup(self, backend_params: Optional[BackendParams] = None): + await self.io.setup() + logger.info("[PlateLoc %s] connected", self.port) + + async def stop(self): + await self.io.stop() + logger.info("[PlateLoc %s] disconnected", self.port) + + @contextlib.contextmanager + def _read_timeout(self, timeout: float): + if hasattr(self.io, "temporary_timeout"): + with self.io.temporary_timeout(timeout): + yield + else: + yield + + async def send_command( + self, + command: str, + payload: str = "00", + expect_response: bool = False, + raise_on_nak: bool = True, + ) -> Optional[str]: + data = self.profile.format_command(command, payload=payload) + if hasattr(self.io, "reset_input_buffer"): + await self.io.reset_input_buffer() + await self.io.write(data) + if self.profile.read_delay > 0: + await asyncio.sleep(self.profile.read_delay) + + response = await self.read_response( + timeout=self.profile.response_timeout if expect_response else self.profile.ack_timeout, + required=expect_response, + ) + if response is not None and raise_on_nak: + self._raise_for_error(command, response) + return response + + async def read_response(self, timeout: Optional[float] = None, required: bool = True) -> Optional[str]: + deadline = time.time() + (timeout if timeout is not None else self.profile.response_timeout) + chunks = bytearray() + while time.time() < deadline: + with self._read_timeout(max(0.01, min(0.1, deadline - time.time()))): + chunk = await self.io.read(1) + if chunk: + chunks.extend(chunk) + if chunks.endswith(self.profile.response_terminator): + break + elif len(chunks) > 0: + break + + if len(chunks) == 0: + if required: + raise TimeoutError("Timeout while waiting for PlateLoc response") + return None + return bytes(chunks).decode("utf-8", errors="replace").strip() + + def _raise_for_error(self, command: str, response: str): + match = _ACK_RE.match(response) + if match is None: + return + code = match.group("code") + expected_code = self.profile.commands.get(command) + if expected_code is not None and code != expected_code: + raise PlateLocError(f"PlateLoc replied with {code!r} to {command!r}: {response!r}") + if match.group("status") == "N": + message = match.group("message") or "command rejected" + raise PlateLocError(f"PlateLoc rejected {command!r}: {message}") + + async def set_sealing_temperature(self, temperature: float): + if not (20 <= temperature <= 235): + raise ValueError("Temperature out of range. Please enter a value between 20 and 235 C.") + payload = f"0.{round(temperature):03d}" + logger.info("[PlateLoc %s] setting sealing temperature to %.1f C", self.port, temperature) + return await self.send_command("set_sealing_temperature", payload=payload) + + async def set_sealing_time(self, duration: float): + if not (0.5 <= duration <= 12.0): + raise ValueError("Duration out of range. Please enter a value between 0.5 and 12.0 s.") + payload = f"0.{round(duration * 10):02d}" + logger.info("[PlateLoc %s] setting sealing time to %.2f s", self.port, duration) + return await self.send_command("set_sealing_time", payload=payload) + + async def move_stage_out(self): + logger.info("[PlateLoc %s] moving stage out", self.port) + response = await self.send_command("move_stage_out") + if self.profile.stage_move_delay > 0: + await asyncio.sleep(self.profile.stage_move_delay) + return response + + async def move_stage_in(self): + logger.info("[PlateLoc %s] moving stage in", self.port) + response = await self.send_command("move_stage_in") + if self.profile.stage_move_delay > 0: + await asyncio.sleep(self.profile.stage_move_delay) + return response + + async def start_cycle(self): + logger.info("[PlateLoc %s] starting sealing cycle", self.port) + return await self.send_command("start_cycle") + + async def stop_cycle(self): + logger.info("[PlateLoc %s] stopping sealing cycle", self.port) + return await self.send_command("stop_cycle") + + async def apply_seal(self): + logger.info("[PlateLoc %s] applying seal", self.port) + return await self.send_command("apply_seal") + + async def clear_error(self): + logger.info("[PlateLoc %s] clearing error", self.port) + return await self.send_command("clear_error") + + async def check_cycle_complete(self) -> bool: + response = await self.send_command( + "check_cycle_complete", + expect_response=True, + raise_on_nak=False, + ) + match = _ACK_RE.match(response or "") + return match is not None and match.group("status") == "A" + + def serialize(self) -> dict: + return { + **super().serialize(), + "port": self.port, + "profile": self.profile.serialize(), + "timeout": self.timeout, + } + + +class PlateLocSealerBackend(SealerBackend): + """Translates SealerBackend operations into direct PlateLoc serial commands.""" + + def __init__(self, driver: PlateLocDriver): + self.driver = driver + + async def seal(self, temperature: int, duration: float): + await self.driver.set_sealing_temperature(temperature) + await self.driver.set_sealing_time(duration) + return await self.driver.start_cycle() + + async def open(self): + return await self.driver.move_stage_out() + + async def close(self): + return await self.driver.move_stage_in() + + +class PlateLoc(Device): + """Agilent PlateLoc thermal microplate sealer.""" + + def __init__( + self, + name: str, + port: Optional[str] = None, + vid: Optional[int] = None, + pid: Optional[int] = None, + profile: Optional[PlateLocSerialProfile | dict] = None, + timeout: float = 30, + serial_cls=Serial, + ): + self.name = name + driver = PlateLocDriver( + port=port, + vid=vid, + pid=pid, + profile=profile, + timeout=timeout, + serial_cls=serial_cls, + ) + super().__init__(driver=driver) + self.driver: PlateLocDriver = driver + self.sealer = Sealer(backend=PlateLocSealerBackend(driver)) + self._capabilities = [self.sealer] + + def serialize(self) -> dict: + return {**super().serialize(), "name": self.name} diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py new file mode 100644 index 00000000000..898982503a1 --- /dev/null +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -0,0 +1,172 @@ +import asyncio +import contextlib +import unittest +from collections import deque + +from pylabrobot.agilent.plateloc import ( + DEFAULT_PLATELOC_COMMANDS, + PlateLoc, + PlateLocDriver, + PlateLocError, + PlateLocSerialProfile, +) + + +class FakeSerial: + def __init__(self, **kwargs): + self.kwargs = kwargs + self._port = kwargs["port"] + self.writes = [] + self.responses = deque() + self.setup_called = False + self.stop_called = False + self.timeout = kwargs["timeout"] + self.reset_input_buffer_called = False + + @property + def port(self): + return self._port + + @contextlib.contextmanager + def temporary_timeout(self, timeout: float): + previous_timeout = self.timeout + self.timeout = timeout + try: + yield + finally: + self.timeout = previous_timeout + + async def setup(self): + self.setup_called = True + + async def stop(self): + self.stop_called = True + + async def write(self, data: bytes): + self.writes.append(data) + + async def read(self, num_bytes: int = 1) -> bytes: + if not self.responses: + await asyncio.sleep(0) + return b"" + response = self.responses[0] + chunk = response[:num_bytes] + response = response[num_bytes:] + if response: + self.responses[0] = response + else: + self.responses.popleft() + return chunk + + def queue_response(self, response: bytes): + self.responses.append(response) + + async def reset_input_buffer(self): + self.reset_input_buffer_called = True + + +class PlateLocTests(unittest.IsolatedAsyncioTestCase): + def make_driver(self, commands=None, ack_timeout=0): + profile = PlateLocSerialProfile( + response_timeout=0.01, + ack_timeout=ack_timeout, + read_delay=0, + stage_move_delay=0, + commands=commands or DEFAULT_PLATELOC_COMMANDS, + ) + return PlateLocDriver(port="COM6", profile=profile, serial_cls=FakeSerial) + + async def test_setup_uses_plr_serial_wrapper_settings(self): + driver = self.make_driver() + + await driver.setup() + + self.assertTrue(driver.io.setup_called) + self.assertEqual(driver.io.kwargs["human_readable_device_name"], "Agilent PlateLoc Sealer") + self.assertEqual(driver.io.kwargs["port"], "COM6") + self.assertEqual(driver.io.kwargs["baudrate"], 19200) + self.assertEqual(driver.io.kwargs["bytesize"], 8) + self.assertEqual(driver.io.kwargs["parity"], "N") + self.assertEqual(driver.io.kwargs["stopbits"], 1) + + await driver.stop() + self.assertTrue(driver.io.stop_called) + + async def test_temperature_and_time_writes_are_scaled_and_validated(self): + driver = self.make_driver() + await driver.setup() + + await driver.set_sealing_temperature(30) + await driver.set_sealing_time(0.5) + + self.assertEqual(driver.io.writes, [b"ST 0.030\r", b"SS 0.05\r"]) + + with self.assertRaises(ValueError): + await driver.set_sealing_temperature(19) + with self.assertRaises(ValueError): + await driver.set_sealing_time(0.4) + + async def test_negative_acknowledgement_raises_protocol_error(self): + driver = self.make_driver(ack_timeout=0.01) + await driver.setup() + driver.io.queue_response(b"STNK(Desired Temperature is Out of Range)\r\r") + + with self.assertRaisesRegex(PlateLocError, "Desired Temperature is Out of Range"): + await driver.set_sealing_temperature(30) + + self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) + + async def test_required_response_reads_until_plate_loc_ack(self): + driver = self.make_driver() + await driver.setup() + driver.io.queue_response(b"CCAK\r") + + self.assertTrue(await driver.check_cycle_complete()) + self.assertEqual(driver.io.writes, [b"CC 00\r"]) + + async def test_cycle_not_complete_returns_false(self): + driver = self.make_driver() + await driver.setup() + driver.io.queue_response(b"CCNK\r") + + self.assertFalse(await driver.check_cycle_complete()) + self.assertEqual(driver.io.writes, [b"CC 00\r"]) + + async def test_custom_command_profile(self): + driver = self.make_driver( + commands={ + "set_sealing_temperature": "TP", + "set_sealing_time": "TM", + } + ) + await driver.setup() + + await driver.set_sealing_temperature(120) + await driver.set_sealing_time(1.25) + + self.assertEqual(driver.io.writes, [b"TP 0.120\r", b"TM 0.12\r"]) + + async def test_device_exposes_sealer_capability(self): + profile = PlateLocSerialProfile( + response_timeout=0.01, + ack_timeout=0, + read_delay=0, + stage_move_delay=0, + ) + device = PlateLoc(name="plateloc", port="COM6", profile=profile, serial_cls=FakeSerial) + + await device.setup() + await device.sealer.seal(120, 1.2) + await device.sealer.open() + await device.sealer.close() + await device.stop() + + self.assertEqual( + device.driver.io.writes, + [b"ST 0.120\r", b"SS 0.12\r", b"GO 00\r", b"SO 00\r", b"SI 00\r"], + ) + self.assertTrue(device.driver.io.stop_called) + + +if __name__ == "__main__": + unittest.main() From cf39e002286dade22b595d47b5aab80b70c19535 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 8 May 2026 18:47:46 -0700 Subject: [PATCH 02/10] Expose PlateLoc setpoints and status --- .../agilent/plateloc/hello-world.md | 19 ++++- pylabrobot/agilent/__init__.py | 1 + pylabrobot/agilent/plateloc/__init__.py | 1 + pylabrobot/agilent/plateloc/plateloc.py | 74 +++++++++++++++++-- pylabrobot/agilent/plateloc/plateloc_tests.py | 39 +++++++++- 5 files changed, 126 insertions(+), 8 deletions(-) diff --git a/docs/user_guide/agilent/plateloc/hello-world.md b/docs/user_guide/agilent/plateloc/hello-world.md index 3784e8bb7d1..247c9b18856 100644 --- a/docs/user_guide/agilent/plateloc/hello-world.md +++ b/docs/user_guide/agilent/plateloc/hello-world.md @@ -14,8 +14,9 @@ from pylabrobot.agilent import PlateLoc plateloc = PlateLoc(name="plateloc", port="COM6") await plateloc.setup() -await plateloc.driver.set_sealing_temperature(175) -await plateloc.driver.set_sealing_time(0.5) +await plateloc.set_sealing_temperature(175) +await plateloc.set_sealing_time(0.5) +status = await plateloc.request_status() await plateloc.stop() ``` @@ -30,6 +31,20 @@ await plateloc.sealer.close() `sealer.open()` and `sealer.close()` move the stage and wait for the default stage-settle delay. `sealer.seal()` starts a sealing cycle after writing the requested temperature and time. +The PlateLoc-specific frontend also exposes independent setpoint and status helpers: + +```python +await plateloc.set_sealing_temperature(160) +await plateloc.set_sealing_time(1.0) + +status = await plateloc.request_status() +print(status.target_temperature, status.sealing_time, status.cycle_complete) +``` + +`request_status()` returns the best-known PLR state plus a live cycle-complete query. The direct +serial protocol decoded here does not expose actual block temperature or actual stored time reads, +so PLR reports the last successfully written target temperature and sealing time. + ## Serial command profile The decoded direct protocol uses `19200 8N1` and carriage-return-terminated ASCII frames with diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index df45420e50f..7829538f52d 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -12,5 +12,6 @@ PlateLocError, PlateLocSealerBackend, PlateLocSerialProfile, + PlateLocStatus, ) from .vspin import Access2, Access2Driver, VSpin diff --git a/pylabrobot/agilent/plateloc/__init__.py b/pylabrobot/agilent/plateloc/__init__.py index cef9dbbd61e..28b11ca53e6 100644 --- a/pylabrobot/agilent/plateloc/__init__.py +++ b/pylabrobot/agilent/plateloc/__init__.py @@ -5,4 +5,5 @@ PlateLocError, PlateLocSealerBackend, PlateLocSerialProfile, + PlateLocStatus, ) diff --git a/pylabrobot/agilent/plateloc/plateloc.py b/pylabrobot/agilent/plateloc/plateloc.py index ae6aa8c8970..ece84fe0c8f 100644 --- a/pylabrobot/agilent/plateloc/plateloc.py +++ b/pylabrobot/agilent/plateloc/plateloc.py @@ -43,6 +43,20 @@ class PlateLocError(RuntimeError): """Raised when PlateLoc communication or protocol handling fails.""" +@dataclasses.dataclass(frozen=True) +class PlateLocStatus: + """Best-known PlateLoc state from direct serial control.""" + + port: str + connected: bool + target_temperature: Optional[float] + sealing_time: Optional[float] + stage_position: Optional[str] + cycle_complete: Optional[bool] + last_command: Optional[str] + last_response: Optional[str] + + @dataclasses.dataclass(frozen=True) class PlateLocSerialProfile: """Serial settings and command codes for a PlateLoc controller. @@ -127,6 +141,12 @@ def __init__( profile = PlateLocSerialProfile.deserialize(profile) self.profile = profile or PlateLocSerialProfile() self.timeout = timeout + self._connected = False + self._target_temperature: Optional[float] = None + self._sealing_time: Optional[float] = None + self._stage_position: Optional[str] = None + self._last_command: Optional[str] = None + self._last_response: Optional[str] = None self.io = serial_cls( human_readable_device_name="Agilent PlateLoc Sealer", port=port, @@ -149,10 +169,12 @@ def port(self) -> str: async def setup(self, backend_params: Optional[BackendParams] = None): await self.io.setup() + self._connected = True logger.info("[PlateLoc %s] connected", self.port) async def stop(self): await self.io.stop() + self._connected = False logger.info("[PlateLoc %s] disconnected", self.port) @contextlib.contextmanager @@ -181,6 +203,8 @@ async def send_command( timeout=self.profile.response_timeout if expect_response else self.profile.ack_timeout, required=expect_response, ) + self._last_command = command + self._last_response = response if response is not None and raise_on_nak: self._raise_for_error(command, response) return response @@ -219,22 +243,29 @@ def _raise_for_error(self, command: str, response: str): async def set_sealing_temperature(self, temperature: float): if not (20 <= temperature <= 235): raise ValueError("Temperature out of range. Please enter a value between 20 and 235 C.") - payload = f"0.{round(temperature):03d}" + target_temperature = round(temperature) + payload = f"0.{target_temperature:03d}" logger.info("[PlateLoc %s] setting sealing temperature to %.1f C", self.port, temperature) - return await self.send_command("set_sealing_temperature", payload=payload) + response = await self.send_command("set_sealing_temperature", payload=payload) + self._target_temperature = float(target_temperature) + return response async def set_sealing_time(self, duration: float): if not (0.5 <= duration <= 12.0): raise ValueError("Duration out of range. Please enter a value between 0.5 and 12.0 s.") - payload = f"0.{round(duration * 10):02d}" + sealing_time_deciseconds = round(duration * 10) + payload = f"0.{sealing_time_deciseconds:02d}" logger.info("[PlateLoc %s] setting sealing time to %.2f s", self.port, duration) - return await self.send_command("set_sealing_time", payload=payload) + response = await self.send_command("set_sealing_time", payload=payload) + self._sealing_time = sealing_time_deciseconds / 10 + return response async def move_stage_out(self): logger.info("[PlateLoc %s] moving stage out", self.port) response = await self.send_command("move_stage_out") if self.profile.stage_move_delay > 0: await asyncio.sleep(self.profile.stage_move_delay) + self._stage_position = "open" return response async def move_stage_in(self): @@ -242,6 +273,7 @@ async def move_stage_in(self): response = await self.send_command("move_stage_in") if self.profile.stage_move_delay > 0: await asyncio.sleep(self.profile.stage_move_delay) + self._stage_position = "closed" return response async def start_cycle(self): @@ -269,6 +301,22 @@ async def check_cycle_complete(self) -> bool: match = _ACK_RE.match(response or "") return match is not None and match.group("status") == "A" + def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: + return PlateLocStatus( + port=self.port, + connected=self._connected, + target_temperature=self._target_temperature, + sealing_time=self._sealing_time, + stage_position=self._stage_position, + cycle_complete=cycle_complete, + last_command=self._last_command, + last_response=self._last_response, + ) + + async def request_status(self, query_cycle_complete: bool = True) -> PlateLocStatus: + cycle_complete = await self.check_cycle_complete() if query_cycle_complete else None + return self.status_snapshot(cycle_complete=cycle_complete) + def serialize(self) -> dict: return { **super().serialize(), @@ -323,5 +371,21 @@ def __init__( self.sealer = Sealer(backend=PlateLocSealerBackend(driver)) self._capabilities = [self.sealer] + async def set_sealing_temperature(self, temperature: float): + return await self.driver.set_sealing_temperature(temperature) + + async def set_sealing_time(self, duration: float): + return await self.driver.set_sealing_time(duration) + + def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: + return self.driver.status_snapshot(cycle_complete=cycle_complete) + + async def request_status(self, query_cycle_complete: bool = True) -> PlateLocStatus: + return await self.driver.request_status(query_cycle_complete=query_cycle_complete) + def serialize(self) -> dict: - return {**super().serialize(), "name": self.name} + return { + **super().serialize(), + "name": self.name, + "status": dataclasses.asdict(self.status_snapshot()), + } diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py index 898982503a1..7ab7fa56720 100644 --- a/pylabrobot/agilent/plateloc/plateloc_tests.py +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -9,6 +9,7 @@ PlateLocDriver, PlateLocError, PlateLocSerialProfile, + PlateLocStatus, ) @@ -132,6 +133,28 @@ async def test_cycle_not_complete_returns_false(self): self.assertFalse(await driver.check_cycle_complete()) self.assertEqual(driver.io.writes, [b"CC 00\r"]) + async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): + driver = self.make_driver() + await driver.setup() + + await driver.set_sealing_temperature(30) + await driver.set_sealing_time(0.5) + await driver.move_stage_out() + driver.io.queue_response(b"CCAK\r") + + status = await driver.request_status() + + self.assertIsInstance(status, PlateLocStatus) + self.assertEqual(status.port, "COM6") + self.assertTrue(status.connected) + self.assertEqual(status.target_temperature, 30) + self.assertEqual(status.sealing_time, 0.5) + self.assertEqual(status.stage_position, "open") + self.assertTrue(status.cycle_complete) + self.assertEqual(status.last_command, "check_cycle_complete") + self.assertEqual(status.last_response, "CCAK") + self.assertEqual(driver.io.writes, [b"ST 0.030\r", b"SS 0.05\r", b"SO 00\r", b"CC 00\r"]) + async def test_custom_command_profile(self): driver = self.make_driver( commands={ @@ -156,15 +179,29 @@ async def test_device_exposes_sealer_capability(self): device = PlateLoc(name="plateloc", port="COM6", profile=profile, serial_cls=FakeSerial) await device.setup() + await device.set_sealing_temperature(100) + await device.set_sealing_time(0.5) await device.sealer.seal(120, 1.2) await device.sealer.open() await device.sealer.close() + status = device.status_snapshot() await device.stop() self.assertEqual( device.driver.io.writes, - [b"ST 0.120\r", b"SS 0.12\r", b"GO 00\r", b"SO 00\r", b"SI 00\r"], + [ + b"ST 0.100\r", + b"SS 0.05\r", + b"ST 0.120\r", + b"SS 0.12\r", + b"GO 00\r", + b"SO 00\r", + b"SI 00\r", + ], ) + self.assertEqual(status.target_temperature, 120) + self.assertEqual(status.sealing_time, 1.2) + self.assertEqual(status.stage_position, "closed") self.assertTrue(device.driver.io.stop_called) From eba553cc9c8b5985ee23a44a65be6eafcffa5ec7 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 8 May 2026 20:43:31 -0700 Subject: [PATCH 03/10] Address PlateLoc review feedback --- pylabrobot/agilent/plateloc/plateloc.py | 27 ++++++++-- pylabrobot/agilent/plateloc/plateloc_tests.py | 52 ++++++++++++++++++- 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/pylabrobot/agilent/plateloc/plateloc.py b/pylabrobot/agilent/plateloc/plateloc.py index ece84fe0c8f..2fb7ef64191 100644 --- a/pylabrobot/agilent/plateloc/plateloc.py +++ b/pylabrobot/agilent/plateloc/plateloc.py @@ -36,7 +36,7 @@ "check_cycle_complete": "CC", } -_ACK_RE = re.compile(r"^\s*(?PAC|AS|CC|CL|GO|SI|SO|SS|ST)(?P[AN])K(?:\((?P.*)\))?\s*$") +_ACK_RE = re.compile(r"^\s*(?P[A-Z0-9]{2})(?P[AN])K(?:\((?P.*)\))?\s*$") class PlateLocError(RuntimeError): @@ -79,6 +79,7 @@ class PlateLocSerialProfile: ack_timeout: float = 10 response_timeout: float = 2 stage_move_delay: float = 6 + cycle_poll_interval: float = 0.5 command_terminator: str = "\r" response_terminator: bytes = b"\r" commands: Mapping[str, str] = dataclasses.field( @@ -106,6 +107,7 @@ def serialize(self) -> dict: "ack_timeout": self.ack_timeout, "response_timeout": self.response_timeout, "stage_move_delay": self.stage_move_delay, + "cycle_poll_interval": self.cycle_poll_interval, "command_terminator": self.command_terminator, "response_terminator": self.response_terminator.decode("latin1"), "commands": dict(self.commands), @@ -299,7 +301,24 @@ async def check_cycle_complete(self) -> bool: raise_on_nak=False, ) match = _ACK_RE.match(response or "") - return match is not None and match.group("status") == "A" + if match is None: + return False + expected_code = self.profile.commands.get("check_cycle_complete") + if expected_code is not None and match.group("code") != expected_code: + raise PlateLocError( + f"PlateLoc replied with {match.group('code')!r} to 'check_cycle_complete': {response!r}" + ) + return match.group("status") == "A" + + async def wait_for_cycle_complete(self, timeout: Optional[float] = None) -> bool: + deadline = time.time() + (self.timeout if timeout is None else timeout) + while True: + if await self.check_cycle_complete(): + return True + remaining = deadline - time.time() + if remaining <= 0: + raise TimeoutError("Timeout while waiting for PlateLoc cycle to complete") + await asyncio.sleep(min(max(self.profile.cycle_poll_interval, 0), remaining)) def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: return PlateLocStatus( @@ -335,7 +354,9 @@ def __init__(self, driver: PlateLocDriver): async def seal(self, temperature: int, duration: float): await self.driver.set_sealing_temperature(temperature) await self.driver.set_sealing_time(duration) - return await self.driver.start_cycle() + response = await self.driver.start_cycle() + await self.driver.wait_for_cycle_complete() + return response async def open(self): return await self.driver.move_stage_out() diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py index 7ab7fa56720..5df1d5b32e9 100644 --- a/pylabrobot/agilent/plateloc/plateloc_tests.py +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -67,15 +67,16 @@ async def reset_input_buffer(self): class PlateLocTests(unittest.IsolatedAsyncioTestCase): - def make_driver(self, commands=None, ack_timeout=0): + def make_driver(self, commands=None, ack_timeout=0, timeout=30): profile = PlateLocSerialProfile( response_timeout=0.01, ack_timeout=ack_timeout, read_delay=0, stage_move_delay=0, + cycle_poll_interval=0, commands=commands or DEFAULT_PLATELOC_COMMANDS, ) - return PlateLocDriver(port="COM6", profile=profile, serial_cls=FakeSerial) + return PlateLocDriver(port="COM6", profile=profile, timeout=timeout, serial_cls=FakeSerial) async def test_setup_uses_plr_serial_wrapper_settings(self): driver = self.make_driver() @@ -169,18 +170,64 @@ async def test_custom_command_profile(self): self.assertEqual(driver.io.writes, [b"TP 0.120\r", b"TM 0.12\r"]) + async def test_custom_command_acknowledgement_codes_are_parsed(self): + commands = { + **DEFAULT_PLATELOC_COMMANDS, + "set_sealing_temperature": "TP", + "check_cycle_complete": "CP", + } + driver = self.make_driver(commands=commands, ack_timeout=0.01) + await driver.setup() + driver.io.queue_response(b"TPNK(Desired Temperature is Out of Range)\r") + + with self.assertRaisesRegex(PlateLocError, "Desired Temperature is Out of Range"): + await driver.set_sealing_temperature(120) + + driver.io.queue_response(b"CPAK\r") + self.assertTrue(await driver.check_cycle_complete()) + self.assertEqual(driver.io.writes, [b"TP 0.120\r", b"CP 00\r"]) + + async def test_seal_waits_for_cycle_completion(self): + profile = PlateLocSerialProfile( + response_timeout=0.01, + ack_timeout=0, + read_delay=0, + stage_move_delay=0, + cycle_poll_interval=0, + ) + device = PlateLoc(name="plateloc", port="COM6", profile=profile, timeout=1, serial_cls=FakeSerial) + + await device.setup() + device.driver.io.queue_response(b"CCNK\r") + device.driver.io.queue_response(b"CCAK\r") + + await device.sealer.seal(120, 1.2) + + self.assertEqual( + device.driver.io.writes, + [ + b"ST 0.120\r", + b"SS 0.12\r", + b"GO 00\r", + b"CC 00\r", + b"CC 00\r", + ], + ) + async def test_device_exposes_sealer_capability(self): profile = PlateLocSerialProfile( response_timeout=0.01, ack_timeout=0, read_delay=0, stage_move_delay=0, + cycle_poll_interval=0, ) device = PlateLoc(name="plateloc", port="COM6", profile=profile, serial_cls=FakeSerial) await device.setup() await device.set_sealing_temperature(100) await device.set_sealing_time(0.5) + device.driver.io.queue_response(b"CCAK\r") await device.sealer.seal(120, 1.2) await device.sealer.open() await device.sealer.close() @@ -195,6 +242,7 @@ async def test_device_exposes_sealer_capability(self): b"ST 0.120\r", b"SS 0.12\r", b"GO 00\r", + b"CC 00\r", b"SO 00\r", b"SI 00\r", ], From 1ab7247a91b60cf03187b95db0f590bf650b01b4 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Fri, 8 May 2026 20:46:07 -0700 Subject: [PATCH 04/10] Fix PlateLoc typing --- pylabrobot/agilent/plateloc/plateloc.py | 4 ++-- pylabrobot/agilent/plateloc/plateloc_tests.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pylabrobot/agilent/plateloc/plateloc.py b/pylabrobot/agilent/plateloc/plateloc.py index 2fb7ef64191..851ff6500be 100644 --- a/pylabrobot/agilent/plateloc/plateloc.py +++ b/pylabrobot/agilent/plateloc/plateloc.py @@ -6,7 +6,7 @@ import logging import re import time -from typing import Mapping, Optional +from typing import Mapping, Optional, cast from pylabrobot.capabilities.capability import BackendParams from pylabrobot.capabilities.sealing import Sealer, SealerBackend @@ -167,7 +167,7 @@ def __init__( @property def port(self) -> str: - return self.io.port + return cast(str, self.io.port) async def setup(self, backend_params: Optional[BackendParams] = None): await self.io.setup() diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py index 5df1d5b32e9..367e77dfa1a 100644 --- a/pylabrobot/agilent/plateloc/plateloc_tests.py +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -2,6 +2,7 @@ import contextlib import unittest from collections import deque +from typing import Deque from pylabrobot.agilent.plateloc import ( DEFAULT_PLATELOC_COMMANDS, @@ -18,7 +19,7 @@ def __init__(self, **kwargs): self.kwargs = kwargs self._port = kwargs["port"] self.writes = [] - self.responses = deque() + self.responses: Deque[bytes] = deque() self.setup_called = False self.stop_called = False self.timeout = kwargs["timeout"] From abd93773e7f7e736a52e3aec984ab2876a251364 Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 13 May 2026 18:44:13 -0700 Subject: [PATCH 05/10] Harden PlateLoc serial acknowledgements --- .../agilent/plateloc/hello-world.md | 2 +- pylabrobot/agilent/plateloc/plateloc.py | 11 ++-- pylabrobot/agilent/plateloc/plateloc_tests.py | 52 +++++++++++++++++-- 3 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/user_guide/agilent/plateloc/hello-world.md b/docs/user_guide/agilent/plateloc/hello-world.md index 247c9b18856..b8e3cf219b2 100644 --- a/docs/user_guide/agilent/plateloc/hello-world.md +++ b/docs/user_guide/agilent/plateloc/hello-world.md @@ -31,7 +31,7 @@ await plateloc.sealer.close() `sealer.open()` and `sealer.close()` move the stage and wait for the default stage-settle delay. `sealer.seal()` starts a sealing cycle after writing the requested temperature and time. -The PlateLoc-specific frontend also exposes independent setpoint and status helpers: +The PlateLoc device class also exposes independent setpoint and status helpers: ```python await plateloc.set_sealing_temperature(160) diff --git a/pylabrobot/agilent/plateloc/plateloc.py b/pylabrobot/agilent/plateloc/plateloc.py index 851ff6500be..92411d30ef8 100644 --- a/pylabrobot/agilent/plateloc/plateloc.py +++ b/pylabrobot/agilent/plateloc/plateloc.py @@ -203,11 +203,12 @@ async def send_command( response = await self.read_response( timeout=self.profile.response_timeout if expect_response else self.profile.ack_timeout, - required=expect_response, + required=True, ) + assert response is not None self._last_command = command self._last_response = response - if response is not None and raise_on_nak: + if raise_on_nak: self._raise_for_error(command, response) return response @@ -233,7 +234,7 @@ async def read_response(self, timeout: Optional[float] = None, required: bool = def _raise_for_error(self, command: str, response: str): match = _ACK_RE.match(response) if match is None: - return + raise PlateLocError(f"PlateLoc returned invalid response to {command!r}: {response!r}") code = match.group("code") expected_code = self.profile.commands.get(command) if expected_code is not None and code != expected_code: @@ -302,7 +303,9 @@ async def check_cycle_complete(self) -> bool: ) match = _ACK_RE.match(response or "") if match is None: - return False + raise PlateLocError( + f"PlateLoc returned invalid response to 'check_cycle_complete': {response!r}" + ) expected_code = self.profile.commands.get("check_cycle_complete") if expected_code is not None and match.group("code") != expected_code: raise PlateLocError( diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py index 367e77dfa1a..ae5eb51756e 100644 --- a/pylabrobot/agilent/plateloc/plateloc_tests.py +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -68,7 +68,7 @@ async def reset_input_buffer(self): class PlateLocTests(unittest.IsolatedAsyncioTestCase): - def make_driver(self, commands=None, ack_timeout=0, timeout=30): + def make_driver(self, commands=None, ack_timeout=0.01, timeout=30): profile = PlateLocSerialProfile( response_timeout=0.01, ack_timeout=ack_timeout, @@ -98,6 +98,8 @@ async def test_setup_uses_plr_serial_wrapper_settings(self): async def test_temperature_and_time_writes_are_scaled_and_validated(self): driver = self.make_driver() await driver.setup() + driver.io.queue_response(b"STAK\r") + driver.io.queue_response(b"SSAK\r") await driver.set_sealing_temperature(30) await driver.set_sealing_time(0.5) @@ -119,6 +121,25 @@ async def test_negative_acknowledgement_raises_protocol_error(self): self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) + async def test_missing_acknowledgement_raises_timeout(self): + driver = self.make_driver() + await driver.setup() + + with self.assertRaisesRegex(TimeoutError, "Timeout"): + await driver.set_sealing_temperature(30) + + self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) + + async def test_malformed_acknowledgement_raises_protocol_error(self): + driver = self.make_driver() + await driver.setup() + driver.io.queue_response(b"unexpected\r") + + with self.assertRaisesRegex(PlateLocError, "invalid response"): + await driver.set_sealing_temperature(30) + + self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) + async def test_required_response_reads_until_plate_loc_ack(self): driver = self.make_driver() await driver.setup() @@ -135,9 +156,22 @@ async def test_cycle_not_complete_returns_false(self): self.assertFalse(await driver.check_cycle_complete()) self.assertEqual(driver.io.writes, [b"CC 00\r"]) + async def test_invalid_cycle_complete_response_raises_protocol_error(self): + driver = self.make_driver() + await driver.setup() + driver.io.queue_response(b"unexpected\r") + + with self.assertRaisesRegex(PlateLocError, "invalid response"): + await driver.check_cycle_complete() + + self.assertEqual(driver.io.writes, [b"CC 00\r"]) + async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): driver = self.make_driver() await driver.setup() + driver.io.queue_response(b"STAK\r") + driver.io.queue_response(b"SSAK\r") + driver.io.queue_response(b"SOAK\r") await driver.set_sealing_temperature(30) await driver.set_sealing_time(0.5) @@ -165,6 +199,8 @@ async def test_custom_command_profile(self): } ) await driver.setup() + driver.io.queue_response(b"TPAK\r") + driver.io.queue_response(b"TMAK\r") await driver.set_sealing_temperature(120) await driver.set_sealing_time(1.25) @@ -191,7 +227,7 @@ async def test_custom_command_acknowledgement_codes_are_parsed(self): async def test_seal_waits_for_cycle_completion(self): profile = PlateLocSerialProfile( response_timeout=0.01, - ack_timeout=0, + ack_timeout=0.01, read_delay=0, stage_move_delay=0, cycle_poll_interval=0, @@ -199,6 +235,9 @@ async def test_seal_waits_for_cycle_completion(self): device = PlateLoc(name="plateloc", port="COM6", profile=profile, timeout=1, serial_cls=FakeSerial) await device.setup() + device.driver.io.queue_response(b"STAK\r") + device.driver.io.queue_response(b"SSAK\r") + device.driver.io.queue_response(b"GOAK\r") device.driver.io.queue_response(b"CCNK\r") device.driver.io.queue_response(b"CCAK\r") @@ -218,7 +257,7 @@ async def test_seal_waits_for_cycle_completion(self): async def test_device_exposes_sealer_capability(self): profile = PlateLocSerialProfile( response_timeout=0.01, - ack_timeout=0, + ack_timeout=0.01, read_delay=0, stage_move_delay=0, cycle_poll_interval=0, @@ -226,11 +265,18 @@ async def test_device_exposes_sealer_capability(self): device = PlateLoc(name="plateloc", port="COM6", profile=profile, serial_cls=FakeSerial) await device.setup() + device.driver.io.queue_response(b"STAK\r") await device.set_sealing_temperature(100) + device.driver.io.queue_response(b"SSAK\r") await device.set_sealing_time(0.5) + device.driver.io.queue_response(b"STAK\r") + device.driver.io.queue_response(b"SSAK\r") + device.driver.io.queue_response(b"GOAK\r") device.driver.io.queue_response(b"CCAK\r") await device.sealer.seal(120, 1.2) + device.driver.io.queue_response(b"SOAK\r") await device.sealer.open() + device.driver.io.queue_response(b"SIAK\r") await device.sealer.close() status = device.status_snapshot() await device.stop() From f0efff746046a456eb08e37c4469e27a7f174853 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 14 May 2026 12:14:25 -0700 Subject: [PATCH 06/10] Convert PlateLoc hello-world doc to notebook Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agilent/plateloc/hello-world.ipynb | 157 ++++++++++++++++++ .../agilent/plateloc/hello-world.md | 100 ----------- 2 files changed, 157 insertions(+), 100 deletions(-) create mode 100644 docs/user_guide/agilent/plateloc/hello-world.ipynb delete mode 100644 docs/user_guide/agilent/plateloc/hello-world.md diff --git a/docs/user_guide/agilent/plateloc/hello-world.ipynb b/docs/user_guide/agilent/plateloc/hello-world.ipynb new file mode 100644 index 00000000000..9e01e667532 --- /dev/null +++ b/docs/user_guide/agilent/plateloc/hello-world.ipynb @@ -0,0 +1,157 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "plateloc-intro", + "source": "# Agilent PlateLoc\n\nThe Agilent PlateLoc is controlled through PLR's `Sealer` capability with a direct RS-232 serial driver. It does not require Agilent ActiveX, VWorks, or vendor server software. Install the optional serial dependency before connecting:\n\n```bash\npip install \"pylabrobot[serial]\"\n```", + "metadata": {} + }, + { + "cell_type": "code", + "id": "plateloc-import", + "source": "from pylabrobot.agilent import PlateLoc\n\nplateloc = PlateLoc(name=\"plateloc\", port=\"COM6\")", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-setup", + "source": "await plateloc.setup()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-set-temp", + "source": "await plateloc.set_sealing_temperature(175)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-set-time", + "source": "await plateloc.set_sealing_time(0.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-status-first", + "source": "status = await plateloc.request_status()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-stop-first", + "source": "await plateloc.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "plateloc-sealer-intro", + "source": "The device also exposes the standard sealer capability:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "plateloc-seal", + "source": "await plateloc.sealer.seal(temperature=175, duration=1.5)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-open", + "source": "await plateloc.sealer.open()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-close", + "source": "await plateloc.sealer.close()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "plateloc-sealer-notes", + "source": "`sealer.open()` and `sealer.close()` move the stage and wait for the default stage-settle delay. `sealer.seal()` starts a sealing cycle after writing the requested temperature and time.\n\nThe PlateLoc device class also exposes independent setpoint and status helpers:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "plateloc-set-temp-160", + "source": "await plateloc.set_sealing_temperature(160)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-set-time-1", + "source": "await plateloc.set_sealing_time(1.0)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "plateloc-status-print", + "source": "status = await plateloc.request_status()\nprint(status.target_temperature, status.sealing_time, status.cycle_complete)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "plateloc-status-notes", + "source": "`request_status()` returns the best-known PLR state plus a live cycle-complete query. The direct serial protocol decoded here does not expose actual block temperature or actual stored time reads, so PLR reports the last successfully written target temperature and sealing time.", + "metadata": {} + }, + { + "cell_type": "markdown", + "id": "plateloc-protocol", + "source": "## Serial command profile\n\nThe decoded direct protocol uses `19200 8N1` and carriage-return-terminated ASCII frames with two-letter command codes plus payloads. Temperature and time payloads use the firmware's fractional setpoint convention: the digits after the decimal point are the integer controller value.\n\n| Operation | Frame |\n|---|---|\n| Set sealing temperature | `ST 0.{temperature_celsius:03d}\\r` |\n| Set sealing time | `SS 0.{seconds_x10:02d}\\r` |\n| Start cycle | `GO 00\\r` |\n| Stop cycle | `AC 00\\r` |\n| Move stage out | `SO 00\\r` |\n| Move stage in | `SI 00\\r` |\n| Apply seal | `AS 00\\r` |\n| Clear error | `CL 00\\r` |\n| Check cycle complete | `CC 00\\r` |\n\nFor example, `set_sealing_temperature(175)` writes `ST 0.175\\r`, `set_sealing_temperature(30)` writes `ST 0.030\\r`, `set_sealing_time(0.5)` writes `SS 0.05\\r`, and `set_sealing_time(1.2)` writes `SS 0.12\\r`.\n\nNegative acknowledgements are parsed as `NK(message)` and raised as `PlateLocError`. Some valid firmware commands reply with single-carriage-return acknowledgements such as `SOAK\\r`. The cycle-complete command returns `True` for `CCAK\\r` and `False` for `CCNK\\r`.\n\nYou can still override command codes or serial settings with `PlateLocSerialProfile` while keeping the same PLR frontend:", + "metadata": {} + }, + { + "cell_type": "code", + "id": "plateloc-profile", + "source": "from pylabrobot.agilent import PlateLoc, PlateLocSerialProfile\n\nprofile = PlateLocSerialProfile(\n baudrate=19200,\n stage_move_delay=6,\n commands={\n \"set_sealing_temperature\": \"ST\",\n \"set_sealing_time\": \"SS\",\n \"start_cycle\": \"GO\",\n \"move_stage_out\": \"SO\",\n \"move_stage_in\": \"SI\",\n },\n)\n\nplateloc = PlateLoc(name=\"plateloc\", port=\"COM6\", profile=profile)", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "plateloc-troubleshooting", + "source": "## Troubleshooting\n\nThe PlateLoc RS-232 connector is not VGA and is not USB TTL. Use a USB-to-RS-232 adapter plus the correct DB9 cable for the instrument. If the port opens but every command times out, verify the PlateLoc is powered, the rear serial cable is seated, and the cable wiring matches the instrument requirement. Some setups require a null-modem DB9 adapter rather than a straight-through cable.", + "metadata": {} + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/user_guide/agilent/plateloc/hello-world.md b/docs/user_guide/agilent/plateloc/hello-world.md deleted file mode 100644 index b8e3cf219b2..00000000000 --- a/docs/user_guide/agilent/plateloc/hello-world.md +++ /dev/null @@ -1,100 +0,0 @@ -# Agilent PlateLoc - -The Agilent PlateLoc is controlled through PLR's `Sealer` capability with a direct RS-232 serial -driver. It does not require Agilent ActiveX, VWorks, or vendor server software. Install the -optional serial dependency before connecting: - -```bash -pip install "pylabrobot[serial]" -``` - -```python -from pylabrobot.agilent import PlateLoc - -plateloc = PlateLoc(name="plateloc", port="COM6") - -await plateloc.setup() -await plateloc.set_sealing_temperature(175) -await plateloc.set_sealing_time(0.5) -status = await plateloc.request_status() -await plateloc.stop() -``` - -The device also exposes the standard sealer capability: - -```python -await plateloc.sealer.seal(temperature=175, duration=1.5) -await plateloc.sealer.open() -await plateloc.sealer.close() -``` - -`sealer.open()` and `sealer.close()` move the stage and wait for the default stage-settle delay. -`sealer.seal()` starts a sealing cycle after writing the requested temperature and time. - -The PlateLoc device class also exposes independent setpoint and status helpers: - -```python -await plateloc.set_sealing_temperature(160) -await plateloc.set_sealing_time(1.0) - -status = await plateloc.request_status() -print(status.target_temperature, status.sealing_time, status.cycle_complete) -``` - -`request_status()` returns the best-known PLR state plus a live cycle-complete query. The direct -serial protocol decoded here does not expose actual block temperature or actual stored time reads, -so PLR reports the last successfully written target temperature and sealing time. - -## Serial command profile - -The decoded direct protocol uses `19200 8N1` and carriage-return-terminated ASCII frames with -two-letter command codes plus payloads. Temperature and time payloads use the firmware's fractional -setpoint convention: the digits after the decimal point are the integer controller value. - -| Operation | Frame | -|---|---| -| Set sealing temperature | `ST 0.{temperature_celsius:03d}\r` | -| Set sealing time | `SS 0.{seconds_x10:02d}\r` | -| Start cycle | `GO 00\r` | -| Stop cycle | `AC 00\r` | -| Move stage out | `SO 00\r` | -| Move stage in | `SI 00\r` | -| Apply seal | `AS 00\r` | -| Clear error | `CL 00\r` | -| Check cycle complete | `CC 00\r` | - -For example, `set_sealing_temperature(175)` writes `ST 0.175\r`, `set_sealing_temperature(30)` -writes `ST 0.030\r`, `set_sealing_time(0.5)` writes `SS 0.05\r`, and `set_sealing_time(1.2)` -writes `SS 0.12\r`. - -Negative acknowledgements are parsed as `NK(message)` and raised as `PlateLocError`. Some -valid firmware commands reply with single-carriage-return acknowledgements such as `SOAK\r`. The -cycle-complete command returns `True` for `CCAK\r` and `False` for `CCNK\r`. - -You can still override command codes or serial settings with `PlateLocSerialProfile` while keeping -the same PLR frontend: - -```python -from pylabrobot.agilent import PlateLoc, PlateLocSerialProfile - -profile = PlateLocSerialProfile( - baudrate=19200, - stage_move_delay=6, - commands={ - "set_sealing_temperature": "ST", - "set_sealing_time": "SS", - "start_cycle": "GO", - "move_stage_out": "SO", - "move_stage_in": "SI", - }, -) - -plateloc = PlateLoc(name="plateloc", port="COM6", profile=profile) -``` - -## Troubleshooting - -The PlateLoc RS-232 connector is not VGA and is not USB TTL. Use a USB-to-RS-232 adapter plus the -correct DB9 cable for the instrument. If the port opens but every command times out, verify the -PlateLoc is powered, the rear serial cable is seated, and the cable wiring matches the instrument -requirement. Some setups require a null-modem DB9 adapter rather than a straight-through cable. From 1630f941f6a8e7338b3f5c79123612c7a2abce6b Mon Sep 17 00:00:00 2001 From: Alex Godfrey Date: Wed, 20 May 2026 16:06:45 -0700 Subject: [PATCH 07/10] Address PlateLoc v1b1 review feedback --- docs/api/pylabrobot.agilent.rst | 19 ++ .../agilent/plateloc/hello-world.ipynb | 34 +- pylabrobot/agilent/__init__.py | 1 + pylabrobot/agilent/plateloc/__init__.py | 2 +- pylabrobot/agilent/plateloc/plateloc.py | 305 +++++++++--------- pylabrobot/agilent/plateloc/plateloc_tests.py | 154 ++++----- 6 files changed, 279 insertions(+), 236 deletions(-) diff --git a/docs/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst index 4f03a36203b..ce31bc7b03f 100644 --- a/docs/api/pylabrobot.agilent.rst +++ b/docs/api/pylabrobot.agilent.rst @@ -99,3 +99,22 @@ VSpin VSpin Access2 Access2Driver + + +PlateLoc +-------- + +.. currentmodule:: pylabrobot.agilent.plateloc + +.. autosummary:: + :toctree: _autosummary + :nosignatures: + :recursive: + + PlateLoc + PlateLocSealer + PlateLocSealerBackend + PlateLocDriver + PlateLocSerialProfile + PlateLocStatus + PlateLocError diff --git a/docs/user_guide/agilent/plateloc/hello-world.ipynb b/docs/user_guide/agilent/plateloc/hello-world.ipynb index 9e01e667532..246a6b5a63d 100644 --- a/docs/user_guide/agilent/plateloc/hello-world.ipynb +++ b/docs/user_guide/agilent/plateloc/hello-world.ipynb @@ -25,7 +25,7 @@ { "cell_type": "code", "id": "plateloc-set-temp", - "source": "await plateloc.set_sealing_temperature(175)", + "source": "await plateloc.sealer.set_sealing_temperature(175)", "metadata": {}, "execution_count": null, "outputs": [] @@ -33,7 +33,7 @@ { "cell_type": "code", "id": "plateloc-set-time", - "source": "await plateloc.set_sealing_time(0.5)", + "source": "await plateloc.sealer.set_sealing_time(0.5)", "metadata": {}, "execution_count": null, "outputs": [] @@ -41,15 +41,7 @@ { "cell_type": "code", "id": "plateloc-status-first", - "source": "status = await plateloc.request_status()", - "metadata": {}, - "execution_count": null, - "outputs": [] - }, - { - "cell_type": "code", - "id": "plateloc-stop-first", - "source": "await plateloc.stop()", + "source": "status = await plateloc.sealer.request_status()", "metadata": {}, "execution_count": null, "outputs": [] @@ -87,13 +79,13 @@ { "cell_type": "markdown", "id": "plateloc-sealer-notes", - "source": "`sealer.open()` and `sealer.close()` move the stage and wait for the default stage-settle delay. `sealer.seal()` starts a sealing cycle after writing the requested temperature and time.\n\nThe PlateLoc device class also exposes independent setpoint and status helpers:", + "source": "`sealer.open()` and `sealer.close()` move the stage and wait for the default stage-settle delay. `sealer.seal()` starts a sealing cycle after writing the requested temperature and time.\n\nThe PlateLoc sealer capability also exposes independent setpoint and status helpers:", "metadata": {} }, { "cell_type": "code", "id": "plateloc-set-temp-160", - "source": "await plateloc.set_sealing_temperature(160)", + "source": "await plateloc.sealer.set_sealing_temperature(160)", "metadata": {}, "execution_count": null, "outputs": [] @@ -101,7 +93,7 @@ { "cell_type": "code", "id": "plateloc-set-time-1", - "source": "await plateloc.set_sealing_time(1.0)", + "source": "await plateloc.sealer.set_sealing_time(1.0)", "metadata": {}, "execution_count": null, "outputs": [] @@ -109,7 +101,7 @@ { "cell_type": "code", "id": "plateloc-status-print", - "source": "status = await plateloc.request_status()\nprint(status.target_temperature, status.sealing_time, status.cycle_complete)", + "source": "status = await plateloc.sealer.request_status()\nprint(status.target_temperature, status.sealing_time, status.cycle_complete)", "metadata": {}, "execution_count": null, "outputs": [] @@ -120,16 +112,24 @@ "source": "`request_status()` returns the best-known PLR state plus a live cycle-complete query. The direct serial protocol decoded here does not expose actual block temperature or actual stored time reads, so PLR reports the last successfully written target temperature and sealing time.", "metadata": {} }, + { + "cell_type": "code", + "id": "plateloc-stop", + "source": "await plateloc.stop()", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, { "cell_type": "markdown", "id": "plateloc-protocol", - "source": "## Serial command profile\n\nThe decoded direct protocol uses `19200 8N1` and carriage-return-terminated ASCII frames with two-letter command codes plus payloads. Temperature and time payloads use the firmware's fractional setpoint convention: the digits after the decimal point are the integer controller value.\n\n| Operation | Frame |\n|---|---|\n| Set sealing temperature | `ST 0.{temperature_celsius:03d}\\r` |\n| Set sealing time | `SS 0.{seconds_x10:02d}\\r` |\n| Start cycle | `GO 00\\r` |\n| Stop cycle | `AC 00\\r` |\n| Move stage out | `SO 00\\r` |\n| Move stage in | `SI 00\\r` |\n| Apply seal | `AS 00\\r` |\n| Clear error | `CL 00\\r` |\n| Check cycle complete | `CC 00\\r` |\n\nFor example, `set_sealing_temperature(175)` writes `ST 0.175\\r`, `set_sealing_temperature(30)` writes `ST 0.030\\r`, `set_sealing_time(0.5)` writes `SS 0.05\\r`, and `set_sealing_time(1.2)` writes `SS 0.12\\r`.\n\nNegative acknowledgements are parsed as `NK(message)` and raised as `PlateLocError`. Some valid firmware commands reply with single-carriage-return acknowledgements such as `SOAK\\r`. The cycle-complete command returns `True` for `CCAK\\r` and `False` for `CCNK\\r`.\n\nYou can still override command codes or serial settings with `PlateLocSerialProfile` while keeping the same PLR frontend:", + "source": "## Serial command profile\n\nThe decoded direct protocol uses `19200 8N1` and carriage-return-terminated ASCII frames with two-letter command codes plus payloads. Temperature and time payloads use the firmware's fractional setpoint convention: the digits after the decimal point are the integer controller value.\n\n| Operation | Frame |\n|---|---|\n| Set sealing temperature | `ST 0.{temperature_celsius:03d}\\r` |\n| Set sealing time | `SS 0.{seconds_x10:02d}\\r` |\n| Start cycle | `GO 00\\r` |\n| Stop cycle | `AC 00\\r` |\n| Move stage out | `SO 00\\r` |\n| Move stage in | `SI 00\\r` |\n| Apply seal | `AS 00\\r` |\n| Clear error | `CL 00\\r` |\n| Check cycle complete | `CC 00\\r` |\n\nFor example, `set_sealing_temperature(175)` writes `ST 0.175\\r`, `set_sealing_temperature(30)` writes `ST 0.030\\r`, `set_sealing_time(0.5)` writes `SS 0.05\\r`, and `set_sealing_time(1.2)` writes `SS 0.12\\r`.\n\nNegative acknowledgements are parsed as `NK(message)` and raised as `PlateLocError`. Some valid firmware commands reply with single-carriage-return acknowledgements such as `SOAK\\r`. The cycle-complete command returns `True` for `CCAK\\r` and `False` for `CCNK\\r`.\n\nYou can still override serial settings and timing with `PlateLocSerialProfile` while keeping the same PLR frontend:", "metadata": {} }, { "cell_type": "code", "id": "plateloc-profile", - "source": "from pylabrobot.agilent import PlateLoc, PlateLocSerialProfile\n\nprofile = PlateLocSerialProfile(\n baudrate=19200,\n stage_move_delay=6,\n commands={\n \"set_sealing_temperature\": \"ST\",\n \"set_sealing_time\": \"SS\",\n \"start_cycle\": \"GO\",\n \"move_stage_out\": \"SO\",\n \"move_stage_in\": \"SI\",\n },\n)\n\nplateloc = PlateLoc(name=\"plateloc\", port=\"COM6\", profile=profile)", + "source": "from pylabrobot.agilent import PlateLoc, PlateLocSerialProfile\n\nprofile = PlateLocSerialProfile(\n baudrate=19200,\n stage_move_delay=6,\n)\n\nplateloc = PlateLoc(name=\"plateloc\", port=\"COM6\", profile=profile)", "metadata": {}, "execution_count": null, "outputs": [] diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index 7829538f52d..9104a0ad44c 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -10,6 +10,7 @@ PlateLoc, PlateLocDriver, PlateLocError, + PlateLocSealer, PlateLocSealerBackend, PlateLocSerialProfile, PlateLocStatus, diff --git a/pylabrobot/agilent/plateloc/__init__.py b/pylabrobot/agilent/plateloc/__init__.py index 28b11ca53e6..dc194e38b7d 100644 --- a/pylabrobot/agilent/plateloc/__init__.py +++ b/pylabrobot/agilent/plateloc/__init__.py @@ -1,8 +1,8 @@ from .plateloc import ( - DEFAULT_PLATELOC_COMMANDS, PlateLoc, PlateLocDriver, PlateLocError, + PlateLocSealer, PlateLocSealerBackend, PlateLocSerialProfile, PlateLocStatus, diff --git a/pylabrobot/agilent/plateloc/plateloc.py b/pylabrobot/agilent/plateloc/plateloc.py index 92411d30ef8..152d01bdc8f 100644 --- a/pylabrobot/agilent/plateloc/plateloc.py +++ b/pylabrobot/agilent/plateloc/plateloc.py @@ -1,14 +1,13 @@ from __future__ import annotations import asyncio -import contextlib import dataclasses import logging import re import time -from typing import Mapping, Optional, cast +from typing import Optional, cast -from pylabrobot.capabilities.capability import BackendParams +from pylabrobot.capabilities.capability import BackendParams, need_capability_ready from pylabrobot.capabilities.sealing import Sealer, SealerBackend from pylabrobot.device import Device, Driver from pylabrobot.io.serial import Serial @@ -24,18 +23,6 @@ logger = logging.getLogger(__name__) -DEFAULT_PLATELOC_COMMANDS: Mapping[str, str] = { - "set_sealing_temperature": "ST", - "set_sealing_time": "SS", - "move_stage_out": "SO", - "move_stage_in": "SI", - "start_cycle": "GO", - "stop_cycle": "AC", - "apply_seal": "AS", - "clear_error": "CL", - "check_cycle_complete": "CC", -} - _ACK_RE = re.compile(r"^\s*(?P[A-Z0-9]{2})(?P[AN])K(?:\((?P.*)\))?\s*$") @@ -59,11 +46,11 @@ class PlateLocStatus: @dataclasses.dataclass(frozen=True) class PlateLocSerialProfile: - """Serial settings and command codes for a PlateLoc controller. + """Serial settings for a PlateLoc controller. - The decoded low-level protocol uses two-letter command codes followed by a payload and a - carriage return. Setpoint payloads are encoded as a decimal fraction whose fractional digits - hold the integer setpoint, for example ``ST 0.175`` for 175 C and ``SS 0.12`` for 1.2 s. + The decoded low-level protocol uses carriage-return-terminated ASCII frames. + Setpoint payloads are encoded as a decimal fraction whose fractional digits hold the integer + setpoint, for example ``ST 0.175`` for 175 C and ``SS 0.12`` for 1.2 s. """ baudrate: int = 19200 @@ -82,15 +69,6 @@ class PlateLocSerialProfile: cycle_poll_interval: float = 0.5 command_terminator: str = "\r" response_terminator: bytes = b"\r" - commands: Mapping[str, str] = dataclasses.field( - default_factory=lambda: dict(DEFAULT_PLATELOC_COMMANDS) - ) - - def format_command(self, command: str, payload: str = "00") -> bytes: - code = self.commands.get(command) - if code is None: - raise PlateLocError(f"No PlateLoc serial command configured for {command!r}.") - return f"{code} {payload}{self.command_terminator}".encode("ascii") def serialize(self) -> dict: return { @@ -110,7 +88,6 @@ def serialize(self) -> dict: "cycle_poll_interval": self.cycle_poll_interval, "command_terminator": self.command_terminator, "response_terminator": self.response_terminator.decode("latin1"), - "commands": dict(self.commands), } @classmethod @@ -122,7 +99,7 @@ def deserialize(cls, data: dict) -> "PlateLocSerialProfile": class PlateLocDriver(Driver): - """Direct serial driver for the Agilent PlateLoc thermal microplate sealer.""" + """Direct serial transport for the Agilent PlateLoc thermal microplate sealer.""" def __init__( self, @@ -131,10 +108,9 @@ def __init__( pid: Optional[int] = None, profile: Optional[PlateLocSerialProfile | dict] = None, timeout: float = 30, - serial_cls=Serial, ) -> None: super().__init__() - if serial_cls is Serial and not HAS_SERIAL: + if not HAS_SERIAL: raise RuntimeError( "pyserial is not installed. Install with: pip install pylabrobot[serial]. " f"Import error: {_SERIAL_IMPORT_ERROR}" @@ -144,12 +120,9 @@ def __init__( self.profile = profile or PlateLocSerialProfile() self.timeout = timeout self._connected = False - self._target_temperature: Optional[float] = None - self._sealing_time: Optional[float] = None - self._stage_position: Optional[str] = None self._last_command: Optional[str] = None self._last_response: Optional[str] = None - self.io = serial_cls( + self.io = Serial( human_readable_device_name="Agilent PlateLoc Sealer", port=port, vid=vid, @@ -169,6 +142,18 @@ def __init__( def port(self) -> str: return cast(str, self.io.port) + @property + def connected(self) -> bool: + return self._connected + + @property + def last_command(self) -> Optional[str]: + return self._last_command + + @property + def last_response(self) -> Optional[str]: + return self._last_response + async def setup(self, backend_params: Optional[BackendParams] = None): await self.io.setup() self._connected = True @@ -179,44 +164,35 @@ async def stop(self): self._connected = False logger.info("[PlateLoc %s] disconnected", self.port) - @contextlib.contextmanager - def _read_timeout(self, timeout: float): - if hasattr(self.io, "temporary_timeout"): - with self.io.temporary_timeout(timeout): - yield - else: - yield - async def send_command( self, command: str, - payload: str = "00", - expect_response: bool = False, - raise_on_nak: bool = True, + *, + timeout: Optional[float] = None, + required: bool = True, ) -> Optional[str]: - data = self.profile.format_command(command, payload=payload) - if hasattr(self.io, "reset_input_buffer"): - await self.io.reset_input_buffer() - await self.io.write(data) + """Send one literal PlateLoc serial frame and return the raw response.""" + command = command.removesuffix(self.profile.command_terminator) + await self.io.reset_input_buffer() + await self.io.write(f"{command}{self.profile.command_terminator}".encode("ascii")) + self._last_command = command + if self.profile.read_delay > 0: await asyncio.sleep(self.profile.read_delay) - response = await self.read_response( - timeout=self.profile.response_timeout if expect_response else self.profile.ack_timeout, - required=True, - ) - assert response is not None - self._last_command = command + response = await self.read_response(timeout=timeout, required=required) self._last_response = response - if raise_on_nak: - self._raise_for_error(command, response) return response - async def read_response(self, timeout: Optional[float] = None, required: bool = True) -> Optional[str]: + async def read_response( + self, + timeout: Optional[float] = None, + required: bool = True, + ) -> Optional[str]: deadline = time.time() + (timeout if timeout is not None else self.profile.response_timeout) chunks = bytearray() while time.time() < deadline: - with self._read_timeout(max(0.01, min(0.1, deadline - time.time()))): + with self.io.temporary_timeout(max(0.01, min(0.1, deadline - time.time()))): chunk = await self.io.read(1) if chunk: chunks.extend(chunk) @@ -231,25 +207,80 @@ async def read_response(self, timeout: Optional[float] = None, required: bool = return None return bytes(chunks).decode("utf-8", errors="replace").strip() - def _raise_for_error(self, command: str, response: str): + def serialize(self) -> dict: + return { + **super().serialize(), + "port": self.port, + "profile": self.profile.serialize(), + "timeout": self.timeout, + } + + +class PlateLocSealerBackend(SealerBackend): + """Translates SealerBackend operations into direct PlateLoc serial commands.""" + + _SET_TEMPERATURE = "ST" + _SET_TIME = "SS" + _MOVE_STAGE_OUT = "SO" + _MOVE_STAGE_IN = "SI" + _START_CYCLE = "GO" + _STOP_CYCLE = "AC" + _APPLY_SEAL = "AS" + _CLEAR_ERROR = "CL" + _CHECK_CYCLE_COMPLETE = "CC" + + def __init__(self, driver: PlateLocDriver): + self._driver = driver + self._target_temperature: Optional[float] = None + self._sealing_time: Optional[float] = None + self._stage_position: Optional[str] = None + + @property + def driver(self) -> PlateLocDriver: + return self._driver + + def _parse_response(self, command_code: str, response: str) -> re.Match[str]: match = _ACK_RE.match(response) if match is None: - raise PlateLocError(f"PlateLoc returned invalid response to {command!r}: {response!r}") + raise PlateLocError(f"PlateLoc returned invalid response to {command_code!r}: {response!r}") code = match.group("code") - expected_code = self.profile.commands.get(command) - if expected_code is not None and code != expected_code: - raise PlateLocError(f"PlateLoc replied with {code!r} to {command!r}: {response!r}") + if code != command_code: + raise PlateLocError(f"PlateLoc replied with {code!r} to {command_code!r}: {response!r}") + return match + + def _raise_for_error(self, command_code: str, response: str) -> None: + match = self._parse_response(command_code, response) if match.group("status") == "N": message = match.group("message") or "command rejected" - raise PlateLocError(f"PlateLoc rejected {command!r}: {message}") + raise PlateLocError(f"PlateLoc rejected {command_code!r}: {message}") + + async def _send( + self, + command: str, + *, + timeout: Optional[float] = None, + raise_on_nak: bool = True, + ) -> str: + response = await self._driver.send_command( + command, + timeout=timeout if timeout is not None else self._driver.profile.ack_timeout, + required=True, + ) + assert response is not None + if raise_on_nak: + self._raise_for_error(command[:2], response) + return response async def set_sealing_temperature(self, temperature: float): if not (20 <= temperature <= 235): raise ValueError("Temperature out of range. Please enter a value between 20 and 235 C.") target_temperature = round(temperature) - payload = f"0.{target_temperature:03d}" - logger.info("[PlateLoc %s] setting sealing temperature to %.1f C", self.port, temperature) - response = await self.send_command("set_sealing_temperature", payload=payload) + logger.info( + "[PlateLoc %s] setting sealing temperature to %.1f C", + self._driver.port, + temperature, + ) + response = await self._send(f"{self._SET_TEMPERATURE} 0.{target_temperature:03d}") self._target_temperature = float(target_temperature) return response @@ -257,115 +288,113 @@ async def set_sealing_time(self, duration: float): if not (0.5 <= duration <= 12.0): raise ValueError("Duration out of range. Please enter a value between 0.5 and 12.0 s.") sealing_time_deciseconds = round(duration * 10) - payload = f"0.{sealing_time_deciseconds:02d}" - logger.info("[PlateLoc %s] setting sealing time to %.2f s", self.port, duration) - response = await self.send_command("set_sealing_time", payload=payload) + logger.info("[PlateLoc %s] setting sealing time to %.2f s", self._driver.port, duration) + response = await self._send(f"{self._SET_TIME} 0.{sealing_time_deciseconds:02d}") self._sealing_time = sealing_time_deciseconds / 10 return response async def move_stage_out(self): - logger.info("[PlateLoc %s] moving stage out", self.port) - response = await self.send_command("move_stage_out") - if self.profile.stage_move_delay > 0: - await asyncio.sleep(self.profile.stage_move_delay) + logger.info("[PlateLoc %s] moving stage out", self._driver.port) + response = await self._send(f"{self._MOVE_STAGE_OUT} 00") + if self._driver.profile.stage_move_delay > 0: + await asyncio.sleep(self._driver.profile.stage_move_delay) self._stage_position = "open" return response async def move_stage_in(self): - logger.info("[PlateLoc %s] moving stage in", self.port) - response = await self.send_command("move_stage_in") - if self.profile.stage_move_delay > 0: - await asyncio.sleep(self.profile.stage_move_delay) + logger.info("[PlateLoc %s] moving stage in", self._driver.port) + response = await self._send(f"{self._MOVE_STAGE_IN} 00") + if self._driver.profile.stage_move_delay > 0: + await asyncio.sleep(self._driver.profile.stage_move_delay) self._stage_position = "closed" return response async def start_cycle(self): - logger.info("[PlateLoc %s] starting sealing cycle", self.port) - return await self.send_command("start_cycle") + logger.info("[PlateLoc %s] starting sealing cycle", self._driver.port) + return await self._send(f"{self._START_CYCLE} 00") async def stop_cycle(self): - logger.info("[PlateLoc %s] stopping sealing cycle", self.port) - return await self.send_command("stop_cycle") + logger.info("[PlateLoc %s] stopping sealing cycle", self._driver.port) + return await self._send(f"{self._STOP_CYCLE} 00") async def apply_seal(self): - logger.info("[PlateLoc %s] applying seal", self.port) - return await self.send_command("apply_seal") + logger.info("[PlateLoc %s] applying seal", self._driver.port) + return await self._send(f"{self._APPLY_SEAL} 00") async def clear_error(self): - logger.info("[PlateLoc %s] clearing error", self.port) - return await self.send_command("clear_error") + logger.info("[PlateLoc %s] clearing error", self._driver.port) + return await self._send(f"{self._CLEAR_ERROR} 00") async def check_cycle_complete(self) -> bool: - response = await self.send_command( - "check_cycle_complete", - expect_response=True, + response = await self._send( + f"{self._CHECK_CYCLE_COMPLETE} 00", + timeout=self._driver.profile.response_timeout, raise_on_nak=False, ) - match = _ACK_RE.match(response or "") - if match is None: - raise PlateLocError( - f"PlateLoc returned invalid response to 'check_cycle_complete': {response!r}" - ) - expected_code = self.profile.commands.get("check_cycle_complete") - if expected_code is not None and match.group("code") != expected_code: - raise PlateLocError( - f"PlateLoc replied with {match.group('code')!r} to 'check_cycle_complete': {response!r}" - ) + match = self._parse_response(self._CHECK_CYCLE_COMPLETE, response) return match.group("status") == "A" async def wait_for_cycle_complete(self, timeout: Optional[float] = None) -> bool: - deadline = time.time() + (self.timeout if timeout is None else timeout) + deadline = time.time() + (self._driver.timeout if timeout is None else timeout) while True: if await self.check_cycle_complete(): return True remaining = deadline - time.time() if remaining <= 0: raise TimeoutError("Timeout while waiting for PlateLoc cycle to complete") - await asyncio.sleep(min(max(self.profile.cycle_poll_interval, 0), remaining)) + await asyncio.sleep(min(max(self._driver.profile.cycle_poll_interval, 0), remaining)) def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: return PlateLocStatus( - port=self.port, - connected=self._connected, + port=self._driver.port, + connected=self._driver.connected, target_temperature=self._target_temperature, sealing_time=self._sealing_time, stage_position=self._stage_position, cycle_complete=cycle_complete, - last_command=self._last_command, - last_response=self._last_response, + last_command=self._driver.last_command, + last_response=self._driver.last_response, ) async def request_status(self, query_cycle_complete: bool = True) -> PlateLocStatus: cycle_complete = await self.check_cycle_complete() if query_cycle_complete else None return self.status_snapshot(cycle_complete=cycle_complete) - def serialize(self) -> dict: - return { - **super().serialize(), - "port": self.port, - "profile": self.profile.serialize(), - "timeout": self.timeout, - } - - -class PlateLocSealerBackend(SealerBackend): - """Translates SealerBackend operations into direct PlateLoc serial commands.""" - - def __init__(self, driver: PlateLocDriver): - self.driver = driver - async def seal(self, temperature: int, duration: float): - await self.driver.set_sealing_temperature(temperature) - await self.driver.set_sealing_time(duration) - response = await self.driver.start_cycle() - await self.driver.wait_for_cycle_complete() + await self.set_sealing_temperature(temperature) + await self.set_sealing_time(duration) + response = await self.start_cycle() + await self.wait_for_cycle_complete() return response async def open(self): - return await self.driver.move_stage_out() + return await self.move_stage_out() async def close(self): - return await self.driver.move_stage_in() + return await self.move_stage_in() + + +class PlateLocSealer(Sealer): + """PlateLoc-specific sealing capability.""" + + def __init__(self, backend: PlateLocSealerBackend): + super().__init__(backend=backend) + self.backend: PlateLocSealerBackend = backend + + @need_capability_ready + async def set_sealing_temperature(self, temperature: float): + return await self.backend.set_sealing_temperature(temperature) + + @need_capability_ready + async def set_sealing_time(self, duration: float): + return await self.backend.set_sealing_time(duration) + + @need_capability_ready + async def request_status(self, query_cycle_complete: bool = True) -> PlateLocStatus: + return await self.backend.request_status(query_cycle_complete=query_cycle_complete) + + def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: + return self.backend.status_snapshot(cycle_complete=cycle_complete) class PlateLoc(Device): @@ -379,7 +408,6 @@ def __init__( pid: Optional[int] = None, profile: Optional[PlateLocSerialProfile | dict] = None, timeout: float = 30, - serial_cls=Serial, ): self.name = name driver = PlateLocDriver( @@ -388,28 +416,15 @@ def __init__( pid=pid, profile=profile, timeout=timeout, - serial_cls=serial_cls, ) super().__init__(driver=driver) self.driver: PlateLocDriver = driver - self.sealer = Sealer(backend=PlateLocSealerBackend(driver)) + self.sealer: PlateLocSealer = PlateLocSealer(backend=PlateLocSealerBackend(driver)) self._capabilities = [self.sealer] - async def set_sealing_temperature(self, temperature: float): - return await self.driver.set_sealing_temperature(temperature) - - async def set_sealing_time(self, duration: float): - return await self.driver.set_sealing_time(duration) - - def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: - return self.driver.status_snapshot(cycle_complete=cycle_complete) - - async def request_status(self, query_cycle_complete: bool = True) -> PlateLocStatus: - return await self.driver.request_status(query_cycle_complete=query_cycle_complete) - def serialize(self) -> dict: return { **super().serialize(), "name": self.name, - "status": dataclasses.asdict(self.status_snapshot()), + "status": dataclasses.asdict(self.sealer.status_snapshot()), } diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py index ae5eb51756e..fc8c6b84999 100644 --- a/pylabrobot/agilent/plateloc/plateloc_tests.py +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -3,12 +3,15 @@ import unittest from collections import deque from typing import Deque +from unittest.mock import patch +import pylabrobot.agilent.plateloc.plateloc as plateloc_module from pylabrobot.agilent.plateloc import ( - DEFAULT_PLATELOC_COMMANDS, PlateLoc, PlateLocDriver, PlateLocError, + PlateLocSealer, + PlateLocSealerBackend, PlateLocSerialProfile, PlateLocStatus, ) @@ -68,16 +71,38 @@ async def reset_input_buffer(self): class PlateLocTests(unittest.IsolatedAsyncioTestCase): - def make_driver(self, commands=None, ack_timeout=0.01, timeout=30): + @contextlib.contextmanager + def patch_serial(self): + with ( + patch.object(plateloc_module, "HAS_SERIAL", True), + patch.object(plateloc_module, "Serial", FakeSerial), + ): + yield + + def make_driver(self, ack_timeout=0.01, timeout=30): profile = PlateLocSerialProfile( response_timeout=0.01, ack_timeout=ack_timeout, read_delay=0, stage_move_delay=0, cycle_poll_interval=0, - commands=commands or DEFAULT_PLATELOC_COMMANDS, ) - return PlateLocDriver(port="COM6", profile=profile, timeout=timeout, serial_cls=FakeSerial) + with self.patch_serial(): + return PlateLocDriver(port="COM6", profile=profile, timeout=timeout) + + def make_device(self, timeout=30): + profile = PlateLocSerialProfile( + response_timeout=0.01, + ack_timeout=0.01, + read_delay=0, + stage_move_delay=0, + cycle_poll_interval=0, + ) + with self.patch_serial(): + return PlateLoc(name="plateloc", port="COM6", profile=profile, timeout=timeout) + + def backend(self, device: PlateLoc) -> PlateLocSealerBackend: + return device.sealer.backend async def test_setup_uses_plr_serial_wrapper_settings(self): driver = self.make_driver() @@ -95,90 +120,111 @@ async def test_setup_uses_plr_serial_wrapper_settings(self): await driver.stop() self.assertTrue(driver.io.stop_called) + async def test_driver_sends_literal_serial_frame(self): + driver = self.make_driver() + await driver.setup() + driver.io.queue_response(b"STAK\r") + + response = await driver.send_command("ST 0.030", timeout=0.01) + + self.assertEqual(response, "STAK") + self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) + self.assertTrue(driver.io.reset_input_buffer_called) + self.assertEqual(driver.last_command, "ST 0.030") + self.assertEqual(driver.last_response, "STAK") + async def test_temperature_and_time_writes_are_scaled_and_validated(self): driver = self.make_driver() + backend = PlateLocSealerBackend(driver) await driver.setup() driver.io.queue_response(b"STAK\r") driver.io.queue_response(b"SSAK\r") - await driver.set_sealing_temperature(30) - await driver.set_sealing_time(0.5) + await backend.set_sealing_temperature(30) + await backend.set_sealing_time(0.5) self.assertEqual(driver.io.writes, [b"ST 0.030\r", b"SS 0.05\r"]) with self.assertRaises(ValueError): - await driver.set_sealing_temperature(19) + await backend.set_sealing_temperature(19) with self.assertRaises(ValueError): - await driver.set_sealing_time(0.4) + await backend.set_sealing_time(0.4) async def test_negative_acknowledgement_raises_protocol_error(self): - driver = self.make_driver(ack_timeout=0.01) + driver = self.make_driver() + backend = PlateLocSealerBackend(driver) await driver.setup() driver.io.queue_response(b"STNK(Desired Temperature is Out of Range)\r\r") with self.assertRaisesRegex(PlateLocError, "Desired Temperature is Out of Range"): - await driver.set_sealing_temperature(30) + await backend.set_sealing_temperature(30) self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) async def test_missing_acknowledgement_raises_timeout(self): driver = self.make_driver() + backend = PlateLocSealerBackend(driver) await driver.setup() with self.assertRaisesRegex(TimeoutError, "Timeout"): - await driver.set_sealing_temperature(30) + await backend.set_sealing_temperature(30) self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) async def test_malformed_acknowledgement_raises_protocol_error(self): driver = self.make_driver() + backend = PlateLocSealerBackend(driver) await driver.setup() driver.io.queue_response(b"unexpected\r") with self.assertRaisesRegex(PlateLocError, "invalid response"): - await driver.set_sealing_temperature(30) + await backend.set_sealing_temperature(30) self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) async def test_required_response_reads_until_plate_loc_ack(self): driver = self.make_driver() + backend = PlateLocSealerBackend(driver) await driver.setup() driver.io.queue_response(b"CCAK\r") - self.assertTrue(await driver.check_cycle_complete()) + self.assertTrue(await backend.check_cycle_complete()) self.assertEqual(driver.io.writes, [b"CC 00\r"]) async def test_cycle_not_complete_returns_false(self): driver = self.make_driver() + backend = PlateLocSealerBackend(driver) await driver.setup() driver.io.queue_response(b"CCNK\r") - self.assertFalse(await driver.check_cycle_complete()) + self.assertFalse(await backend.check_cycle_complete()) self.assertEqual(driver.io.writes, [b"CC 00\r"]) async def test_invalid_cycle_complete_response_raises_protocol_error(self): driver = self.make_driver() + backend = PlateLocSealerBackend(driver) await driver.setup() driver.io.queue_response(b"unexpected\r") with self.assertRaisesRegex(PlateLocError, "invalid response"): - await driver.check_cycle_complete() + await backend.check_cycle_complete() self.assertEqual(driver.io.writes, [b"CC 00\r"]) async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): driver = self.make_driver() + backend = PlateLocSealerBackend(driver) await driver.setup() driver.io.queue_response(b"STAK\r") driver.io.queue_response(b"SSAK\r") driver.io.queue_response(b"SOAK\r") - await driver.set_sealing_temperature(30) - await driver.set_sealing_time(0.5) - await driver.move_stage_out() + await backend.set_sealing_temperature(30) + await backend.set_sealing_time(0.5) + await backend.move_stage_out() driver.io.queue_response(b"CCAK\r") - status = await driver.request_status() + status = await backend.request_status() self.assertIsInstance(status, PlateLocStatus) self.assertEqual(status.port, "COM6") @@ -187,52 +233,13 @@ async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): self.assertEqual(status.sealing_time, 0.5) self.assertEqual(status.stage_position, "open") self.assertTrue(status.cycle_complete) - self.assertEqual(status.last_command, "check_cycle_complete") + self.assertEqual(status.last_command, "CC 00") self.assertEqual(status.last_response, "CCAK") self.assertEqual(driver.io.writes, [b"ST 0.030\r", b"SS 0.05\r", b"SO 00\r", b"CC 00\r"]) - async def test_custom_command_profile(self): - driver = self.make_driver( - commands={ - "set_sealing_temperature": "TP", - "set_sealing_time": "TM", - } - ) - await driver.setup() - driver.io.queue_response(b"TPAK\r") - driver.io.queue_response(b"TMAK\r") - - await driver.set_sealing_temperature(120) - await driver.set_sealing_time(1.25) - - self.assertEqual(driver.io.writes, [b"TP 0.120\r", b"TM 0.12\r"]) - - async def test_custom_command_acknowledgement_codes_are_parsed(self): - commands = { - **DEFAULT_PLATELOC_COMMANDS, - "set_sealing_temperature": "TP", - "check_cycle_complete": "CP", - } - driver = self.make_driver(commands=commands, ack_timeout=0.01) - await driver.setup() - driver.io.queue_response(b"TPNK(Desired Temperature is Out of Range)\r") - - with self.assertRaisesRegex(PlateLocError, "Desired Temperature is Out of Range"): - await driver.set_sealing_temperature(120) - - driver.io.queue_response(b"CPAK\r") - self.assertTrue(await driver.check_cycle_complete()) - self.assertEqual(driver.io.writes, [b"TP 0.120\r", b"CP 00\r"]) - async def test_seal_waits_for_cycle_completion(self): - profile = PlateLocSerialProfile( - response_timeout=0.01, - ack_timeout=0.01, - read_delay=0, - stage_move_delay=0, - cycle_poll_interval=0, - ) - device = PlateLoc(name="plateloc", port="COM6", profile=profile, timeout=1, serial_cls=FakeSerial) + device = self.make_device(timeout=1) + backend = self.backend(device) await device.setup() device.driver.io.queue_response(b"STAK\r") @@ -253,22 +260,17 @@ async def test_seal_waits_for_cycle_completion(self): b"CC 00\r", ], ) + self.assertEqual(backend.status_snapshot().target_temperature, 120) + self.assertEqual(backend.status_snapshot().sealing_time, 1.2) - async def test_device_exposes_sealer_capability(self): - profile = PlateLocSerialProfile( - response_timeout=0.01, - ack_timeout=0.01, - read_delay=0, - stage_move_delay=0, - cycle_poll_interval=0, - ) - device = PlateLoc(name="plateloc", port="COM6", profile=profile, serial_cls=FakeSerial) + async def test_device_exposes_plate_loc_sealer_capability(self): + device = self.make_device() await device.setup() device.driver.io.queue_response(b"STAK\r") - await device.set_sealing_temperature(100) + await device.sealer.set_sealing_temperature(100) device.driver.io.queue_response(b"SSAK\r") - await device.set_sealing_time(0.5) + await device.sealer.set_sealing_time(0.5) device.driver.io.queue_response(b"STAK\r") device.driver.io.queue_response(b"SSAK\r") device.driver.io.queue_response(b"GOAK\r") @@ -278,9 +280,13 @@ async def test_device_exposes_sealer_capability(self): await device.sealer.open() device.driver.io.queue_response(b"SIAK\r") await device.sealer.close() - status = device.status_snapshot() + device.driver.io.queue_response(b"CCAK\r") + status = await device.sealer.request_status() await device.stop() + self.assertIsInstance(device.sealer, PlateLocSealer) + self.assertFalse(hasattr(device, "set_sealing_temperature")) + self.assertFalse(hasattr(device, "set_sealing_time")) self.assertEqual( device.driver.io.writes, [ @@ -292,11 +298,13 @@ async def test_device_exposes_sealer_capability(self): b"CC 00\r", b"SO 00\r", b"SI 00\r", + b"CC 00\r", ], ) self.assertEqual(status.target_temperature, 120) self.assertEqual(status.sealing_time, 1.2) self.assertEqual(status.stage_position, "closed") + self.assertTrue(status.cycle_complete) self.assertTrue(device.driver.io.stop_called) From ebe6fa392d86d6485da0a26da8fd8144543a3372 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 27 Aug 2026 16:56:24 -0700 Subject: [PATCH 08/10] Adapt PlateLoc to the current device API --- docs/_static/devices.json | 14 + docs/api/pylabrobot.agilent.rst | 3 - docs/user_guide/agilent/index.md | 1 - .../agilent/plateloc/hello-world.ipynb | 268 +++++++++++++----- pylabrobot/agilent/__init__.py | 3 - pylabrobot/agilent/plateloc/__init__.py | 3 - pylabrobot/agilent/plateloc/plateloc.py | 221 ++++++--------- pylabrobot/agilent/plateloc/plateloc_tests.py | 240 ++++++++-------- 8 files changed, 413 insertions(+), 340 deletions(-) diff --git a/docs/_static/devices.json b/docs/_static/devices.json index 16eade89a54..4dfbc20060a 100644 --- a/docs/_static/devices.json +++ b/docs/_static/devices.json @@ -15,6 +15,20 @@ "manager": "https://discuss.pylabrobot.org/u/rickwierenga", "notes": "BenchCel 4R four-stacker configuration; protocol verified with firmware 3.2.20.0." }, + { + "id": "agilent-plateloc", + "vendor": "Agilent", + "name": "PlateLoc", + "kind": "sealer", + "capabilities": [ + "sealing" + ], + "status": "mostly", + "api": "pylabrobot.agilent.PlateLoc", + "api_version": "v1", + "code_slug": "agilent/plateloc", + "doc_slug": "agilent/plateloc/hello-world" + }, { "id": "agilent-vspin", "vendor": "Agilent", diff --git a/docs/api/pylabrobot.agilent.rst b/docs/api/pylabrobot.agilent.rst index ce31bc7b03f..b67cf81071c 100644 --- a/docs/api/pylabrobot.agilent.rst +++ b/docs/api/pylabrobot.agilent.rst @@ -112,9 +112,6 @@ PlateLoc :recursive: PlateLoc - PlateLocSealer - PlateLocSealerBackend - PlateLocDriver PlateLocSerialProfile PlateLocStatus PlateLocError diff --git a/docs/user_guide/agilent/index.md b/docs/user_guide/agilent/index.md index d9baefcb4a1..f13fe0794b9 100644 --- a/docs/user_guide/agilent/index.md +++ b/docs/user_guide/agilent/index.md @@ -4,7 +4,6 @@ :maxdepth: 1 benchcel/hello-world -biotek/index plateloc/hello-world vspin/index ``` diff --git a/docs/user_guide/agilent/plateloc/hello-world.ipynb b/docs/user_guide/agilent/plateloc/hello-world.ipynb index 246a6b5a63d..ca676b06eb6 100644 --- a/docs/user_guide/agilent/plateloc/hello-world.ipynb +++ b/docs/user_guide/agilent/plateloc/hello-world.ipynb @@ -3,142 +3,272 @@ { "cell_type": "markdown", "id": "plateloc-intro", - "source": "# Agilent PlateLoc\n\nThe Agilent PlateLoc is controlled through PLR's `Sealer` capability with a direct RS-232 serial driver. It does not require Agilent ActiveX, VWorks, or vendor server software. Install the optional serial dependency before connecting:\n\n```bash\npip install \"pylabrobot[serial]\"\n```", - "metadata": {} + "metadata": {}, + "source": [ + "# Agilent PlateLoc\n", + "\n", + "```{device-card} agilent-plateloc\n", + "```\n", + "\n", + "| Property | Value |\n", + "|---|---|\n", + "| Connection | RS-232 serial, 19200 8N1 |\n", + "| Default port example | `COM6` |\n", + "| PLR extra | `serial` |\n", + "\n", + "The PlateLoc uses direct RS-232 control and does not require Agilent ActiveX, VWorks, or vendor server software. Install the optional serial dependency before connecting:\n", + "\n", + "```bash\n", + "pip install \"pylabrobot[serial]\"\n", + "```" + ] }, { - "cell_type": "code", - "id": "plateloc-import", - "source": "from pylabrobot.agilent import PlateLoc\n\nplateloc = PlateLoc(name=\"plateloc\", port=\"COM6\")", + "cell_type": "markdown", + "id": "plateloc-create-heading", "metadata": {}, + "source": [ + "## Create the driver\n", + "\n", + "Use the serial port connected to the PlateLoc." + ] + }, + { + "cell_type": "code", "execution_count": null, - "outputs": [] + "id": "plateloc-create", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent import PlateLoc\n", + "\n", + "plateloc = PlateLoc(port=\"COM6\")" + ] + }, + { + "cell_type": "markdown", + "id": "plateloc-physical-setup", + "metadata": {}, + "source": [ + "## Physical setup\n", + "\n", + "Connect a USB-to-RS-232 adapter and the correct DB9 cable, power on the PlateLoc, and confirm that the stage is clear before opening the connection." + ] + }, + { + "cell_type": "markdown", + "id": "plateloc-setup-heading", + "metadata": {}, + "source": [ + "## Connect" + ] }, { "cell_type": "code", + "execution_count": null, "id": "plateloc-setup", - "source": "await plateloc.setup()", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "await plateloc.setup()" + ] }, { - "cell_type": "code", - "id": "plateloc-set-temp", - "source": "await plateloc.sealer.set_sealing_temperature(175)", + "cell_type": "markdown", + "id": "plateloc-temperature-heading", "metadata": {}, - "execution_count": null, - "outputs": [] + "source": [ + "## Set the sealing temperature\n", + "\n", + "The accepted range is 20–235 °C." + ] }, { "cell_type": "code", - "id": "plateloc-set-time", - "source": "await plateloc.sealer.set_sealing_time(0.5)", - "metadata": {}, "execution_count": null, - "outputs": [] + "id": "plateloc-temperature", + "metadata": {}, + "outputs": [], + "source": [ + "await plateloc.set_sealing_temperature(175)" + ] }, { - "cell_type": "code", - "id": "plateloc-status-first", - "source": "status = await plateloc.sealer.request_status()", + "cell_type": "markdown", + "id": "plateloc-time-heading", "metadata": {}, + "source": [ + "## Set the sealing time\n", + "\n", + "The accepted range is 0.5–12.0 seconds." + ] + }, + { + "cell_type": "code", "execution_count": null, - "outputs": [] + "id": "plateloc-time", + "metadata": {}, + "outputs": [], + "source": [ + "await plateloc.set_sealing_time(0.5)" + ] }, { "cell_type": "markdown", - "id": "plateloc-sealer-intro", - "source": "The device also exposes the standard sealer capability:", - "metadata": {} + "id": "plateloc-status-heading", + "metadata": {}, + "source": [ + "## Read status\n", + "\n", + "`request_status()` returns the last successfully written setpoints plus a live cycle-complete query. The direct protocol does not expose the actual block temperature or stored sealing time." + ] }, { "cell_type": "code", - "id": "plateloc-seal", - "source": "await plateloc.sealer.seal(temperature=175, duration=1.5)", - "metadata": {}, "execution_count": null, - "outputs": [] + "id": "plateloc-status", + "metadata": {}, + "outputs": [], + "source": [ + "status = await plateloc.request_status()\n", + "print(status.target_temperature, status.sealing_time, status.cycle_complete)" + ] }, { - "cell_type": "code", - "id": "plateloc-open", - "source": "await plateloc.sealer.open()", + "cell_type": "markdown", + "id": "plateloc-seal-heading", "metadata": {}, - "execution_count": null, - "outputs": [] + "source": [ + "## Seal a plate\n", + "\n", + "Place the plate and sealing material as required by the instrument, then set the temperature and duration and wait for the cycle to finish." + ] }, { "cell_type": "code", - "id": "plateloc-close", - "source": "await plateloc.sealer.close()", - "metadata": {}, "execution_count": null, - "outputs": [] + "id": "plateloc-seal", + "metadata": {}, + "outputs": [], + "source": [ + "await plateloc.seal(temperature=175, duration=1.5)" + ] }, { "cell_type": "markdown", - "id": "plateloc-sealer-notes", - "source": "`sealer.open()` and `sealer.close()` move the stage and wait for the default stage-settle delay. `sealer.seal()` starts a sealing cycle after writing the requested temperature and time.\n\nThe PlateLoc sealer capability also exposes independent setpoint and status helpers:", - "metadata": {} + "id": "plateloc-open-heading", + "metadata": {}, + "source": [ + "## Open the stage\n", + "\n", + "Make sure the stage has room to move." + ] }, { "cell_type": "code", - "id": "plateloc-set-temp-160", - "source": "await plateloc.sealer.set_sealing_temperature(160)", - "metadata": {}, "execution_count": null, - "outputs": [] + "id": "plateloc-open", + "metadata": {}, + "outputs": [], + "source": [ + "await plateloc.open()" + ] }, { - "cell_type": "code", - "id": "plateloc-set-time-1", - "source": "await plateloc.sealer.set_sealing_time(1.0)", + "cell_type": "markdown", + "id": "plateloc-close-heading", "metadata": {}, - "execution_count": null, - "outputs": [] + "source": [ + "## Close the stage\n", + "\n", + "Remove or place the plate before closing the stage." + ] }, { "cell_type": "code", - "id": "plateloc-status-print", - "source": "status = await plateloc.sealer.request_status()\nprint(status.target_temperature, status.sealing_time, status.cycle_complete)", - "metadata": {}, "execution_count": null, - "outputs": [] + "id": "plateloc-close", + "metadata": {}, + "outputs": [], + "source": [ + "await plateloc.close()" + ] }, { "cell_type": "markdown", - "id": "plateloc-status-notes", - "source": "`request_status()` returns the best-known PLR state plus a live cycle-complete query. The direct serial protocol decoded here does not expose actual block temperature or actual stored time reads, so PLR reports the last successfully written target temperature and sealing time.", - "metadata": {} + "id": "plateloc-stop-heading", + "metadata": {}, + "source": [ + "## Disconnect" + ] }, { "cell_type": "code", + "execution_count": null, "id": "plateloc-stop", - "source": "await plateloc.stop()", "metadata": {}, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "await plateloc.stop()" + ] }, { "cell_type": "markdown", - "id": "plateloc-protocol", - "source": "## Serial command profile\n\nThe decoded direct protocol uses `19200 8N1` and carriage-return-terminated ASCII frames with two-letter command codes plus payloads. Temperature and time payloads use the firmware's fractional setpoint convention: the digits after the decimal point are the integer controller value.\n\n| Operation | Frame |\n|---|---|\n| Set sealing temperature | `ST 0.{temperature_celsius:03d}\\r` |\n| Set sealing time | `SS 0.{seconds_x10:02d}\\r` |\n| Start cycle | `GO 00\\r` |\n| Stop cycle | `AC 00\\r` |\n| Move stage out | `SO 00\\r` |\n| Move stage in | `SI 00\\r` |\n| Apply seal | `AS 00\\r` |\n| Clear error | `CL 00\\r` |\n| Check cycle complete | `CC 00\\r` |\n\nFor example, `set_sealing_temperature(175)` writes `ST 0.175\\r`, `set_sealing_temperature(30)` writes `ST 0.030\\r`, `set_sealing_time(0.5)` writes `SS 0.05\\r`, and `set_sealing_time(1.2)` writes `SS 0.12\\r`.\n\nNegative acknowledgements are parsed as `NK(message)` and raised as `PlateLocError`. Some valid firmware commands reply with single-carriage-return acknowledgements such as `SOAK\\r`. The cycle-complete command returns `True` for `CCAK\\r` and `False` for `CCNK\\r`.\n\nYou can still override serial settings and timing with `PlateLocSerialProfile` while keeping the same PLR frontend:", - "metadata": {} + "id": "plateloc-profile-heading", + "metadata": {}, + "source": [ + "## Serial command profile\n", + "\n", + "The default profile uses `19200 8N1` and carriage-return-terminated ASCII frames. Temperature and time payloads use the firmware fractional-setpoint convention: the digits after the decimal point are the integer controller value.\n", + "\n", + "| Operation | Frame |\n", + "|---|---|\n", + "| Set sealing temperature | `ST 0.{temperature:03d}\\r` |\n", + "| Set sealing time | `SS 0.{seconds_x10:02d}\\r` |\n", + "| Start cycle | `GO 00\\r` |\n", + "| Stop cycle | `AC 00\\r` |\n", + "| Move stage out | `SO 00\\r` |\n", + "| Move stage in | `SI 00\\r` |\n", + "| Apply seal | `AS 00\\r` |\n", + "| Clear error | `CL 00\\r` |\n", + "| Check cycle complete | `CC 00\\r` |\n", + "\n", + "For example, `set_sealing_temperature(175)` writes `ST 0.175\\r`, and `set_sealing_time(1.2)` writes `SS 0.12\\r`. Negative acknowledgements have the form `NK(message)` and raise `PlateLocError`." + ] }, { - "cell_type": "code", - "id": "plateloc-profile", - "source": "from pylabrobot.agilent import PlateLoc, PlateLocSerialProfile\n\nprofile = PlateLocSerialProfile(\n baudrate=19200,\n stage_move_delay=6,\n)\n\nplateloc = PlateLoc(name=\"plateloc\", port=\"COM6\", profile=profile)", + "cell_type": "markdown", + "id": "plateloc-profile-create-heading", "metadata": {}, + "source": [ + "Override serial settings and timing by passing a `PlateLocSerialProfile`." + ] + }, + { + "cell_type": "code", "execution_count": null, - "outputs": [] + "id": "plateloc-profile-create", + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.agilent import PlateLoc, PlateLocSerialProfile\n", + "\n", + "profile = PlateLocSerialProfile(\n", + " baudrate=19200,\n", + " stage_move_delay=6,\n", + ")\n", + "plateloc = PlateLoc(port=\"COM6\", profile=profile)" + ] }, { "cell_type": "markdown", "id": "plateloc-troubleshooting", - "source": "## Troubleshooting\n\nThe PlateLoc RS-232 connector is not VGA and is not USB TTL. Use a USB-to-RS-232 adapter plus the correct DB9 cable for the instrument. If the port opens but every command times out, verify the PlateLoc is powered, the rear serial cable is seated, and the cable wiring matches the instrument requirement. Some setups require a null-modem DB9 adapter rather than a straight-through cable.", - "metadata": {} + "metadata": {}, + "source": [ + "## Troubleshooting\n", + "\n", + "The PlateLoc RS-232 connector is not VGA and is not USB TTL. Use a USB-to-RS-232 adapter plus the correct DB9 cable for the instrument. If the port opens but every command times out, verify that the PlateLoc is powered, the rear serial cable is seated, and the wiring matches the instrument requirement. Some setups require a null-modem DB9 adapter rather than a straight-through cable." + ] } ], "metadata": { diff --git a/pylabrobot/agilent/__init__.py b/pylabrobot/agilent/__init__.py index 9104a0ad44c..ab682a6f603 100644 --- a/pylabrobot/agilent/__init__.py +++ b/pylabrobot/agilent/__init__.py @@ -8,10 +8,7 @@ ) from .plateloc import ( PlateLoc, - PlateLocDriver, PlateLocError, - PlateLocSealer, - PlateLocSealerBackend, PlateLocSerialProfile, PlateLocStatus, ) diff --git a/pylabrobot/agilent/plateloc/__init__.py b/pylabrobot/agilent/plateloc/__init__.py index dc194e38b7d..62cbee7810b 100644 --- a/pylabrobot/agilent/plateloc/__init__.py +++ b/pylabrobot/agilent/plateloc/__init__.py @@ -1,9 +1,6 @@ from .plateloc import ( PlateLoc, - PlateLocDriver, PlateLocError, - PlateLocSealer, - PlateLocSealerBackend, PlateLocSerialProfile, PlateLocStatus, ) diff --git a/pylabrobot/agilent/plateloc/plateloc.py b/pylabrobot/agilent/plateloc/plateloc.py index 152d01bdc8f..b941deb4cf4 100644 --- a/pylabrobot/agilent/plateloc/plateloc.py +++ b/pylabrobot/agilent/plateloc/plateloc.py @@ -5,11 +5,8 @@ import logging import re import time -from typing import Optional, cast +from typing import Literal, Optional, cast -from pylabrobot.capabilities.capability import BackendParams, need_capability_ready -from pylabrobot.capabilities.sealing import Sealer, SealerBackend -from pylabrobot.device import Device, Driver from pylabrobot.io.serial import Serial try: @@ -38,7 +35,7 @@ class PlateLocStatus: connected: bool target_temperature: Optional[float] sealing_time: Optional[float] - stage_position: Optional[str] + stage_position: Optional[Literal["open", "closed"]] cycle_complete: Optional[bool] last_command: Optional[str] last_response: Optional[str] @@ -71,6 +68,7 @@ class PlateLocSerialProfile: response_terminator: bytes = b"\r" def serialize(self) -> dict: + """Serialize this profile to JSON-compatible values.""" return { "baudrate": self.baudrate, "bytesize": self.bytesize, @@ -92,14 +90,25 @@ def serialize(self) -> dict: @classmethod def deserialize(cls, data: dict) -> "PlateLocSerialProfile": + """Deserialize a profile produced by :meth:`serialize`.""" data = data.copy() if "response_terminator" in data: data["response_terminator"] = data["response_terminator"].encode("latin1") return cls(**data) -class PlateLocDriver(Driver): - """Direct serial transport for the Agilent PlateLoc thermal microplate sealer.""" +class PlateLoc: + """Direct serial driver for the Agilent PlateLoc thermal microplate sealer.""" + + _SET_TEMPERATURE = "ST" + _SET_TIME = "SS" + _MOVE_STAGE_OUT = "SO" + _MOVE_STAGE_IN = "SI" + _START_CYCLE = "GO" + _STOP_CYCLE = "AC" + _APPLY_SEAL = "AS" + _CLEAR_ERROR = "CL" + _CHECK_CYCLE_COMPLETE = "CC" def __init__( self, @@ -109,7 +118,6 @@ def __init__( profile: Optional[PlateLocSerialProfile | dict] = None, timeout: float = 30, ) -> None: - super().__init__() if not HAS_SERIAL: raise RuntimeError( "pyserial is not installed. Install with: pip install pylabrobot[serial]. " @@ -120,6 +128,9 @@ def __init__( self.profile = profile or PlateLocSerialProfile() self.timeout = timeout self._connected = False + self._target_temperature: Optional[float] = None + self._sealing_time: Optional[float] = None + self._stage_position: Optional[Literal["open", "closed"]] = None self._last_command: Optional[str] = None self._last_response: Optional[str] = None self.io = Serial( @@ -140,26 +151,32 @@ def __init__( @property def port(self) -> str: + """The configured or detected serial port.""" return cast(str, self.io.port) @property def connected(self) -> bool: + """Whether :meth:`setup` has completed without a subsequent :meth:`stop`.""" return self._connected @property def last_command(self) -> Optional[str]: + """The last command sent, without its carriage-return terminator.""" return self._last_command @property def last_response(self) -> Optional[str]: + """The last response received, without surrounding whitespace.""" return self._last_response - async def setup(self, backend_params: Optional[BackendParams] = None): + async def setup(self) -> None: + """Open the serial connection.""" await self.io.setup() self._connected = True logger.info("[PlateLoc %s] connected", self.port) - async def stop(self): + async def stop(self) -> None: + """Close the serial connection.""" await self.io.stop() self._connected = False logger.info("[PlateLoc %s] disconnected", self.port) @@ -189,10 +206,14 @@ async def read_response( timeout: Optional[float] = None, required: bool = True, ) -> Optional[str]: - deadline = time.time() + (timeout if timeout is not None else self.profile.response_timeout) + """Read one carriage-return-terminated response.""" + deadline = time.monotonic() + ( + timeout if timeout is not None else self.profile.response_timeout + ) chunks = bytearray() - while time.time() < deadline: - with self.io.temporary_timeout(max(0.01, min(0.1, deadline - time.time()))): + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + with self.io.temporary_timeout(max(0.01, min(0.1, remaining))): chunk = await self.io.read(1) if chunk: chunks.extend(chunk) @@ -207,38 +228,6 @@ async def read_response( return None return bytes(chunks).decode("utf-8", errors="replace").strip() - def serialize(self) -> dict: - return { - **super().serialize(), - "port": self.port, - "profile": self.profile.serialize(), - "timeout": self.timeout, - } - - -class PlateLocSealerBackend(SealerBackend): - """Translates SealerBackend operations into direct PlateLoc serial commands.""" - - _SET_TEMPERATURE = "ST" - _SET_TIME = "SS" - _MOVE_STAGE_OUT = "SO" - _MOVE_STAGE_IN = "SI" - _START_CYCLE = "GO" - _STOP_CYCLE = "AC" - _APPLY_SEAL = "AS" - _CLEAR_ERROR = "CL" - _CHECK_CYCLE_COMPLETE = "CC" - - def __init__(self, driver: PlateLocDriver): - self._driver = driver - self._target_temperature: Optional[float] = None - self._sealing_time: Optional[float] = None - self._stage_position: Optional[str] = None - - @property - def driver(self) -> PlateLocDriver: - return self._driver - def _parse_response(self, command_code: str, response: str) -> re.Match[str]: match = _ACK_RE.match(response) if match is None: @@ -261,9 +250,9 @@ async def _send( timeout: Optional[float] = None, raise_on_nak: bool = True, ) -> str: - response = await self._driver.send_command( + response = await self.send_command( command, - timeout=timeout if timeout is not None else self._driver.profile.ack_timeout, + timeout=timeout if timeout is not None else self.profile.ack_timeout, required=True, ) assert response is not None @@ -271,160 +260,124 @@ async def _send( self._raise_for_error(command[:2], response) return response - async def set_sealing_temperature(self, temperature: float): + async def set_sealing_temperature(self, temperature: float) -> str: + """Set the sealing target temperature in degrees C.""" if not (20 <= temperature <= 235): raise ValueError("Temperature out of range. Please enter a value between 20 and 235 C.") target_temperature = round(temperature) - logger.info( - "[PlateLoc %s] setting sealing temperature to %.1f C", - self._driver.port, - temperature, - ) + logger.info("[PlateLoc %s] setting sealing temperature to %.1f C", self.port, temperature) response = await self._send(f"{self._SET_TEMPERATURE} 0.{target_temperature:03d}") self._target_temperature = float(target_temperature) return response - async def set_sealing_time(self, duration: float): + async def set_sealing_time(self, duration: float) -> str: + """Set the sealing duration in seconds.""" if not (0.5 <= duration <= 12.0): raise ValueError("Duration out of range. Please enter a value between 0.5 and 12.0 s.") sealing_time_deciseconds = round(duration * 10) - logger.info("[PlateLoc %s] setting sealing time to %.2f s", self._driver.port, duration) + logger.info("[PlateLoc %s] setting sealing time to %.2f s", self.port, duration) response = await self._send(f"{self._SET_TIME} 0.{sealing_time_deciseconds:02d}") self._sealing_time = sealing_time_deciseconds / 10 return response - async def move_stage_out(self): - logger.info("[PlateLoc %s] moving stage out", self._driver.port) + async def move_stage_out(self) -> str: + """Move the plate stage to its open position.""" + logger.info("[PlateLoc %s] moving stage out", self.port) response = await self._send(f"{self._MOVE_STAGE_OUT} 00") - if self._driver.profile.stage_move_delay > 0: - await asyncio.sleep(self._driver.profile.stage_move_delay) + if self.profile.stage_move_delay > 0: + await asyncio.sleep(self.profile.stage_move_delay) self._stage_position = "open" return response - async def move_stage_in(self): - logger.info("[PlateLoc %s] moving stage in", self._driver.port) + async def move_stage_in(self) -> str: + """Move the plate stage to its closed position.""" + logger.info("[PlateLoc %s] moving stage in", self.port) response = await self._send(f"{self._MOVE_STAGE_IN} 00") - if self._driver.profile.stage_move_delay > 0: - await asyncio.sleep(self._driver.profile.stage_move_delay) + if self.profile.stage_move_delay > 0: + await asyncio.sleep(self.profile.stage_move_delay) self._stage_position = "closed" return response - async def start_cycle(self): - logger.info("[PlateLoc %s] starting sealing cycle", self._driver.port) + async def start_cycle(self) -> str: + """Start a sealing cycle with the current setpoints.""" + logger.info("[PlateLoc %s] starting sealing cycle", self.port) return await self._send(f"{self._START_CYCLE} 00") - async def stop_cycle(self): - logger.info("[PlateLoc %s] stopping sealing cycle", self._driver.port) + async def stop_cycle(self) -> str: + """Stop the active sealing cycle.""" + logger.info("[PlateLoc %s] stopping sealing cycle", self.port) return await self._send(f"{self._STOP_CYCLE} 00") - async def apply_seal(self): - logger.info("[PlateLoc %s] applying seal", self._driver.port) + async def apply_seal(self) -> str: + """Apply the current seal.""" + logger.info("[PlateLoc %s] applying seal", self.port) return await self._send(f"{self._APPLY_SEAL} 00") - async def clear_error(self): - logger.info("[PlateLoc %s] clearing error", self._driver.port) + async def clear_error(self) -> str: + """Clear the active PlateLoc error.""" + logger.info("[PlateLoc %s] clearing error", self.port) return await self._send(f"{self._CLEAR_ERROR} 00") async def check_cycle_complete(self) -> bool: + """Return whether the current sealing cycle is complete.""" response = await self._send( f"{self._CHECK_CYCLE_COMPLETE} 00", - timeout=self._driver.profile.response_timeout, + timeout=self.profile.response_timeout, raise_on_nak=False, ) match = self._parse_response(self._CHECK_CYCLE_COMPLETE, response) return match.group("status") == "A" async def wait_for_cycle_complete(self, timeout: Optional[float] = None) -> bool: - deadline = time.time() + (self._driver.timeout if timeout is None else timeout) + """Wait until the current sealing cycle completes.""" + deadline = time.monotonic() + (self.timeout if timeout is None else timeout) while True: if await self.check_cycle_complete(): return True - remaining = deadline - time.time() + remaining = deadline - time.monotonic() if remaining <= 0: raise TimeoutError("Timeout while waiting for PlateLoc cycle to complete") - await asyncio.sleep(min(max(self._driver.profile.cycle_poll_interval, 0), remaining)) + await asyncio.sleep(min(max(self.profile.cycle_poll_interval, 0), remaining)) def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: + """Return the locally tracked state without communicating with the device.""" return PlateLocStatus( - port=self._driver.port, - connected=self._driver.connected, + port=self.port, + connected=self.connected, target_temperature=self._target_temperature, sealing_time=self._sealing_time, stage_position=self._stage_position, cycle_complete=cycle_complete, - last_command=self._driver.last_command, - last_response=self._driver.last_response, + last_command=self.last_command, + last_response=self.last_response, ) async def request_status(self, query_cycle_complete: bool = True) -> PlateLocStatus: + """Return locally tracked state, optionally querying cycle completion.""" cycle_complete = await self.check_cycle_complete() if query_cycle_complete else None return self.status_snapshot(cycle_complete=cycle_complete) - async def seal(self, temperature: int, duration: float): + async def seal(self, temperature: int, duration: float) -> str: + """Seal a plate at the requested temperature and duration.""" await self.set_sealing_temperature(temperature) await self.set_sealing_time(duration) response = await self.start_cycle() await self.wait_for_cycle_complete() return response - async def open(self): + async def open(self) -> str: + """Move the plate stage to its open position.""" return await self.move_stage_out() - async def close(self): + async def close(self) -> str: + """Move the plate stage to its closed position.""" return await self.move_stage_in() - -class PlateLocSealer(Sealer): - """PlateLoc-specific sealing capability.""" - - def __init__(self, backend: PlateLocSealerBackend): - super().__init__(backend=backend) - self.backend: PlateLocSealerBackend = backend - - @need_capability_ready - async def set_sealing_temperature(self, temperature: float): - return await self.backend.set_sealing_temperature(temperature) - - @need_capability_ready - async def set_sealing_time(self, duration: float): - return await self.backend.set_sealing_time(duration) - - @need_capability_ready - async def request_status(self, query_cycle_complete: bool = True) -> PlateLocStatus: - return await self.backend.request_status(query_cycle_complete=query_cycle_complete) - - def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: - return self.backend.status_snapshot(cycle_complete=cycle_complete) - - -class PlateLoc(Device): - """Agilent PlateLoc thermal microplate sealer.""" - - def __init__( - self, - name: str, - port: Optional[str] = None, - vid: Optional[int] = None, - pid: Optional[int] = None, - profile: Optional[PlateLocSerialProfile | dict] = None, - timeout: float = 30, - ): - self.name = name - driver = PlateLocDriver( - port=port, - vid=vid, - pid=pid, - profile=profile, - timeout=timeout, - ) - super().__init__(driver=driver) - self.driver: PlateLocDriver = driver - self.sealer: PlateLocSealer = PlateLocSealer(backend=PlateLocSealerBackend(driver)) - self._capabilities = [self.sealer] - def serialize(self) -> dict: + """Serialize the configured connection and locally tracked state.""" return { - **super().serialize(), - "name": self.name, - "status": dataclasses.asdict(self.sealer.status_snapshot()), + "port": self.port, + "profile": self.profile.serialize(), + "timeout": self.timeout, + "status": dataclasses.asdict(self.status_snapshot()), } diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py index fc8c6b84999..830ad172c58 100644 --- a/pylabrobot/agilent/plateloc/plateloc_tests.py +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -2,16 +2,13 @@ import contextlib import unittest from collections import deque -from typing import Deque +from typing import Deque, cast from unittest.mock import patch import pylabrobot.agilent.plateloc.plateloc as plateloc_module from pylabrobot.agilent.plateloc import ( PlateLoc, - PlateLocDriver, PlateLocError, - PlateLocSealer, - PlateLocSealerBackend, PlateLocSerialProfile, PlateLocStatus, ) @@ -79,7 +76,7 @@ def patch_serial(self): ): yield - def make_driver(self, ack_timeout=0.01, timeout=30): + def make_device(self, ack_timeout: float = 0.01, timeout: float = 30) -> PlateLoc: profile = PlateLocSerialProfile( response_timeout=0.01, ack_timeout=ack_timeout, @@ -88,143 +85,134 @@ def make_driver(self, ack_timeout=0.01, timeout=30): cycle_poll_interval=0, ) with self.patch_serial(): - return PlateLocDriver(port="COM6", profile=profile, timeout=timeout) + return PlateLoc(port="COM6", profile=profile, timeout=timeout) - def make_device(self, timeout=30): - profile = PlateLocSerialProfile( - response_timeout=0.01, - ack_timeout=0.01, - read_delay=0, - stage_move_delay=0, - cycle_poll_interval=0, - ) - with self.patch_serial(): - return PlateLoc(name="plateloc", port="COM6", profile=profile, timeout=timeout) - - def backend(self, device: PlateLoc) -> PlateLocSealerBackend: - return device.sealer.backend + def fake_io(self, device: PlateLoc) -> FakeSerial: + return cast(FakeSerial, device.io) async def test_setup_uses_plr_serial_wrapper_settings(self): - driver = self.make_driver() + device = self.make_device() + io = self.fake_io(device) - await driver.setup() + await device.setup() - self.assertTrue(driver.io.setup_called) - self.assertEqual(driver.io.kwargs["human_readable_device_name"], "Agilent PlateLoc Sealer") - self.assertEqual(driver.io.kwargs["port"], "COM6") - self.assertEqual(driver.io.kwargs["baudrate"], 19200) - self.assertEqual(driver.io.kwargs["bytesize"], 8) - self.assertEqual(driver.io.kwargs["parity"], "N") - self.assertEqual(driver.io.kwargs["stopbits"], 1) + self.assertTrue(io.setup_called) + self.assertEqual(io.kwargs["human_readable_device_name"], "Agilent PlateLoc Sealer") + self.assertEqual(io.kwargs["port"], "COM6") + self.assertEqual(io.kwargs["baudrate"], 19200) + self.assertEqual(io.kwargs["bytesize"], 8) + self.assertEqual(io.kwargs["parity"], "N") + self.assertEqual(io.kwargs["stopbits"], 1) - await driver.stop() - self.assertTrue(driver.io.stop_called) + await device.stop() + self.assertTrue(io.stop_called) - async def test_driver_sends_literal_serial_frame(self): - driver = self.make_driver() - await driver.setup() - driver.io.queue_response(b"STAK\r") + async def test_sends_literal_serial_frame(self): + device = self.make_device() + io = self.fake_io(device) + await device.setup() + io.queue_response(b"STAK\r") - response = await driver.send_command("ST 0.030", timeout=0.01) + response = await device.send_command("ST 0.030", timeout=0.01) self.assertEqual(response, "STAK") - self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) - self.assertTrue(driver.io.reset_input_buffer_called) - self.assertEqual(driver.last_command, "ST 0.030") - self.assertEqual(driver.last_response, "STAK") + self.assertEqual(io.writes, [b"ST 0.030\r"]) + self.assertTrue(io.reset_input_buffer_called) + self.assertEqual(device.last_command, "ST 0.030") + self.assertEqual(device.last_response, "STAK") async def test_temperature_and_time_writes_are_scaled_and_validated(self): - driver = self.make_driver() - backend = PlateLocSealerBackend(driver) - await driver.setup() - driver.io.queue_response(b"STAK\r") - driver.io.queue_response(b"SSAK\r") + device = self.make_device() + io = self.fake_io(device) + await device.setup() + io.queue_response(b"STAK\r") + io.queue_response(b"SSAK\r") - await backend.set_sealing_temperature(30) - await backend.set_sealing_time(0.5) + await device.set_sealing_temperature(30) + await device.set_sealing_time(0.5) - self.assertEqual(driver.io.writes, [b"ST 0.030\r", b"SS 0.05\r"]) + self.assertEqual(io.writes, [b"ST 0.030\r", b"SS 0.05\r"]) with self.assertRaises(ValueError): - await backend.set_sealing_temperature(19) + await device.set_sealing_temperature(19) with self.assertRaises(ValueError): - await backend.set_sealing_time(0.4) + await device.set_sealing_time(0.4) async def test_negative_acknowledgement_raises_protocol_error(self): - driver = self.make_driver() - backend = PlateLocSealerBackend(driver) - await driver.setup() - driver.io.queue_response(b"STNK(Desired Temperature is Out of Range)\r\r") + device = self.make_device() + io = self.fake_io(device) + await device.setup() + io.queue_response(b"STNK(Desired Temperature is Out of Range)\r\r") with self.assertRaisesRegex(PlateLocError, "Desired Temperature is Out of Range"): - await backend.set_sealing_temperature(30) + await device.set_sealing_temperature(30) - self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) + self.assertEqual(io.writes, [b"ST 0.030\r"]) async def test_missing_acknowledgement_raises_timeout(self): - driver = self.make_driver() - backend = PlateLocSealerBackend(driver) - await driver.setup() + device = self.make_device() + io = self.fake_io(device) + await device.setup() with self.assertRaisesRegex(TimeoutError, "Timeout"): - await backend.set_sealing_temperature(30) + await device.set_sealing_temperature(30) - self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) + self.assertEqual(io.writes, [b"ST 0.030\r"]) async def test_malformed_acknowledgement_raises_protocol_error(self): - driver = self.make_driver() - backend = PlateLocSealerBackend(driver) - await driver.setup() - driver.io.queue_response(b"unexpected\r") + device = self.make_device() + io = self.fake_io(device) + await device.setup() + io.queue_response(b"unexpected\r") with self.assertRaisesRegex(PlateLocError, "invalid response"): - await backend.set_sealing_temperature(30) + await device.set_sealing_temperature(30) - self.assertEqual(driver.io.writes, [b"ST 0.030\r"]) + self.assertEqual(io.writes, [b"ST 0.030\r"]) async def test_required_response_reads_until_plate_loc_ack(self): - driver = self.make_driver() - backend = PlateLocSealerBackend(driver) - await driver.setup() - driver.io.queue_response(b"CCAK\r") + device = self.make_device() + io = self.fake_io(device) + await device.setup() + io.queue_response(b"CCAK\r") - self.assertTrue(await backend.check_cycle_complete()) - self.assertEqual(driver.io.writes, [b"CC 00\r"]) + self.assertTrue(await device.check_cycle_complete()) + self.assertEqual(io.writes, [b"CC 00\r"]) async def test_cycle_not_complete_returns_false(self): - driver = self.make_driver() - backend = PlateLocSealerBackend(driver) - await driver.setup() - driver.io.queue_response(b"CCNK\r") + device = self.make_device() + io = self.fake_io(device) + await device.setup() + io.queue_response(b"CCNK\r") - self.assertFalse(await backend.check_cycle_complete()) - self.assertEqual(driver.io.writes, [b"CC 00\r"]) + self.assertFalse(await device.check_cycle_complete()) + self.assertEqual(io.writes, [b"CC 00\r"]) async def test_invalid_cycle_complete_response_raises_protocol_error(self): - driver = self.make_driver() - backend = PlateLocSealerBackend(driver) - await driver.setup() - driver.io.queue_response(b"unexpected\r") + device = self.make_device() + io = self.fake_io(device) + await device.setup() + io.queue_response(b"unexpected\r") with self.assertRaisesRegex(PlateLocError, "invalid response"): - await backend.check_cycle_complete() + await device.check_cycle_complete() - self.assertEqual(driver.io.writes, [b"CC 00\r"]) + self.assertEqual(io.writes, [b"CC 00\r"]) async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): - driver = self.make_driver() - backend = PlateLocSealerBackend(driver) - await driver.setup() - driver.io.queue_response(b"STAK\r") - driver.io.queue_response(b"SSAK\r") - driver.io.queue_response(b"SOAK\r") + device = self.make_device() + io = self.fake_io(device) + await device.setup() + io.queue_response(b"STAK\r") + io.queue_response(b"SSAK\r") + io.queue_response(b"SOAK\r") - await backend.set_sealing_temperature(30) - await backend.set_sealing_time(0.5) - await backend.move_stage_out() - driver.io.queue_response(b"CCAK\r") + await device.set_sealing_temperature(30) + await device.set_sealing_time(0.5) + await device.move_stage_out() + io.queue_response(b"CCAK\r") - status = await backend.request_status() + status = await device.request_status() self.assertIsInstance(status, PlateLocStatus) self.assertEqual(status.port, "COM6") @@ -235,23 +223,23 @@ async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): self.assertTrue(status.cycle_complete) self.assertEqual(status.last_command, "CC 00") self.assertEqual(status.last_response, "CCAK") - self.assertEqual(driver.io.writes, [b"ST 0.030\r", b"SS 0.05\r", b"SO 00\r", b"CC 00\r"]) + self.assertEqual(io.writes, [b"ST 0.030\r", b"SS 0.05\r", b"SO 00\r", b"CC 00\r"]) async def test_seal_waits_for_cycle_completion(self): device = self.make_device(timeout=1) - backend = self.backend(device) + io = self.fake_io(device) await device.setup() - device.driver.io.queue_response(b"STAK\r") - device.driver.io.queue_response(b"SSAK\r") - device.driver.io.queue_response(b"GOAK\r") - device.driver.io.queue_response(b"CCNK\r") - device.driver.io.queue_response(b"CCAK\r") + io.queue_response(b"STAK\r") + io.queue_response(b"SSAK\r") + io.queue_response(b"GOAK\r") + io.queue_response(b"CCNK\r") + io.queue_response(b"CCAK\r") - await device.sealer.seal(120, 1.2) + await device.seal(120, 1.2) self.assertEqual( - device.driver.io.writes, + io.writes, [ b"ST 0.120\r", b"SS 0.12\r", @@ -260,35 +248,33 @@ async def test_seal_waits_for_cycle_completion(self): b"CC 00\r", ], ) - self.assertEqual(backend.status_snapshot().target_temperature, 120) - self.assertEqual(backend.status_snapshot().sealing_time, 1.2) + self.assertEqual(device.status_snapshot().target_temperature, 120) + self.assertEqual(device.status_snapshot().sealing_time, 1.2) - async def test_device_exposes_plate_loc_sealer_capability(self): + async def test_device_exposes_plain_sealer_api(self): device = self.make_device() + io = self.fake_io(device) await device.setup() - device.driver.io.queue_response(b"STAK\r") - await device.sealer.set_sealing_temperature(100) - device.driver.io.queue_response(b"SSAK\r") - await device.sealer.set_sealing_time(0.5) - device.driver.io.queue_response(b"STAK\r") - device.driver.io.queue_response(b"SSAK\r") - device.driver.io.queue_response(b"GOAK\r") - device.driver.io.queue_response(b"CCAK\r") - await device.sealer.seal(120, 1.2) - device.driver.io.queue_response(b"SOAK\r") - await device.sealer.open() - device.driver.io.queue_response(b"SIAK\r") - await device.sealer.close() - device.driver.io.queue_response(b"CCAK\r") - status = await device.sealer.request_status() + io.queue_response(b"STAK\r") + await device.set_sealing_temperature(100) + io.queue_response(b"SSAK\r") + await device.set_sealing_time(0.5) + io.queue_response(b"STAK\r") + io.queue_response(b"SSAK\r") + io.queue_response(b"GOAK\r") + io.queue_response(b"CCAK\r") + await device.seal(120, 1.2) + io.queue_response(b"SOAK\r") + await device.open() + io.queue_response(b"SIAK\r") + await device.close() + io.queue_response(b"CCAK\r") + status = await device.request_status() await device.stop() - self.assertIsInstance(device.sealer, PlateLocSealer) - self.assertFalse(hasattr(device, "set_sealing_temperature")) - self.assertFalse(hasattr(device, "set_sealing_time")) self.assertEqual( - device.driver.io.writes, + io.writes, [ b"ST 0.100\r", b"SS 0.05\r", @@ -305,7 +291,7 @@ async def test_device_exposes_plate_loc_sealer_capability(self): self.assertEqual(status.sealing_time, 1.2) self.assertEqual(status.stage_position, "closed") self.assertTrue(status.cycle_complete) - self.assertTrue(device.driver.io.stop_called) + self.assertTrue(io.stop_called) if __name__ == "__main__": From 9aaf8172d4970680b664041209e6910daafb7d0a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 27 Aug 2026 18:21:53 -0700 Subject: [PATCH 09/10] Simplify the PlateLoc public API --- .../agilent/plateloc/hello-world.ipynb | 27 +--- pylabrobot/agilent/plateloc/plateloc.py | 135 +++++------------- pylabrobot/agilent/plateloc/plateloc_tests.py | 57 ++++---- 3 files changed, 75 insertions(+), 144 deletions(-) diff --git a/docs/user_guide/agilent/plateloc/hello-world.ipynb b/docs/user_guide/agilent/plateloc/hello-world.ipynb index ca676b06eb6..45792b23972 100644 --- a/docs/user_guide/agilent/plateloc/hello-world.ipynb +++ b/docs/user_guide/agilent/plateloc/hello-world.ipynb @@ -13,7 +13,6 @@ "| Property | Value |\n", "|---|---|\n", "| Connection | RS-232 serial, 19200 8N1 |\n", - "| Default port example | `COM6` |\n", "| PLR extra | `serial` |\n", "\n", "The PlateLoc uses direct RS-232 control and does not require Agilent ActiveX, VWorks, or vendor server software. Install the optional serial dependency before connecting:\n", @@ -93,26 +92,6 @@ "await plateloc.set_sealing_temperature(175)" ] }, - { - "cell_type": "markdown", - "id": "plateloc-time-heading", - "metadata": {}, - "source": [ - "## Set the sealing time\n", - "\n", - "The accepted range is 0.5–12.0 seconds." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "plateloc-time", - "metadata": {}, - "outputs": [], - "source": [ - "await plateloc.set_sealing_time(0.5)" - ] - }, { "cell_type": "markdown", "id": "plateloc-status-heading", @@ -120,7 +99,7 @@ "source": [ "## Read status\n", "\n", - "`request_status()` returns the last successfully written setpoints plus a live cycle-complete query. The direct protocol does not expose the actual block temperature or stored sealing time." + "`request_status()` returns the last successfully written temperature target, the tracked stage position, and a live cycle-complete query. The direct protocol does not expose the actual block temperature." ] }, { @@ -131,7 +110,7 @@ "outputs": [], "source": [ "status = await plateloc.request_status()\n", - "print(status.target_temperature, status.sealing_time, status.cycle_complete)" + "print(status.target_temperature, status.stage_position, status.cycle_complete)" ] }, { @@ -233,7 +212,7 @@ "| Clear error | `CL 00\\r` |\n", "| Check cycle complete | `CC 00\\r` |\n", "\n", - "For example, `set_sealing_temperature(175)` writes `ST 0.175\\r`, and `set_sealing_time(1.2)` writes `SS 0.12\\r`. Negative acknowledgements have the form `NK(message)` and raise `PlateLocError`." + "For example, `set_sealing_temperature(175)` writes `ST 0.175\\r`, and `seal(temperature=175, duration=1.2)` writes `SS 0.12\\r` as part of the cycle setup. Negative acknowledgements have the form `NK(message)` and raise `PlateLocError`." ] }, { diff --git a/pylabrobot/agilent/plateloc/plateloc.py b/pylabrobot/agilent/plateloc/plateloc.py index b941deb4cf4..7455ff412aa 100644 --- a/pylabrobot/agilent/plateloc/plateloc.py +++ b/pylabrobot/agilent/plateloc/plateloc.py @@ -34,11 +34,8 @@ class PlateLocStatus: port: str connected: bool target_temperature: Optional[float] - sealing_time: Optional[float] stage_position: Optional[Literal["open", "closed"]] cycle_complete: Optional[bool] - last_command: Optional[str] - last_response: Optional[str] @dataclasses.dataclass(frozen=True) @@ -100,16 +97,6 @@ def deserialize(cls, data: dict) -> "PlateLocSerialProfile": class PlateLoc: """Direct serial driver for the Agilent PlateLoc thermal microplate sealer.""" - _SET_TEMPERATURE = "ST" - _SET_TIME = "SS" - _MOVE_STAGE_OUT = "SO" - _MOVE_STAGE_IN = "SI" - _START_CYCLE = "GO" - _STOP_CYCLE = "AC" - _APPLY_SEAL = "AS" - _CLEAR_ERROR = "CL" - _CHECK_CYCLE_COMPLETE = "CC" - def __init__( self, port: Optional[str] = None, @@ -129,10 +116,7 @@ def __init__( self.timeout = timeout self._connected = False self._target_temperature: Optional[float] = None - self._sealing_time: Optional[float] = None self._stage_position: Optional[Literal["open", "closed"]] = None - self._last_command: Optional[str] = None - self._last_response: Optional[str] = None self.io = Serial( human_readable_device_name="Agilent PlateLoc Sealer", port=port, @@ -159,16 +143,6 @@ def connected(self) -> bool: """Whether :meth:`setup` has completed without a subsequent :meth:`stop`.""" return self._connected - @property - def last_command(self) -> Optional[str]: - """The last command sent, without its carriage-return terminator.""" - return self._last_command - - @property - def last_response(self) -> Optional[str]: - """The last response received, without surrounding whitespace.""" - return self._last_response - async def setup(self) -> None: """Open the serial connection.""" await self.io.setup() @@ -181,7 +155,7 @@ async def stop(self) -> None: self._connected = False logger.info("[PlateLoc %s] disconnected", self.port) - async def send_command( + async def _send_command( self, command: str, *, @@ -192,16 +166,13 @@ async def send_command( command = command.removesuffix(self.profile.command_terminator) await self.io.reset_input_buffer() await self.io.write(f"{command}{self.profile.command_terminator}".encode("ascii")) - self._last_command = command if self.profile.read_delay > 0: await asyncio.sleep(self.profile.read_delay) - response = await self.read_response(timeout=timeout, required=required) - self._last_response = response - return response + return await self._read_response(timeout=timeout, required=required) - async def read_response( + async def _read_response( self, timeout: Optional[float] = None, required: bool = True, @@ -250,7 +221,7 @@ async def _send( timeout: Optional[float] = None, raise_on_nak: bool = True, ) -> str: - response = await self.send_command( + response = await self._send_command( command, timeout=timeout if timeout is not None else self.profile.ack_timeout, required=True, @@ -266,118 +237,90 @@ async def set_sealing_temperature(self, temperature: float) -> str: raise ValueError("Temperature out of range. Please enter a value between 20 and 235 C.") target_temperature = round(temperature) logger.info("[PlateLoc %s] setting sealing temperature to %.1f C", self.port, temperature) - response = await self._send(f"{self._SET_TEMPERATURE} 0.{target_temperature:03d}") + response = await self._send(f"ST 0.{target_temperature:03d}") self._target_temperature = float(target_temperature) return response - async def set_sealing_time(self, duration: float) -> str: - """Set the sealing duration in seconds.""" - if not (0.5 <= duration <= 12.0): - raise ValueError("Duration out of range. Please enter a value between 0.5 and 12.0 s.") - sealing_time_deciseconds = round(duration * 10) - logger.info("[PlateLoc %s] setting sealing time to %.2f s", self.port, duration) - response = await self._send(f"{self._SET_TIME} 0.{sealing_time_deciseconds:02d}") - self._sealing_time = sealing_time_deciseconds / 10 - return response - - async def move_stage_out(self) -> str: - """Move the plate stage to its open position.""" - logger.info("[PlateLoc %s] moving stage out", self.port) - response = await self._send(f"{self._MOVE_STAGE_OUT} 00") - if self.profile.stage_move_delay > 0: - await asyncio.sleep(self.profile.stage_move_delay) - self._stage_position = "open" - return response - - async def move_stage_in(self) -> str: - """Move the plate stage to its closed position.""" - logger.info("[PlateLoc %s] moving stage in", self.port) - response = await self._send(f"{self._MOVE_STAGE_IN} 00") - if self.profile.stage_move_delay > 0: - await asyncio.sleep(self.profile.stage_move_delay) - self._stage_position = "closed" - return response - - async def start_cycle(self) -> str: - """Start a sealing cycle with the current setpoints.""" - logger.info("[PlateLoc %s] starting sealing cycle", self.port) - return await self._send(f"{self._START_CYCLE} 00") - async def stop_cycle(self) -> str: """Stop the active sealing cycle.""" logger.info("[PlateLoc %s] stopping sealing cycle", self.port) - return await self._send(f"{self._STOP_CYCLE} 00") + return await self._send("AC 00") - async def apply_seal(self) -> str: + async def _apply_seal(self) -> str: """Apply the current seal.""" logger.info("[PlateLoc %s] applying seal", self.port) - return await self._send(f"{self._APPLY_SEAL} 00") + return await self._send("AS 00") async def clear_error(self) -> str: """Clear the active PlateLoc error.""" logger.info("[PlateLoc %s] clearing error", self.port) - return await self._send(f"{self._CLEAR_ERROR} 00") + return await self._send("CL 00") - async def check_cycle_complete(self) -> bool: + async def request_cycle_complete(self) -> bool: """Return whether the current sealing cycle is complete.""" response = await self._send( - f"{self._CHECK_CYCLE_COMPLETE} 00", + "CC 00", timeout=self.profile.response_timeout, raise_on_nak=False, ) - match = self._parse_response(self._CHECK_CYCLE_COMPLETE, response) + match = self._parse_response("CC", response) return match.group("status") == "A" - async def wait_for_cycle_complete(self, timeout: Optional[float] = None) -> bool: - """Wait until the current sealing cycle completes.""" - deadline = time.monotonic() + (self.timeout if timeout is None else timeout) - while True: - if await self.check_cycle_complete(): - return True - remaining = deadline - time.monotonic() - if remaining <= 0: - raise TimeoutError("Timeout while waiting for PlateLoc cycle to complete") - await asyncio.sleep(min(max(self.profile.cycle_poll_interval, 0), remaining)) - def status_snapshot(self, cycle_complete: Optional[bool] = None) -> PlateLocStatus: """Return the locally tracked state without communicating with the device.""" return PlateLocStatus( port=self.port, connected=self.connected, target_temperature=self._target_temperature, - sealing_time=self._sealing_time, stage_position=self._stage_position, cycle_complete=cycle_complete, - last_command=self.last_command, - last_response=self.last_response, ) async def request_status(self, query_cycle_complete: bool = True) -> PlateLocStatus: """Return locally tracked state, optionally querying cycle completion.""" - cycle_complete = await self.check_cycle_complete() if query_cycle_complete else None + cycle_complete = await self.request_cycle_complete() if query_cycle_complete else None return self.status_snapshot(cycle_complete=cycle_complete) async def seal(self, temperature: int, duration: float) -> str: """Seal a plate at the requested temperature and duration.""" + if not (0.5 <= duration <= 12.0): + raise ValueError("Duration out of range. Please enter a value between 0.5 and 12.0 s.") await self.set_sealing_temperature(temperature) - await self.set_sealing_time(duration) - response = await self.start_cycle() - await self.wait_for_cycle_complete() + sealing_time_deciseconds = round(duration * 10) + logger.info("[PlateLoc %s] setting sealing time to %.2f s", self.port, duration) + await self._send(f"SS 0.{sealing_time_deciseconds:02d}") + logger.info("[PlateLoc %s] starting sealing cycle", self.port) + response = await self._send("GO 00") + deadline = time.monotonic() + self.timeout + while not await self.request_cycle_complete(): + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Timeout while waiting for PlateLoc cycle to complete") + await asyncio.sleep(min(max(self.profile.cycle_poll_interval, 0), remaining)) return response async def open(self) -> str: """Move the plate stage to its open position.""" - return await self.move_stage_out() + logger.info("[PlateLoc %s] moving stage out", self.port) + response = await self._send("SO 00") + if self.profile.stage_move_delay > 0: + await asyncio.sleep(self.profile.stage_move_delay) + self._stage_position = "open" + return response async def close(self) -> str: """Move the plate stage to its closed position.""" - return await self.move_stage_in() + logger.info("[PlateLoc %s] moving stage in", self.port) + response = await self._send("SI 00") + if self.profile.stage_move_delay > 0: + await asyncio.sleep(self.profile.stage_move_delay) + self._stage_position = "closed" + return response def serialize(self) -> dict: - """Serialize the configured connection and locally tracked state.""" + """Serialize the connection configuration.""" return { "port": self.port, "profile": self.profile.serialize(), "timeout": self.timeout, - "status": dataclasses.asdict(self.status_snapshot()), } diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py index 830ad172c58..71352d40c35 100644 --- a/pylabrobot/agilent/plateloc/plateloc_tests.py +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -113,30 +113,34 @@ async def test_sends_literal_serial_frame(self): await device.setup() io.queue_response(b"STAK\r") - response = await device.send_command("ST 0.030", timeout=0.01) + response = await device._send_command("ST 0.030", timeout=0.01) self.assertEqual(response, "STAK") self.assertEqual(io.writes, [b"ST 0.030\r"]) self.assertTrue(io.reset_input_buffer_called) - self.assertEqual(device.last_command, "ST 0.030") - self.assertEqual(device.last_response, "STAK") - async def test_temperature_and_time_writes_are_scaled_and_validated(self): + async def test_serialize_contains_only_connection_configuration(self): + device = self.make_device(timeout=42) + + serialized = device.serialize() + + self.assertEqual(set(serialized), {"port", "profile", "timeout"}) + self.assertEqual(serialized["port"], "COM6") + self.assertEqual(serialized["timeout"], 42) + self.assertEqual(serialized["profile"], device.profile.serialize()) + + async def test_temperature_write_is_scaled_and_validated(self): device = self.make_device() io = self.fake_io(device) await device.setup() io.queue_response(b"STAK\r") - io.queue_response(b"SSAK\r") await device.set_sealing_temperature(30) - await device.set_sealing_time(0.5) - self.assertEqual(io.writes, [b"ST 0.030\r", b"SS 0.05\r"]) + self.assertEqual(io.writes, [b"ST 0.030\r"]) with self.assertRaises(ValueError): await device.set_sealing_temperature(19) - with self.assertRaises(ValueError): - await device.set_sealing_time(0.4) async def test_negative_acknowledgement_raises_protocol_error(self): device = self.make_device() @@ -176,7 +180,7 @@ async def test_required_response_reads_until_plate_loc_ack(self): await device.setup() io.queue_response(b"CCAK\r") - self.assertTrue(await device.check_cycle_complete()) + self.assertTrue(await device.request_cycle_complete()) self.assertEqual(io.writes, [b"CC 00\r"]) async def test_cycle_not_complete_returns_false(self): @@ -185,7 +189,7 @@ async def test_cycle_not_complete_returns_false(self): await device.setup() io.queue_response(b"CCNK\r") - self.assertFalse(await device.check_cycle_complete()) + self.assertFalse(await device.request_cycle_complete()) self.assertEqual(io.writes, [b"CC 00\r"]) async def test_invalid_cycle_complete_response_raises_protocol_error(self): @@ -195,7 +199,7 @@ async def test_invalid_cycle_complete_response_raises_protocol_error(self): io.queue_response(b"unexpected\r") with self.assertRaisesRegex(PlateLocError, "invalid response"): - await device.check_cycle_complete() + await device.request_cycle_complete() self.assertEqual(io.writes, [b"CC 00\r"]) @@ -205,11 +209,12 @@ async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): await device.setup() io.queue_response(b"STAK\r") io.queue_response(b"SSAK\r") + io.queue_response(b"GOAK\r") + io.queue_response(b"CCAK\r") io.queue_response(b"SOAK\r") - await device.set_sealing_temperature(30) - await device.set_sealing_time(0.5) - await device.move_stage_out() + await device.seal(30, 0.5) + await device.open() io.queue_response(b"CCAK\r") status = await device.request_status() @@ -218,12 +223,12 @@ async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): self.assertEqual(status.port, "COM6") self.assertTrue(status.connected) self.assertEqual(status.target_temperature, 30) - self.assertEqual(status.sealing_time, 0.5) self.assertEqual(status.stage_position, "open") self.assertTrue(status.cycle_complete) - self.assertEqual(status.last_command, "CC 00") - self.assertEqual(status.last_response, "CCAK") - self.assertEqual(io.writes, [b"ST 0.030\r", b"SS 0.05\r", b"SO 00\r", b"CC 00\r"]) + self.assertEqual( + io.writes, + [b"ST 0.030\r", b"SS 0.05\r", b"GO 00\r", b"CC 00\r", b"SO 00\r", b"CC 00\r"], + ) async def test_seal_waits_for_cycle_completion(self): device = self.make_device(timeout=1) @@ -249,7 +254,15 @@ async def test_seal_waits_for_cycle_completion(self): ], ) self.assertEqual(device.status_snapshot().target_temperature, 120) - self.assertEqual(device.status_snapshot().sealing_time, 1.2) + + async def test_seal_validates_duration_before_sending_commands(self): + device = self.make_device() + io = self.fake_io(device) + + with self.assertRaises(ValueError): + await device.seal(120, 0.4) + + self.assertEqual(io.writes, []) async def test_device_exposes_plain_sealer_api(self): device = self.make_device() @@ -258,8 +271,6 @@ async def test_device_exposes_plain_sealer_api(self): await device.setup() io.queue_response(b"STAK\r") await device.set_sealing_temperature(100) - io.queue_response(b"SSAK\r") - await device.set_sealing_time(0.5) io.queue_response(b"STAK\r") io.queue_response(b"SSAK\r") io.queue_response(b"GOAK\r") @@ -277,7 +288,6 @@ async def test_device_exposes_plain_sealer_api(self): io.writes, [ b"ST 0.100\r", - b"SS 0.05\r", b"ST 0.120\r", b"SS 0.12\r", b"GO 00\r", @@ -288,7 +298,6 @@ async def test_device_exposes_plain_sealer_api(self): ], ) self.assertEqual(status.target_temperature, 120) - self.assertEqual(status.sealing_time, 1.2) self.assertEqual(status.stage_position, "closed") self.assertTrue(status.cycle_complete) self.assertTrue(io.stop_called) From 8b9e93b66e7f65fafa1619f07c3bf41c5cb0ddea Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Thu, 27 Aug 2026 18:29:18 -0700 Subject: [PATCH 10/10] Use a Serial mock in PlateLoc tests --- pylabrobot/agilent/plateloc/plateloc_tests.py | 239 +++++++----------- 1 file changed, 91 insertions(+), 148 deletions(-) diff --git a/pylabrobot/agilent/plateloc/plateloc_tests.py b/pylabrobot/agilent/plateloc/plateloc_tests.py index 71352d40c35..02d48ca497d 100644 --- a/pylabrobot/agilent/plateloc/plateloc_tests.py +++ b/pylabrobot/agilent/plateloc/plateloc_tests.py @@ -2,8 +2,8 @@ import contextlib import unittest from collections import deque -from typing import Deque, cast -from unittest.mock import patch +from typing import Deque +from unittest.mock import MagicMock, patch import pylabrobot.agilent.plateloc.plateloc as plateloc_module from pylabrobot.agilent.plateloc import ( @@ -12,71 +12,31 @@ PlateLocSerialProfile, PlateLocStatus, ) - - -class FakeSerial: - def __init__(self, **kwargs): - self.kwargs = kwargs - self._port = kwargs["port"] - self.writes = [] - self.responses: Deque[bytes] = deque() - self.setup_called = False - self.stop_called = False - self.timeout = kwargs["timeout"] - self.reset_input_buffer_called = False - - @property - def port(self): - return self._port - - @contextlib.contextmanager - def temporary_timeout(self, timeout: float): - previous_timeout = self.timeout - self.timeout = timeout - try: - yield - finally: - self.timeout = previous_timeout - - async def setup(self): - self.setup_called = True - - async def stop(self): - self.stop_called = True - - async def write(self, data: bytes): - self.writes.append(data) - - async def read(self, num_bytes: int = 1) -> bytes: - if not self.responses: - await asyncio.sleep(0) - return b"" - response = self.responses[0] - chunk = response[:num_bytes] - response = response[num_bytes:] - if response: - self.responses[0] = response - else: - self.responses.popleft() - return chunk - - def queue_response(self, response: bytes): - self.responses.append(response) - - async def reset_input_buffer(self): - self.reset_input_buffer_called = True +from pylabrobot.io.serial import Serial class PlateLocTests(unittest.IsolatedAsyncioTestCase): - @contextlib.contextmanager - def patch_serial(self): - with ( - patch.object(plateloc_module, "HAS_SERIAL", True), - patch.object(plateloc_module, "Serial", FakeSerial), - ): - yield + serial_constructor: MagicMock + + def make_device( + self, ack_timeout: float = 0.01, timeout: float = 30 + ) -> tuple[PlateLoc, MagicMock, Deque[int]]: + responses: Deque[int] = deque() + io = MagicMock(spec=Serial) + io.port = "COM6" + io.temporary_timeout.side_effect = lambda _timeout: contextlib.nullcontext() + + async def read(num_bytes: int = 1) -> bytes: + if not responses: + await asyncio.sleep(0) + return b"" + chunk = bytearray() + while responses and len(chunk) < num_bytes: + chunk.append(responses.popleft()) + return bytes(chunk) + + io.read.side_effect = read - def make_device(self, ack_timeout: float = 0.01, timeout: float = 30) -> PlateLoc: profile = PlateLocSerialProfile( response_timeout=0.01, ack_timeout=ack_timeout, @@ -84,43 +44,55 @@ def make_device(self, ack_timeout: float = 0.01, timeout: float = 30) -> PlateLo stage_move_delay=0, cycle_poll_interval=0, ) - with self.patch_serial(): - return PlateLoc(port="COM6", profile=profile, timeout=timeout) + with ( + patch.object(plateloc_module, "HAS_SERIAL", True), + patch.object(plateloc_module, "Serial", return_value=io) as serial_constructor, + ): + device = PlateLoc(port="COM6", profile=profile, timeout=timeout) + self.serial_constructor = serial_constructor + return device, io, responses - def fake_io(self, device: PlateLoc) -> FakeSerial: - return cast(FakeSerial, device.io) + def assert_writes(self, io: MagicMock, expected: list[bytes]) -> None: + self.assertEqual([mock_call.args[0] for mock_call in io.write.await_args_list], expected) async def test_setup_uses_plr_serial_wrapper_settings(self): - device = self.make_device() - io = self.fake_io(device) + device, io, _ = self.make_device() await device.setup() - self.assertTrue(io.setup_called) - self.assertEqual(io.kwargs["human_readable_device_name"], "Agilent PlateLoc Sealer") - self.assertEqual(io.kwargs["port"], "COM6") - self.assertEqual(io.kwargs["baudrate"], 19200) - self.assertEqual(io.kwargs["bytesize"], 8) - self.assertEqual(io.kwargs["parity"], "N") - self.assertEqual(io.kwargs["stopbits"], 1) + io.setup.assert_awaited_once_with() + self.serial_constructor.assert_called_once_with( + human_readable_device_name="Agilent PlateLoc Sealer", + port="COM6", + vid=None, + pid=None, + baudrate=19200, + bytesize=8, + parity="N", + stopbits=1, + write_timeout=1, + timeout=1, + rtscts=False, + dsrdtr=False, + xonxoff=False, + ) await device.stop() - self.assertTrue(io.stop_called) + io.stop.assert_awaited_once_with() async def test_sends_literal_serial_frame(self): - device = self.make_device() - io = self.fake_io(device) + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"STAK\r") + responses.extend(b"STAK\r") response = await device._send_command("ST 0.030", timeout=0.01) self.assertEqual(response, "STAK") - self.assertEqual(io.writes, [b"ST 0.030\r"]) - self.assertTrue(io.reset_input_buffer_called) + self.assert_writes(io, [b"ST 0.030\r"]) + io.reset_input_buffer.assert_awaited_once_with() async def test_serialize_contains_only_connection_configuration(self): - device = self.make_device(timeout=42) + device, _, _ = self.make_device(timeout=42) serialized = device.serialize() @@ -130,93 +102,79 @@ async def test_serialize_contains_only_connection_configuration(self): self.assertEqual(serialized["profile"], device.profile.serialize()) async def test_temperature_write_is_scaled_and_validated(self): - device = self.make_device() - io = self.fake_io(device) + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"STAK\r") + responses.extend(b"STAK\r") await device.set_sealing_temperature(30) - self.assertEqual(io.writes, [b"ST 0.030\r"]) + self.assert_writes(io, [b"ST 0.030\r"]) with self.assertRaises(ValueError): await device.set_sealing_temperature(19) async def test_negative_acknowledgement_raises_protocol_error(self): - device = self.make_device() - io = self.fake_io(device) + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"STNK(Desired Temperature is Out of Range)\r\r") + responses.extend(b"STNK(Desired Temperature is Out of Range)\r\r") with self.assertRaisesRegex(PlateLocError, "Desired Temperature is Out of Range"): await device.set_sealing_temperature(30) - self.assertEqual(io.writes, [b"ST 0.030\r"]) + self.assert_writes(io, [b"ST 0.030\r"]) async def test_missing_acknowledgement_raises_timeout(self): - device = self.make_device() - io = self.fake_io(device) + device, io, _ = self.make_device() await device.setup() with self.assertRaisesRegex(TimeoutError, "Timeout"): await device.set_sealing_temperature(30) - self.assertEqual(io.writes, [b"ST 0.030\r"]) + self.assert_writes(io, [b"ST 0.030\r"]) async def test_malformed_acknowledgement_raises_protocol_error(self): - device = self.make_device() - io = self.fake_io(device) + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"unexpected\r") + responses.extend(b"unexpected\r") with self.assertRaisesRegex(PlateLocError, "invalid response"): await device.set_sealing_temperature(30) - self.assertEqual(io.writes, [b"ST 0.030\r"]) + self.assert_writes(io, [b"ST 0.030\r"]) async def test_required_response_reads_until_plate_loc_ack(self): - device = self.make_device() - io = self.fake_io(device) + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"CCAK\r") + responses.extend(b"CCAK\r") self.assertTrue(await device.request_cycle_complete()) - self.assertEqual(io.writes, [b"CC 00\r"]) + self.assert_writes(io, [b"CC 00\r"]) async def test_cycle_not_complete_returns_false(self): - device = self.make_device() - io = self.fake_io(device) + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"CCNK\r") + responses.extend(b"CCNK\r") self.assertFalse(await device.request_cycle_complete()) - self.assertEqual(io.writes, [b"CC 00\r"]) + self.assert_writes(io, [b"CC 00\r"]) async def test_invalid_cycle_complete_response_raises_protocol_error(self): - device = self.make_device() - io = self.fake_io(device) + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"unexpected\r") + responses.extend(b"unexpected\r") with self.assertRaisesRegex(PlateLocError, "invalid response"): await device.request_cycle_complete() - self.assertEqual(io.writes, [b"CC 00\r"]) + self.assert_writes(io, [b"CC 00\r"]) async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): - device = self.make_device() - io = self.fake_io(device) + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"STAK\r") - io.queue_response(b"SSAK\r") - io.queue_response(b"GOAK\r") - io.queue_response(b"CCAK\r") - io.queue_response(b"SOAK\r") + responses.extend(b"STAK\rSSAK\rGOAK\rCCAK\rSOAK\rCCAK\r") await device.seal(30, 0.5) await device.open() - io.queue_response(b"CCAK\r") - status = await device.request_status() self.assertIsInstance(status, PlateLocStatus) @@ -225,26 +183,20 @@ async def test_status_snapshot_tracks_setpoints_and_live_cycle_complete(self): self.assertEqual(status.target_temperature, 30) self.assertEqual(status.stage_position, "open") self.assertTrue(status.cycle_complete) - self.assertEqual( - io.writes, + self.assert_writes( + io, [b"ST 0.030\r", b"SS 0.05\r", b"GO 00\r", b"CC 00\r", b"SO 00\r", b"CC 00\r"], ) async def test_seal_waits_for_cycle_completion(self): - device = self.make_device(timeout=1) - io = self.fake_io(device) - + device, io, responses = self.make_device(timeout=1) await device.setup() - io.queue_response(b"STAK\r") - io.queue_response(b"SSAK\r") - io.queue_response(b"GOAK\r") - io.queue_response(b"CCNK\r") - io.queue_response(b"CCAK\r") + responses.extend(b"STAK\rSSAK\rGOAK\rCCNK\rCCAK\r") await device.seal(120, 1.2) - self.assertEqual( - io.writes, + self.assert_writes( + io, [ b"ST 0.120\r", b"SS 0.12\r", @@ -256,36 +208,27 @@ async def test_seal_waits_for_cycle_completion(self): self.assertEqual(device.status_snapshot().target_temperature, 120) async def test_seal_validates_duration_before_sending_commands(self): - device = self.make_device() - io = self.fake_io(device) + device, io, _ = self.make_device() with self.assertRaises(ValueError): await device.seal(120, 0.4) - self.assertEqual(io.writes, []) + self.assert_writes(io, []) async def test_device_exposes_plain_sealer_api(self): - device = self.make_device() - io = self.fake_io(device) - + device, io, responses = self.make_device() await device.setup() - io.queue_response(b"STAK\r") + responses.extend(b"STAK\rSTAK\rSSAK\rGOAK\rCCAK\rSOAK\rSIAK\rCCAK\r") + await device.set_sealing_temperature(100) - io.queue_response(b"STAK\r") - io.queue_response(b"SSAK\r") - io.queue_response(b"GOAK\r") - io.queue_response(b"CCAK\r") await device.seal(120, 1.2) - io.queue_response(b"SOAK\r") await device.open() - io.queue_response(b"SIAK\r") await device.close() - io.queue_response(b"CCAK\r") status = await device.request_status() await device.stop() - self.assertEqual( - io.writes, + self.assert_writes( + io, [ b"ST 0.100\r", b"ST 0.120\r", @@ -300,7 +243,7 @@ async def test_device_exposes_plain_sealer_api(self): self.assertEqual(status.target_temperature, 120) self.assertEqual(status.stage_position, "closed") self.assertTrue(status.cycle_complete) - self.assertTrue(io.stop_called) + io.stop.assert_awaited_once_with() if __name__ == "__main__":