From 9038100d5f6765ba7ae3f80a108027eff3e6d911 Mon Sep 17 00:00:00 2001 From: David Nedrud Date: Tue, 28 Oct 2025 10:10:54 -0500 Subject: [PATCH 01/17] Control opentrons temperature module from usb Directly control opentrons temperature module using USB serial commands. This allows use in other machines (like hamilton) not just opentrons liquid handler. --- .../temperature_controlling/opentrons.py | 8 +- .../opentrons_backend.py | 107 +++++++++++++++--- 2 files changed, 96 insertions(+), 19 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/temperature_controlling/opentrons.py index 6f3f45303c2..6364bade3a4 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/temperature_controlling/opentrons.py @@ -23,6 +23,7 @@ def __init__( opentrons_id: str, child_location: Coordinate = Coordinate.zero(), child: Optional[ItemizedResource] = None, + port: Optional[str] = None, ): """Create a new Opentrons temperature module v2. @@ -30,22 +31,25 @@ def __init__( name: Name of the temperature module. opentrons_id: Opentrons ID of the temperature module. Get it from `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. + If USE_OT is False, this can be any identifier. + port: Serial port for USB communication. Required when USE_OT is False. child: Optional child resource like a tube rack or well plate to use on the temperature controller module. """ + backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id, port=port) + super().__init__( name=name, size_x=193.5, size_y=89.2, size_z=84.0, # height without any aluminum block child_location=child_location, - backend=OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id), + backend=backend, category="temperature_controller", model="temperatureModuleV2", # Must match OT moduleModel in list_connected_modules() ) - self.backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id) self.child = child if child is not None: diff --git a/pylabrobot/temperature_controlling/opentrons_backend.py b/pylabrobot/temperature_controlling/opentrons_backend.py index 01367b847b6..51d2d9c2461 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/temperature_controlling/opentrons_backend.py @@ -1,5 +1,5 @@ import sys -from typing import cast +from typing import Optional, cast from pylabrobot.temperature_controlling.backend import ( TemperatureControllerBackend, @@ -18,49 +18,122 @@ else: USE_OT = False +# Import serial for USB communication when USE_OT is False +try: + from pylabrobot.io.serial import Serial + + HAS_SERIAL = True +except ImportError: + HAS_SERIAL = False + class OpentronsTemperatureModuleBackend(TemperatureControllerBackend): """Opentrons temperature module backend.""" @property def supports_active_cooling(self) -> bool: - return False + return True - def __init__(self, opentrons_id: str): + def __init__( + self, + opentrons_id: str, + port: Optional[str] = None, + ): """Create a new Opentrons temperature module backend. Args: opentrons_id: Opentrons ID of the temperature module. Get it from `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. + If USE_OT is False, this can be any identifier. + port: Serial port for USB communication. Required when USE_OT is False. """ self.opentrons_id = opentrons_id + self.port = port + self.serial: Optional["Serial"] = None - if not USE_OT: + if not USE_OT and not HAS_SERIAL: raise RuntimeError( - "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." - f" Import error: {_OT_IMPORT_ERROR}." + "Neither Opentrons API nor pyserial is installed. " + "Please run pip install pylabrobot[opentrons] or pip install pyserial." ) async def setup(self): - pass + if USE_OT: + # No setup needed for opentrons API + pass + else: + # Setup serial communication for USB + if self.port is None: + raise RuntimeError("Serial port must be specified when USE_OT is False.") + self.serial = Serial(port=self.port, baudrate=115200, timeout=3) + await self.serial.setup() async def stop(self): await self.deactivate() + if self.serial is not None: + await self.serial.stop() + self.serial = None def serialize(self) -> dict: - return {**super().serialize(), "opentrons_id": self.opentrons_id} + return {**super().serialize(), "opentrons_id": self.opentrons_id, "port": self.port} async def set_temperature(self, temperature: float): - ot_api.modules.temperature_module_set_temperature( - celsius=temperature, module_id=self.opentrons_id - ) + if USE_OT: + ot_api.modules.temperature_module_set_temperature( + celsius=temperature, module_id=self.opentrons_id + ) + else: + # Send M104 command over serial to set temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M104 SXXX\r\n command + tmp_message = f"M104 S{temperature}\r\n" + await self.serial.write(tmp_message.encode('utf-8')) + # Read response (should be "ok\r\nok\r\n") + response = await self.serial.readline() + # Verify we got the expected response + if b"ok" not in response: + response2 = await self.serial.readline() + if b"ok" not in response2: + raise RuntimeError(f"Unexpected response from device: {response} {response2}") async def deactivate(self): - ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) + if USE_OT: + ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) + else: + # Send M18 command over serial to stop holding temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M18\r\n command + await self.serial.write(b"M18\r\n") + # Read response (should be "ok\r\nok\r\n") + response = await self.serial.readline() + # Verify we got the expected response + if b"ok" not in response: + response2 = await self.serial.readline() + if b"ok" not in response2: + raise RuntimeError(f"Unexpected response from device: {response} {response2}") async def get_current_temperature(self) -> float: - modules = ot_api.modules.list_connected_modules() - for module in modules: - if module["id"] == self.opentrons_id: - return cast(float, module["data"]["currentTemperature"]) - raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") + if USE_OT: + modules = ot_api.modules.list_connected_modules() + for module in modules: + if module["id"] == self.opentrons_id: + return cast(float, module["data"]["currentTemperature"]) + raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") + else: + # Send M105 command over serial to query temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M105\r\n command + await self.serial.write(b"M105\r\n") + # Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n") + response = await self.serial.readline() + # Verify we got the expected response + if b"C" in response: + response2 = await self.serial.readline() + if b"ok" not in response2: + response3 = await self.serial.readline() + if b"ok" not in response3: + raise RuntimeError(f"Unexpected response from device: {response2} {response3}") + return float(response.strip().split(b"C:")[-1]) From 78f753502711a4789b9abb730139421a782a16b4 Mon Sep 17 00:00:00 2001 From: David Nedrud Date: Tue, 28 Oct 2025 10:54:13 -0500 Subject: [PATCH 02/17] Update reading response Read both responses from opentrons at the same time --- .../opentrons_backend.py | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons_backend.py b/pylabrobot/temperature_controlling/opentrons_backend.py index 51d2d9c2461..40b444f8522 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/temperature_controlling/opentrons_backend.py @@ -90,12 +90,11 @@ async def set_temperature(self, temperature: float): tmp_message = f"M104 S{temperature}\r\n" await self.serial.write(tmp_message.encode('utf-8')) # Read response (should be "ok\r\nok\r\n") - response = await self.serial.readline() + response1 = await self.serial.readline() + response2 = await self.serial.readline() # Verify we got the expected response - if b"ok" not in response: - response2 = await self.serial.readline() - if b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response} {response2}") + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError(f"Unexpected response from device: {response1} {response2}") async def deactivate(self): if USE_OT: @@ -107,12 +106,11 @@ async def deactivate(self): # Send M18\r\n command await self.serial.write(b"M18\r\n") # Read response (should be "ok\r\nok\r\n") - response = await self.serial.readline() + response1 = await self.serial.readline() + response2 = await self.serial.readline() # Verify we got the expected response - if b"ok" not in response: - response2 = await self.serial.readline() - if b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response} {response2}") + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError(f"Unexpected response from device: {response1} {response2}") async def get_current_temperature(self) -> float: if USE_OT: @@ -131,9 +129,12 @@ async def get_current_temperature(self) -> float: response = await self.serial.readline() # Verify we got the expected response if b"C" in response: + # Read response (should be "ok\r\nok\r\n") + response1 = await self.serial.readline() response2 = await self.serial.readline() - if b"ok" not in response2: - response3 = await self.serial.readline() - if b"ok" not in response3: - raise RuntimeError(f"Unexpected response from device: {response2} {response3}") + # Verify we got the expected response + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError(f"Unexpected response from device: {response1} {response2}") return float(response.strip().split(b"C:")[-1]) + else: + raise ValueError(f"Unexpected response from device: {response}") From 19ecfa83fcfc64b720cf715844594e7ef0577b84 Mon Sep 17 00:00:00 2001 From: David Nedrud Date: Tue, 28 Oct 2025 13:58:51 -0500 Subject: [PATCH 03/17] Separated Opentrons vs USB connection made a separate backend for controlling the temperature module using USB. Reverted opentrons_backend.py to original file. Edited opentrons.py to be default connection to Opentrons control --- .../temperature_controlling/opentrons.py | 4 +- .../opentrons_backend.py | 108 +++--------------- .../opentrons_backend_usb.py | 102 +++++++++++++++++ 3 files changed, 121 insertions(+), 93 deletions(-) create mode 100644 pylabrobot/temperature_controlling/opentrons_backend_usb.py diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/temperature_controlling/opentrons.py index 6364bade3a4..546ad9093c0 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/temperature_controlling/opentrons.py @@ -23,7 +23,7 @@ def __init__( opentrons_id: str, child_location: Coordinate = Coordinate.zero(), child: Optional[ItemizedResource] = None, - port: Optional[str] = None, + backend: Optional[OpentronsTemperatureModuleBackend] = None ): """Create a new Opentrons temperature module v2. @@ -37,7 +37,7 @@ def __init__( temperature controller module. """ - backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id, port=port) + backend = backend or OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id) super().__init__( name=name, diff --git a/pylabrobot/temperature_controlling/opentrons_backend.py b/pylabrobot/temperature_controlling/opentrons_backend.py index 40b444f8522..01367b847b6 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend.py +++ b/pylabrobot/temperature_controlling/opentrons_backend.py @@ -1,5 +1,5 @@ import sys -from typing import Optional, cast +from typing import cast from pylabrobot.temperature_controlling.backend import ( TemperatureControllerBackend, @@ -18,123 +18,49 @@ else: USE_OT = False -# Import serial for USB communication when USE_OT is False -try: - from pylabrobot.io.serial import Serial - - HAS_SERIAL = True -except ImportError: - HAS_SERIAL = False - class OpentronsTemperatureModuleBackend(TemperatureControllerBackend): """Opentrons temperature module backend.""" @property def supports_active_cooling(self) -> bool: - return True + return False - def __init__( - self, - opentrons_id: str, - port: Optional[str] = None, - ): + def __init__(self, opentrons_id: str): """Create a new Opentrons temperature module backend. Args: opentrons_id: Opentrons ID of the temperature module. Get it from `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. - If USE_OT is False, this can be any identifier. - port: Serial port for USB communication. Required when USE_OT is False. """ self.opentrons_id = opentrons_id - self.port = port - self.serial: Optional["Serial"] = None - if not USE_OT and not HAS_SERIAL: + if not USE_OT: raise RuntimeError( - "Neither Opentrons API nor pyserial is installed. " - "Please run pip install pylabrobot[opentrons] or pip install pyserial." + "Opentrons is not installed. Please run pip install pylabrobot[opentrons]." + f" Import error: {_OT_IMPORT_ERROR}." ) async def setup(self): - if USE_OT: - # No setup needed for opentrons API - pass - else: - # Setup serial communication for USB - if self.port is None: - raise RuntimeError("Serial port must be specified when USE_OT is False.") - self.serial = Serial(port=self.port, baudrate=115200, timeout=3) - await self.serial.setup() + pass async def stop(self): await self.deactivate() - if self.serial is not None: - await self.serial.stop() - self.serial = None def serialize(self) -> dict: - return {**super().serialize(), "opentrons_id": self.opentrons_id, "port": self.port} + return {**super().serialize(), "opentrons_id": self.opentrons_id} async def set_temperature(self, temperature: float): - if USE_OT: - ot_api.modules.temperature_module_set_temperature( - celsius=temperature, module_id=self.opentrons_id - ) - else: - # Send M104 command over serial to set temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M104 SXXX\r\n command - tmp_message = f"M104 S{temperature}\r\n" - await self.serial.write(tmp_message.encode('utf-8')) - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1} {response2}") + ot_api.modules.temperature_module_set_temperature( + celsius=temperature, module_id=self.opentrons_id + ) async def deactivate(self): - if USE_OT: - ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) - else: - # Send M18 command over serial to stop holding temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M18\r\n command - await self.serial.write(b"M18\r\n") - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1} {response2}") + ot_api.modules.temperature_module_deactivate(module_id=self.opentrons_id) async def get_current_temperature(self) -> float: - if USE_OT: - modules = ot_api.modules.list_connected_modules() - for module in modules: - if module["id"] == self.opentrons_id: - return cast(float, module["data"]["currentTemperature"]) - raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") - else: - # Send M105 command over serial to query temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M105\r\n command - await self.serial.write(b"M105\r\n") - # Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n") - response = await self.serial.readline() - # Verify we got the expected response - if b"C" in response: - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1} {response2}") - return float(response.strip().split(b"C:")[-1]) - else: - raise ValueError(f"Unexpected response from device: {response}") + modules = ot_api.modules.list_connected_modules() + for module in modules: + if module["id"] == self.opentrons_id: + return cast(float, module["data"]["currentTemperature"]) + raise RuntimeError(f"Module with id '{self.opentrons_id}' not found") diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py new file mode 100644 index 00000000000..3205dd38189 --- /dev/null +++ b/pylabrobot/temperature_controlling/opentrons_backend_usb.py @@ -0,0 +1,102 @@ +import sys +from typing import Optional, cast + +from pylabrobot.temperature_controlling.backend import ( + TemperatureControllerBackend, +) + +# Import serial for USB communication when USE_OT is False +try: + from pylabrobot.io.serial import Serial + HAS_SERIAL = True +except ImportError: + HAS_SERIAL = False + + +class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): + """Opentrons temperature module backend.""" + + @property + def supports_active_cooling(self) -> bool: + return True + + def __init__( + self, + port: Optional[str] = None, + ): + """Create a new Opentrons temperature module backend. + + Args: + port: Serial port for USB communication. Required when USE_OT is False. + """ + + self.port = port + self.serial: Optional["Serial"] = None + + if not HAS_SERIAL: + raise RuntimeError( + "Serial connection not working. Perhaps pyserial is not installed. " + ) + + async def setup(self): + # Setup serial communication for USB + if self.port is None: + raise RuntimeError("Serial port must be specified when USE_OT is False.") + self.serial = Serial(port=self.port, baudrate=115200, timeout=3) + await self.serial.setup() + + async def stop(self): + await self.deactivate() + if self.serial is not None: + await self.serial.stop() + self.serial = None + + def serialize(self) -> dict: + return {**super().serialize(), "port": self.port} + + async def set_temperature(self, temperature: float): + # Send M104 command over serial to set temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M104 SXXX\r\n command + tmp_message = f"M104 S{temperature}\r\n" + await self.serial.write(tmp_message.encode('utf-8')) + # Read response (should be "ok\r\nok\r\n") + response1 = await self.serial.readline() + response2 = await self.serial.readline() + # Verify we got the expected response + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError(f"Unexpected response from device: {response1} {response2}") + + async def deactivate(self): + # Send M18 command over serial to stop holding temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M18\r\n command + await self.serial.write(b"M18\r\n") + # Read response (should be "ok\r\nok\r\n") + response1 = await self.serial.readline() + response2 = await self.serial.readline() + # Verify we got the expected response + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError(f"Unexpected response from device: {response1} {response2}") + + async def get_current_temperature(self) -> float: + # Send M105 command over serial to query temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M105\r\n command + await self.serial.write(b"M105\r\n") + # Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n") + response = await self.serial.readline() + # Verify we got the expected response + if b"C" in response: + # Read response (should be "ok\r\nok\r\n") + response1 = await self.serial.readline() + response2 = await self.serial.readline() + # Verify we got the expected response + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError(f"Unexpected response from device: {response1} {response2}") + return float(response.strip().split(b"C:")[-1]) + else: + raise ValueError(f"Unexpected response from device: {response}") From 1ee3e7031b545f444782dab0a23e4b1649572d29 Mon Sep 17 00:00:00 2001 From: David Nedrud Date: Thu, 30 Oct 2025 13:11:53 -0500 Subject: [PATCH 04/17] Change default child_location in Opentrons Temperature module Updated default child_location to new Coordinate values based on dimensional drawing. --- pylabrobot/temperature_controlling/opentrons.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/temperature_controlling/opentrons.py index 546ad9093c0..ce42c7027d5 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/temperature_controlling/opentrons.py @@ -21,7 +21,7 @@ def __init__( self, name: str, opentrons_id: str, - child_location: Coordinate = Coordinate.zero(), + child_location: Coordinate = Coordinate(0, 0, 80.1), # dimensional drawing from OT (x and y are not changed wrt parent) child: Optional[ItemizedResource] = None, backend: Optional[OpentronsTemperatureModuleBackend] = None ): From 935bd8c0f9a1450f226f43da8c00ef2290858970 Mon Sep 17 00:00:00 2001 From: David Nedrud Date: Thu, 30 Oct 2025 16:29:28 -0500 Subject: [PATCH 05/17] Refactor USB backend serial import and error handling Removed conditional import for serial communication and removed unused imports --- .../opentrons_backend_usb.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py index 3205dd38189..b297219725e 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/temperature_controlling/opentrons_backend_usb.py @@ -1,16 +1,11 @@ -import sys -from typing import Optional, cast +from typing import Optional from pylabrobot.temperature_controlling.backend import ( TemperatureControllerBackend, ) -# Import serial for USB communication when USE_OT is False -try: - from pylabrobot.io.serial import Serial - HAS_SERIAL = True -except ImportError: - HAS_SERIAL = False +# Import serial for USB communication +from pylabrobot.io.serial import Serial class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): @@ -33,11 +28,6 @@ def __init__( self.port = port self.serial: Optional["Serial"] = None - if not HAS_SERIAL: - raise RuntimeError( - "Serial connection not working. Perhaps pyserial is not installed. " - ) - async def setup(self): # Setup serial communication for USB if self.port is None: From d57a996e87a3c953ad80f44214d63455b52efc35 Mon Sep 17 00:00:00 2001 From: nedru004 Date: Thu, 30 Oct 2025 17:11:14 -0500 Subject: [PATCH 06/17] Fix type error Decode message from opentrons temperature module --- .../temperature_controlling/opentrons_backend_usb.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py index b297219725e..557dc3016de 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/temperature_controlling/opentrons_backend_usb.py @@ -56,7 +56,7 @@ async def set_temperature(self, temperature: float): response2 = await self.serial.readline() # Verify we got the expected response if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1} {response2}") + raise RuntimeError(f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}") async def deactivate(self): # Send M18 command over serial to stop holding temperature @@ -69,7 +69,7 @@ async def deactivate(self): response2 = await self.serial.readline() # Verify we got the expected response if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1} {response2}") + raise RuntimeError(f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}") async def get_current_temperature(self) -> float: # Send M105 command over serial to query temperature @@ -86,7 +86,7 @@ async def get_current_temperature(self) -> float: response2 = await self.serial.readline() # Verify we got the expected response if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1} {response2}") + raise RuntimeError(f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}") return float(response.strip().split(b"C:")[-1]) else: - raise ValueError(f"Unexpected response from device: {response}") + raise ValueError(f"Unexpected response from device: {response.decode(encoding="utf-8")}") From f5476cadae2652a90255b822a4d4a50dc04a2583 Mon Sep 17 00:00:00 2001 From: nedru004 Date: Fri, 31 Oct 2025 09:00:28 -0500 Subject: [PATCH 07/17] update formatting used ruff to reformat code --- .../temperature_controlling/opentrons.py | 6 +- .../opentrons_backend_usb.py | 92 ++++++++++--------- 2 files changed, 53 insertions(+), 45 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/temperature_controlling/opentrons.py index ce42c7027d5..95e37fcc17e 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/temperature_controlling/opentrons.py @@ -21,9 +21,11 @@ def __init__( self, name: str, opentrons_id: str, - child_location: Coordinate = Coordinate(0, 0, 80.1), # dimensional drawing from OT (x and y are not changed wrt parent) + child_location: Coordinate = Coordinate( + 0, 0, 80.1 + ), # dimensional drawing from OT (x and y are not changed wrt parent) child: Optional[ItemizedResource] = None, - backend: Optional[OpentronsTemperatureModuleBackend] = None + backend: Optional[OpentronsTemperatureModuleBackend] = None, ): """Create a new Opentrons temperature module v2. diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py index 557dc3016de..c0c091008c9 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/temperature_controlling/opentrons_backend_usb.py @@ -29,11 +29,11 @@ def __init__( self.serial: Optional["Serial"] = None async def setup(self): - # Setup serial communication for USB - if self.port is None: - raise RuntimeError("Serial port must be specified when USE_OT is False.") - self.serial = Serial(port=self.port, baudrate=115200, timeout=3) - await self.serial.setup() + # Setup serial communication for USB + if self.port is None: + raise RuntimeError("Serial port must be specified when USE_OT is False.") + self.serial = Serial(port=self.port, baudrate=115200, timeout=3) + await self.serial.setup() async def stop(self): await self.deactivate() @@ -45,48 +45,54 @@ def serialize(self) -> dict: return {**super().serialize(), "port": self.port} async def set_temperature(self, temperature: float): - # Send M104 command over serial to set temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M104 SXXX\r\n command - tmp_message = f"M104 S{temperature}\r\n" - await self.serial.write(tmp_message.encode('utf-8')) - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}") + # Send M104 command over serial to set temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M104 SXXX\r\n command + tmp_message = f"M104 S{temperature}\r\n" + await self.serial.write(tmp_message.encode("utf-8")) + # Read response (should be "ok\r\nok\r\n") + response1 = await self.serial.readline() + response2 = await self.serial.readline() + # Verify we got the expected response + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}" + ) async def deactivate(self): - # Send M18 command over serial to stop holding temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M18\r\n command - await self.serial.write(b"M18\r\n") + # Send M18 command over serial to stop holding temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M18\r\n command + await self.serial.write(b"M18\r\n") + # Read response (should be "ok\r\nok\r\n") + response1 = await self.serial.readline() + response2 = await self.serial.readline() + # Verify we got the expected response + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}" + ) + + async def get_current_temperature(self) -> float: + # Send M105 command over serial to query temperature + if self.serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + # Send M105\r\n command + await self.serial.write(b"M105\r\n") + # Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n") + response = await self.serial.readline() + # Verify we got the expected response + if b"C" in response: # Read response (should be "ok\r\nok\r\n") response1 = await self.serial.readline() response2 = await self.serial.readline() # Verify we got the expected response if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}") - - async def get_current_temperature(self) -> float: - # Send M105 command over serial to query temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M105\r\n command - await self.serial.write(b"M105\r\n") - # Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n") - response = await self.serial.readline() - # Verify we got the expected response - if b"C" in response: - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError(f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}") - return float(response.strip().split(b"C:")[-1]) - else: - raise ValueError(f"Unexpected response from device: {response.decode(encoding="utf-8")}") + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}" + ) + return float(response.strip().split(b"C:")[-1]) + else: + raise ValueError(f"Unexpected response from device: {response.decode(encoding="utf-8")}") From 31c242a7c045fdece99778130f3f14cd7481b7c5 Mon Sep 17 00:00:00 2001 From: nedru004 Date: Fri, 31 Oct 2025 13:25:38 -0500 Subject: [PATCH 08/17] Fix more formatting Import formatting and f string formatting --- .../opentrons_backend_usb.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py index c0c091008c9..74c7c050a06 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/temperature_controlling/opentrons_backend_usb.py @@ -1,12 +1,11 @@ from typing import Optional +# Import serial for USB communication +from pylabrobot.io.serial import Serial from pylabrobot.temperature_controlling.backend import ( TemperatureControllerBackend, ) -# Import serial for USB communication -from pylabrobot.io.serial import Serial - class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): """Opentrons temperature module backend.""" @@ -57,7 +56,7 @@ async def set_temperature(self, temperature: float): # Verify we got the expected response if b"ok" not in response1 or b"ok" not in response2: raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}" + f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" ) async def deactivate(self): @@ -72,7 +71,7 @@ async def deactivate(self): # Verify we got the expected response if b"ok" not in response1 or b"ok" not in response2: raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}" + f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" ) async def get_current_temperature(self) -> float: @@ -91,8 +90,8 @@ async def get_current_temperature(self) -> float: # Verify we got the expected response if b"ok" not in response1 or b"ok" not in response2: raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding="utf-8")} {response2.decode(encoding="utf-8")}" + f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" ) return float(response.strip().split(b"C:")[-1]) else: - raise ValueError(f"Unexpected response from device: {response.decode(encoding="utf-8")}") + raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}") From a6942842e70fb57731aebd598eb907945a795370 Mon Sep 17 00:00:00 2001 From: David Nedrud Date: Mon, 3 Nov 2025 13:30:34 -0600 Subject: [PATCH 09/17] Revise setup and usage instructions in user guide Updated setup instructions and usage details for the Opentrons temperature controller using USB. --- .../ot-temperature-controller.ipynb | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb index cc4d11872cf..8c99d7373de 100644 --- a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb +++ b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb @@ -28,6 +28,8 @@ "source": [ "---\n", "## Setup Instructions (Programmatic)\n", + "\n", + "### Setup with Opentrons" "\n" ] }, @@ -48,7 +50,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Using the Opentrons temperature controller currently requires an Opentrons robot. The robot must be connected to the host computer and to the temperature module." + "Using the Opentrons temperature controller can be controlled an Opentrons robot. The robot must be connected to the host computer and to the temperature module." ] }, { @@ -164,6 +166,35 @@ "lh.deck.assign_child_at_slot(t, slot=3)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---\n", + "\n", + "### Setup with Com Port" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To communicate with the temperature module, find the COM port for the USB connection. Use that to replace COM# when setting up the temperature module in your code." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.temperature_controlling.opentrons import OpentronsTemperatureModuleV2\n" + "from pylabrobot.temperature_controlling.opentrons_backend import OpentronsTemperatureModuleUSBBackend\n" + "\n" + "tc = OpentronsTemperatureModuleV2(name='tc', opentrons_id=None, backend=OpentronsTemperatureModuleUSBBackend(port='COM#'))\n" + "await tc.setup()\n" + ] + }, { "cell_type": "markdown", "metadata": {}, From d8f470b6cd70567cb93c125f4599aab6753f5eb8 Mon Sep 17 00:00:00 2001 From: nedru004 Date: Mon, 3 Nov 2025 13:34:49 -0600 Subject: [PATCH 10/17] Revert "Revise setup and usage instructions in user guide" This reverts commit a6942842e70fb57731aebd598eb907945a795370. --- .../ot-temperature-controller.ipynb | 33 +------------------ 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb index 8c99d7373de..cc4d11872cf 100644 --- a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb +++ b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb @@ -28,8 +28,6 @@ "source": [ "---\n", "## Setup Instructions (Programmatic)\n", - "\n", - "### Setup with Opentrons" "\n" ] }, @@ -50,7 +48,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Using the Opentrons temperature controller can be controlled an Opentrons robot. The robot must be connected to the host computer and to the temperature module." + "Using the Opentrons temperature controller currently requires an Opentrons robot. The robot must be connected to the host computer and to the temperature module." ] }, { @@ -166,35 +164,6 @@ "lh.deck.assign_child_at_slot(t, slot=3)" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "---\n", - "\n", - "### Setup with Com Port" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "To communicate with the temperature module, find the COM port for the USB connection. Use that to replace COM# when setting up the temperature module in your code." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": {}, - "outputs": [], - "source": [ - "from pylabrobot.temperature_controlling.opentrons import OpentronsTemperatureModuleV2\n" - "from pylabrobot.temperature_controlling.opentrons_backend import OpentronsTemperatureModuleUSBBackend\n" - "\n" - "tc = OpentronsTemperatureModuleV2(name='tc', opentrons_id=None, backend=OpentronsTemperatureModuleUSBBackend(port='COM#'))\n" - "await tc.setup()\n" - ] - }, { "cell_type": "markdown", "metadata": {}, From 404d16a0d8393d50ebe0309a3a5554ff4631e75e Mon Sep 17 00:00:00 2001 From: nedru004 Date: Mon, 3 Nov 2025 13:40:38 -0600 Subject: [PATCH 11/17] Update user guide for opentrons temperature controller Add com port control --- .../ot-temperature-controller.ipynb | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb index cc4d11872cf..db6185560b9 100644 --- a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb +++ b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb @@ -8,7 +8,7 @@ "\n", "| Summary | Photo |\n", "|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n", - "| - [OEM Link](https://opentrons.com/products/temperature-module-gen2?sku=991-00350-0)
- **Communication Protocol / Hardware**: ? / USB-A (currently only supported via direct connection into OT-2)
- **Communication Level**: ?

- **OEM version**: GEN2
- **Temperature range**: 4 to 95°C
| ![quadrants](img/ot_temperature_module_gen2.webp) |\n", + "| - [OEM Link](https://opentrons.com/products/temperature-module-gen2?sku=991-00350-0)
- **Communication Protocol / Hardware**: ? / USB-A
- **Communication Level**: ?

- **OEM version**: GEN2
- **Temperature range**: 4 to 95°C
| ![quadrants](img/ot_temperature_module_gen2.webp) |\n", "\n" ] }, @@ -19,7 +19,7 @@ "---\n", "## Setup Instructions (Physical)\n", "\n", - "WIP" + "Connect with USB to opentrons or to computer running pylabrobot" ] }, { @@ -28,7 +28,9 @@ "source": [ "---\n", "## Setup Instructions (Programmatic)\n", - "\n" + "\n", + "\n", + "### Setup with Opentrons" ] }, { @@ -164,6 +166,37 @@ "lh.deck.assign_child_at_slot(t, slot=3)" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "---\n", + "\n", + "### Setup with Com Port" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "To communicate with the temperature module, find the COM port for the USB connection. Use that to replace COM# when setting up the temperature module in your code." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pylabrobot.temperature_controlling.opentrons import OpentronsTemperatureModuleV2\n", + "from pylabrobot.temperature_controlling.opentrons_backend import OpentronsTemperatureModuleUSBBackend\n", + "\n", + "tc = OpentronsTemperatureModuleV2(name='tc', opentrons_id=None, backend=OpentronsTemperatureModuleUSBBackend(port='COM#'))\n", + "await tc.setup()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -329,7 +362,7 @@ ], "metadata": { "kernelspec": { - "display_name": "env", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -343,9 +376,9 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.12" + "version": "3.12.9" } }, "nbformat": 4, - "nbformat_minor": 2 + "nbformat_minor": 4 } From f008d8fecca01cfe315db87caf47c7a47c2beb58 Mon Sep 17 00:00:00 2001 From: nedru004 Date: Mon, 3 Nov 2025 13:41:56 -0600 Subject: [PATCH 12/17] Fix user document fix error --- .../temperature-controllers/ot-temperature-controller.ipynb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb index db6185560b9..5a01049ccb0 100644 --- a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb +++ b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb @@ -167,10 +167,8 @@ ] }, { - "cell_type": "code", - "execution_count": null, + "cell_type": "markdown", "metadata": {}, - "outputs": [], "source": [ "---\n", "\n", From d41b284fc5ba133951353e75185c1825788678ce Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 3 Nov 2025 20:37:05 -0800 Subject: [PATCH 13/17] OpentronsTemperatureModuleUSBBackend port cannot be none --- .../temperature_controlling/opentrons_backend_usb.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py index 74c7c050a06..6dfe99cabc9 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/temperature_controlling/opentrons_backend_usb.py @@ -14,10 +14,7 @@ class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend): def supports_active_cooling(self) -> bool: return True - def __init__( - self, - port: Optional[str] = None, - ): + def __init__(self, port: str): """Create a new Opentrons temperature module backend. Args: @@ -29,8 +26,6 @@ def __init__( async def setup(self): # Setup serial communication for USB - if self.port is None: - raise RuntimeError("Serial port must be specified when USE_OT is False.") self.serial = Serial(port=self.port, baudrate=115200, timeout=3) await self.serial.setup() From 5274c291975a8c478a62782944ddc6023b401969 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 3 Nov 2025 20:39:26 -0800 Subject: [PATCH 14/17] simplify --- .../opentrons_backend_usb.py | 54 ++++++++----------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons_backend_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py index 6dfe99cabc9..04cd776d713 100644 --- a/pylabrobot/temperature_controlling/opentrons_backend_usb.py +++ b/pylabrobot/temperature_controlling/opentrons_backend_usb.py @@ -1,6 +1,5 @@ from typing import Optional -# Import serial for USB communication from pylabrobot.io.serial import Serial from pylabrobot.temperature_controlling.backend import ( TemperatureControllerBackend, @@ -18,31 +17,33 @@ def __init__(self, port: str): """Create a new Opentrons temperature module backend. Args: - port: Serial port for USB communication. Required when USE_OT is False. + port: Serial port for USB communication. """ self.port = port - self.serial: Optional["Serial"] = None + self._serial: Optional["Serial"] = None + + @property + def serial(self) -> "Serial": + if self._serial is None: + raise RuntimeError("Serial device not initialized. Call setup() first.") + return self._serial async def setup(self): # Setup serial communication for USB - self.serial = Serial(port=self.port, baudrate=115200, timeout=3) - await self.serial.setup() + self._serial = Serial(port=self.port, baudrate=115200, timeout=3) + await self._serial.setup() async def stop(self): await self.deactivate() - if self.serial is not None: - await self.serial.stop() - self.serial = None + if self._serial is not None: + await self._serial.stop() + self._serial = None def serialize(self) -> dict: return {**super().serialize(), "port": self.port} async def set_temperature(self, temperature: float): - # Send M104 command over serial to set temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M104 SXXX\r\n command tmp_message = f"M104 S{temperature}\r\n" await self.serial.write(tmp_message.encode("utf-8")) # Read response (should be "ok\r\nok\r\n") @@ -55,10 +56,6 @@ async def set_temperature(self, temperature: float): ) async def deactivate(self): - # Send M18 command over serial to stop holding temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M18\r\n command await self.serial.write(b"M18\r\n") # Read response (should be "ok\r\nok\r\n") response1 = await self.serial.readline() @@ -70,23 +67,18 @@ async def deactivate(self): ) async def get_current_temperature(self) -> float: - # Send M105 command over serial to query temperature - if self.serial is None: - raise RuntimeError("Serial device not initialized. Call setup() first.") - # Send M105\r\n command await self.serial.write(b"M105\r\n") # Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n") response = await self.serial.readline() # Verify we got the expected response - if b"C" in response: - # Read response (should be "ok\r\nok\r\n") - response1 = await self.serial.readline() - response2 = await self.serial.readline() - # Verify we got the expected response - if b"ok" not in response1 or b"ok" not in response2: - raise RuntimeError( - f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" - ) - return float(response.strip().split(b"C:")[-1]) - else: + # Read response (should be "ok\r\nok\r\n") + if b"C" not in response: raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}") + + response1 = await self.serial.readline() + response2 = await self.serial.readline() + if b"ok" not in response1 or b"ok" not in response2: + raise RuntimeError( + f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}" + ) + return float(response.strip().split(b"C:")[-1]) From c1baed7cca93df4fcb63458640d3d8861f053f4a Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 3 Nov 2025 20:42:10 -0800 Subject: [PATCH 15/17] simplify resource class --- .../temperature_controlling/opentrons.py | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/temperature_controlling/opentrons.py index 95e37fcc17e..143d12e1ccd 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/temperature_controlling/opentrons.py @@ -5,6 +5,9 @@ from pylabrobot.temperature_controlling.opentrons_backend import ( OpentronsTemperatureModuleBackend, ) +from pylabrobot.temperature_controlling.opentrons_backend_usb import ( + OpentronsTemperatureModuleUSBBackend, +) from pylabrobot.temperature_controlling.temperature_controller import ( TemperatureController, ) @@ -20,26 +23,34 @@ class OpentronsTemperatureModuleV2(TemperatureController, OTModule): def __init__( self, name: str, - opentrons_id: str, + opentrons_id: Optional[str] = None, + serial_port: Optional[str] = None, child_location: Coordinate = Coordinate( 0, 0, 80.1 ), # dimensional drawing from OT (x and y are not changed wrt parent) child: Optional[ItemizedResource] = None, - backend: Optional[OpentronsTemperatureModuleBackend] = None, ): """Create a new Opentrons temperature module v2. Args: name: Name of the temperature module. opentrons_id: Opentrons ID of the temperature module. Get it from - `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. - If USE_OT is False, this can be any identifier. - port: Serial port for USB communication. Required when USE_OT is False. + `OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. Exactly one of `opentrons_id` or `serial_port` must be provided. + serial_port: Serial port for USB communication. Exactly one of `opentrons_id` or `serial_port` must be provided. child: Optional child resource like a tube rack or well plate to use on the temperature controller module. """ - backend = backend or OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id) + if opentrons_id is None and serial_port is None: + raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") + if opentrons_id is not None and serial_port is not None: + raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") + + if serial_port is not None: + backend = OpentronsTemperatureModuleUSBBackend(port=serial_port) + else: + assert opentrons_id is not None + backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id) super().__init__( name=name, From 273165f81a33460574e68f1cf4c74f808d72e16d Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 3 Nov 2025 20:44:05 -0800 Subject: [PATCH 16/17] doc --- .../temperature-controllers/ot-temperature-controller.ipynb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb index 5a01049ccb0..0c14c08ad9f 100644 --- a/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb +++ b/docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb @@ -179,7 +179,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "To communicate with the temperature module, find the COM port for the USB connection. Use that to replace COM# when setting up the temperature module in your code." + "To communicate with the temperature module, find the COM port for the USB connection. Use that to replace `COM#` when setting up the temperature module in your code." ] }, { @@ -189,9 +189,8 @@ "outputs": [], "source": [ "from pylabrobot.temperature_controlling.opentrons import OpentronsTemperatureModuleV2\n", - "from pylabrobot.temperature_controlling.opentrons_backend import OpentronsTemperatureModuleUSBBackend\n", "\n", - "tc = OpentronsTemperatureModuleV2(name='tc', opentrons_id=None, backend=OpentronsTemperatureModuleUSBBackend(port='COM#'))\n", + "tc = OpentronsTemperatureModuleV2(name='tc', opentrons_id=None, serial_port=\"OM#\")\n", "await tc.setup()" ] }, From 51ef2990fdf42eea37f09ef0f3f4c9ca7eb83b28 Mon Sep 17 00:00:00 2001 From: Rick Wierenga Date: Mon, 3 Nov 2025 20:46:54 -0800 Subject: [PATCH 17/17] type --- pylabrobot/temperature_controlling/opentrons.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/temperature_controlling/opentrons.py index 143d12e1ccd..771789c3aa3 100644 --- a/pylabrobot/temperature_controlling/opentrons.py +++ b/pylabrobot/temperature_controlling/opentrons.py @@ -2,6 +2,7 @@ from pylabrobot.resources import Coordinate, ItemizedResource from pylabrobot.resources.opentrons.module import OTModule +from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend from pylabrobot.temperature_controlling.opentrons_backend import ( OpentronsTemperatureModuleBackend, ) @@ -46,6 +47,7 @@ def __init__( if opentrons_id is not None and serial_port is not None: raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.") + backend: TemperatureControllerBackend if serial_port is not None: backend = OpentronsTemperatureModuleUSBBackend(port=serial_port) else: