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..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
@@ -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
|  |\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
|  |\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,34 @@
"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": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pylabrobot.temperature_controlling.opentrons import OpentronsTemperatureModuleV2\n",
+ "\n",
+ "tc = OpentronsTemperatureModuleV2(name='tc', opentrons_id=None, serial_port=\"OM#\")\n",
+ "await tc.setup()"
+ ]
+ },
{
"cell_type": "markdown",
"metadata": {},
@@ -329,7 +359,7 @@
],
"metadata": {
"kernelspec": {
- "display_name": "env",
+ "display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
@@ -343,9 +373,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
}
diff --git a/pylabrobot/temperature_controlling/opentrons.py b/pylabrobot/temperature_controlling/opentrons.py
index 6f3f45303c2..771789c3aa3 100644
--- a/pylabrobot/temperature_controlling/opentrons.py
+++ b/pylabrobot/temperature_controlling/opentrons.py
@@ -2,9 +2,13 @@
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,
)
+from pylabrobot.temperature_controlling.opentrons_backend_usb import (
+ OpentronsTemperatureModuleUSBBackend,
+)
from pylabrobot.temperature_controlling.temperature_controller import (
TemperatureController,
)
@@ -20,8 +24,11 @@ class OpentronsTemperatureModuleV2(TemperatureController, OTModule):
def __init__(
self,
name: str,
- opentrons_id: str,
- child_location: Coordinate = Coordinate.zero(),
+ 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,
):
"""Create a new Opentrons temperature module v2.
@@ -29,23 +36,35 @@ def __init__(
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()`.
+ `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.
"""
+ 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.")
+
+ backend: TemperatureControllerBackend
+ 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,
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_usb.py b/pylabrobot/temperature_controlling/opentrons_backend_usb.py
new file mode 100644
index 00000000000..04cd776d713
--- /dev/null
+++ b/pylabrobot/temperature_controlling/opentrons_backend_usb.py
@@ -0,0 +1,84 @@
+from typing import Optional
+
+from pylabrobot.io.serial import Serial
+from pylabrobot.temperature_controlling.backend import (
+ TemperatureControllerBackend,
+)
+
+
+class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend):
+ """Opentrons temperature module backend."""
+
+ @property
+ def supports_active_cooling(self) -> bool:
+ return True
+
+ def __init__(self, port: str):
+ """Create a new Opentrons temperature module backend.
+
+ Args:
+ port: Serial port for USB communication.
+ """
+
+ self.port = port
+ 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()
+
+ 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):
+ 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):
+ 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:
+ 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
+ # 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])