Skip to content

Commit db1a84f

Browse files
authored
Control Opentrons temperature module directly with USB (#735)
1 parent b83a102 commit db1a84f

File tree

3 files changed

+144
-11
lines changed

3 files changed

+144
-11
lines changed

docs/user_guide/01_material-handling/temperature-controllers/ot-temperature-controller.ipynb

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"\n",
99
"| Summary | Photo |\n",
1010
"|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|\n",
11-
"| - [OEM Link](https://opentrons.com/products/temperature-module-gen2?sku=991-00350-0)<br>- **Communication Protocol / Hardware**: ? / USB-A (currently only supported via direct connection into OT-2)<br>- **Communication Level**: ?<br><br>- **OEM version**: GEN2<br>- **Temperature range**: 4 to 95°C<br> | ![quadrants](img/ot_temperature_module_gen2.webp) |\n",
11+
"| - [OEM Link](https://opentrons.com/products/temperature-module-gen2?sku=991-00350-0)<br>- **Communication Protocol / Hardware**: ? / USB-A <br>- **Communication Level**: ?<br><br>- **OEM version**: GEN2<br>- **Temperature range**: 4 to 95°C<br> | ![quadrants](img/ot_temperature_module_gen2.webp) |\n",
1212
"\n"
1313
]
1414
},
@@ -19,7 +19,7 @@
1919
"---\n",
2020
"## Setup Instructions (Physical)\n",
2121
"\n",
22-
"WIP"
22+
"Connect with USB to opentrons or to computer running pylabrobot"
2323
]
2424
},
2525
{
@@ -28,7 +28,9 @@
2828
"source": [
2929
"---\n",
3030
"## Setup Instructions (Programmatic)\n",
31-
"\n"
31+
"\n",
32+
"\n",
33+
"### Setup with Opentrons"
3234
]
3335
},
3436
{
@@ -164,6 +166,34 @@
164166
"lh.deck.assign_child_at_slot(t, slot=3)"
165167
]
166168
},
169+
{
170+
"cell_type": "markdown",
171+
"metadata": {},
172+
"source": [
173+
"---\n",
174+
"\n",
175+
"### Setup with Com Port"
176+
]
177+
},
178+
{
179+
"cell_type": "markdown",
180+
"metadata": {},
181+
"source": [
182+
"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."
183+
]
184+
},
185+
{
186+
"cell_type": "code",
187+
"execution_count": null,
188+
"metadata": {},
189+
"outputs": [],
190+
"source": [
191+
"from pylabrobot.temperature_controlling.opentrons import OpentronsTemperatureModuleV2\n",
192+
"\n",
193+
"tc = OpentronsTemperatureModuleV2(name='tc', opentrons_id=None, serial_port=\"OM#\")\n",
194+
"await tc.setup()"
195+
]
196+
},
167197
{
168198
"cell_type": "markdown",
169199
"metadata": {},
@@ -329,7 +359,7 @@
329359
],
330360
"metadata": {
331361
"kernelspec": {
332-
"display_name": "env",
362+
"display_name": "Python 3 (ipykernel)",
333363
"language": "python",
334364
"name": "python3"
335365
},
@@ -343,9 +373,9 @@
343373
"name": "python",
344374
"nbconvert_exporter": "python",
345375
"pygments_lexer": "ipython3",
346-
"version": "3.10.12"
376+
"version": "3.12.9"
347377
}
348378
},
349379
"nbformat": 4,
350-
"nbformat_minor": 2
380+
"nbformat_minor": 4
351381
}

pylabrobot/temperature_controlling/opentrons.py

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22

33
from pylabrobot.resources import Coordinate, ItemizedResource
44
from pylabrobot.resources.opentrons.module import OTModule
5+
from pylabrobot.temperature_controlling.backend import TemperatureControllerBackend
56
from pylabrobot.temperature_controlling.opentrons_backend import (
67
OpentronsTemperatureModuleBackend,
78
)
9+
from pylabrobot.temperature_controlling.opentrons_backend_usb import (
10+
OpentronsTemperatureModuleUSBBackend,
11+
)
812
from pylabrobot.temperature_controlling.temperature_controller import (
913
TemperatureController,
1014
)
@@ -20,32 +24,47 @@ class OpentronsTemperatureModuleV2(TemperatureController, OTModule):
2024
def __init__(
2125
self,
2226
name: str,
23-
opentrons_id: str,
24-
child_location: Coordinate = Coordinate.zero(),
27+
opentrons_id: Optional[str] = None,
28+
serial_port: Optional[str] = None,
29+
child_location: Coordinate = Coordinate(
30+
0, 0, 80.1
31+
), # dimensional drawing from OT (x and y are not changed wrt parent)
2532
child: Optional[ItemizedResource] = None,
2633
):
2734
"""Create a new Opentrons temperature module v2.
2835
2936
Args:
3037
name: Name of the temperature module.
3138
opentrons_id: Opentrons ID of the temperature module. Get it from
32-
`OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`.
39+
`OpentronsBackend(host="x.x.x.x", port=31950).list_connected_modules()`. Exactly one of `opentrons_id` or `serial_port` must be provided.
40+
serial_port: Serial port for USB communication. Exactly one of `opentrons_id` or `serial_port` must be provided.
3341
child: Optional child resource like a tube rack or well plate to use on the
3442
temperature controller module.
3543
"""
3644

45+
if opentrons_id is None and serial_port is None:
46+
raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.")
47+
if opentrons_id is not None and serial_port is not None:
48+
raise ValueError("Exactly one of `opentrons_id` or `serial_port` must be provided.")
49+
50+
backend: TemperatureControllerBackend
51+
if serial_port is not None:
52+
backend = OpentronsTemperatureModuleUSBBackend(port=serial_port)
53+
else:
54+
assert opentrons_id is not None
55+
backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id)
56+
3757
super().__init__(
3858
name=name,
3959
size_x=193.5,
4060
size_y=89.2,
4161
size_z=84.0, # height without any aluminum block
4262
child_location=child_location,
43-
backend=OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id),
63+
backend=backend,
4464
category="temperature_controller",
4565
model="temperatureModuleV2", # Must match OT moduleModel in list_connected_modules()
4666
)
4767

48-
self.backend = OpentronsTemperatureModuleBackend(opentrons_id=opentrons_id)
4968
self.child = child
5069

5170
if child is not None:
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from typing import Optional
2+
3+
from pylabrobot.io.serial import Serial
4+
from pylabrobot.temperature_controlling.backend import (
5+
TemperatureControllerBackend,
6+
)
7+
8+
9+
class OpentronsTemperatureModuleUSBBackend(TemperatureControllerBackend):
10+
"""Opentrons temperature module backend."""
11+
12+
@property
13+
def supports_active_cooling(self) -> bool:
14+
return True
15+
16+
def __init__(self, port: str):
17+
"""Create a new Opentrons temperature module backend.
18+
19+
Args:
20+
port: Serial port for USB communication.
21+
"""
22+
23+
self.port = port
24+
self._serial: Optional["Serial"] = None
25+
26+
@property
27+
def serial(self) -> "Serial":
28+
if self._serial is None:
29+
raise RuntimeError("Serial device not initialized. Call setup() first.")
30+
return self._serial
31+
32+
async def setup(self):
33+
# Setup serial communication for USB
34+
self._serial = Serial(port=self.port, baudrate=115200, timeout=3)
35+
await self._serial.setup()
36+
37+
async def stop(self):
38+
await self.deactivate()
39+
if self._serial is not None:
40+
await self._serial.stop()
41+
self._serial = None
42+
43+
def serialize(self) -> dict:
44+
return {**super().serialize(), "port": self.port}
45+
46+
async def set_temperature(self, temperature: float):
47+
tmp_message = f"M104 S{temperature}\r\n"
48+
await self.serial.write(tmp_message.encode("utf-8"))
49+
# Read response (should be "ok\r\nok\r\n")
50+
response1 = await self.serial.readline()
51+
response2 = await self.serial.readline()
52+
# Verify we got the expected response
53+
if b"ok" not in response1 or b"ok" not in response2:
54+
raise RuntimeError(
55+
f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}"
56+
)
57+
58+
async def deactivate(self):
59+
await self.serial.write(b"M18\r\n")
60+
# Read response (should be "ok\r\nok\r\n")
61+
response1 = await self.serial.readline()
62+
response2 = await self.serial.readline()
63+
# Verify we got the expected response
64+
if b"ok" not in response1 or b"ok" not in response2:
65+
raise RuntimeError(
66+
f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}"
67+
)
68+
69+
async def get_current_temperature(self) -> float:
70+
await self.serial.write(b"M105\r\n")
71+
# Read response (should be "T:XX.XXX C:XX.XXX\r\nok\r\nok\r\n")
72+
response = await self.serial.readline()
73+
# Verify we got the expected response
74+
# Read response (should be "ok\r\nok\r\n")
75+
if b"C" not in response:
76+
raise ValueError(f"Unexpected response from device: {response.decode(encoding='utf-8')}")
77+
78+
response1 = await self.serial.readline()
79+
response2 = await self.serial.readline()
80+
if b"ok" not in response1 or b"ok" not in response2:
81+
raise RuntimeError(
82+
f"Unexpected response from device: {response1.decode(encoding='utf-8')} {response2.decode(encoding='utf-8')}"
83+
)
84+
return float(response.strip().split(b"C:")[-1])

0 commit comments

Comments
 (0)