Skip to content

Commit 63e1d7e

Browse files
committed
feat(api): add a script to update modules without stopping the robot-server.
1 parent a4be1d6 commit 63e1d7e

File tree

1 file changed

+274
-0
lines changed

1 file changed

+274
-0
lines changed
Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
"""Module Firmware update script."""
2+
import argparse
3+
import asyncio
4+
from glob import glob
5+
import os
6+
import re
7+
import subprocess
8+
import sys
9+
from typing import Dict, Final, List, Optional
10+
11+
from opentrons.drivers.rpi_drivers import usb
12+
from opentrons.hardware_control.module_control import MODULE_PORT_REGEX
13+
from opentrons.hardware_control import modules
14+
from opentrons.hardware_control.modules.mod_abc import AbstractModule
15+
from opentrons.hardware_control.modules.update import update_firmware
16+
from opentrons.hardware_control.types import BoardRevision
17+
18+
19+
# Constants for checking if module is back online
20+
ONLINE_RETRIES = 3
21+
DELAY_S = 5
22+
23+
24+
MODULES: Final[Dict[str, str]] = {
25+
"temp-deck": "tempdeck",
26+
"mag-deck": "magdeck",
27+
"thermocycler": "thermocycler",
28+
"heater-shaker": "heatershaker",
29+
"absorbance-reader": "absorbancereader",
30+
"flex-stacker": "flexstacker",
31+
}
32+
33+
34+
def parse_version(filepath: str) -> str:
35+
"""Parse the version string from the filename."""
36+
_, ext = os.path.splitext(os.path.basename(filepath))
37+
ext_pattern = re.escape(ext.lstrip("."))
38+
pattern = rf"@(v\d+(?:\.\d+)*)\.{ext_pattern}"
39+
match = re.search(pattern, os.path.basename(filepath))
40+
return match.group(1) if match else ""
41+
42+
43+
def scan_connected_modules() -> List[modules.ModuleAtPort]:
44+
"""Scan for connected modules and return list of
45+
tuples of serial ports and device names
46+
"""
47+
discovered_modules = []
48+
devices = glob("/dev/ot_module*")
49+
for port in devices:
50+
symlink_port = port.split("dev/")[1]
51+
module_at_port = get_module_at_port(symlink_port)
52+
if module_at_port:
53+
discovered_modules.append(module_at_port)
54+
return discovered_modules
55+
56+
57+
def get_module_at_port(port: str) -> Optional[modules.ModuleAtPort]:
58+
"""Given a port, returns either a ModuleAtPort
59+
if it is a recognized module, or None if not recognized.
60+
"""
61+
match = MODULE_PORT_REGEX.search(port)
62+
if match:
63+
name = match.group(1).lower()
64+
if name not in modules.MODULE_TYPE_BY_NAME:
65+
print(f"Unexpected module connected: {name} on {port}")
66+
return None
67+
return modules.ModuleAtPort(port=f"/dev/{port}", name=name)
68+
return None
69+
70+
71+
async def build_module(
72+
mod: modules.ModuleAtPort, loop: asyncio.AbstractEventLoop
73+
) -> Optional[AbstractModule]:
74+
try:
75+
# Get the device path and
76+
port = subprocess.check_output(["readlink", "-f", mod.port]).decode().strip()
77+
# remove the symlink for the device, so its freed by the robot-server
78+
print(f"Removing symlink {mod.port}")
79+
subprocess.check_call(["unlink", mod.port])
80+
# wwait some time to let the device teardown
81+
await asyncio.sleep(2)
82+
# create an instance of the module using the device path
83+
return await modules.build(
84+
port=port,
85+
usb_port=mod.usb_port,
86+
type=modules.MODULE_TYPE_BY_NAME[mod.name],
87+
simulating=False,
88+
hw_control_loop=loop,
89+
)
90+
except Exception:
91+
return None
92+
93+
94+
async def teardown_module(module: AbstractModule) -> None:
95+
"""Tearsdown the module so it can be used again by the robot-server.."""
96+
name = module.name()
97+
serial = module.device_info["serial"]
98+
port_name = module.usb_port.name
99+
command = f"echo '{port_name}' | tee /sys/bus/usb/drivers/usb"
100+
print(f"Removing module: {name} {serial}")
101+
try:
102+
# stop the poller and disconnect serial
103+
await module._poller.stop() # type: ignore
104+
await module._driver.disconnect() # type: ignore
105+
# unbind the device from usb and re-bind to simulate unplug
106+
subprocess.run(f"{command}/unbind", shell=True, capture_output=True)
107+
await asyncio.sleep(2)
108+
subprocess.run(f"{command}/bind", shell=True, capture_output=True)
109+
except Exception:
110+
pass
111+
112+
113+
def enable_udev_rules(enable: bool) -> None:
114+
"""Enable/Disable creation of opentrons modules by the hardware controller.
115+
116+
This is done so the module is not automatically picked up by the server
117+
while we are updating it.
118+
"""
119+
rule = "95-opentrons-modules.rules"
120+
original = f"/etc/udev/rules.d/{rule}"
121+
destination = f"/var/lib/{rule}"
122+
src = original if not enable else destination
123+
dst = destination if not enable else original
124+
msg = "Disabl" if not enable else "Enabl"
125+
if not os.path.exists(src):
126+
sys.exit(f"ERROR: Rule file not found: {src}")
127+
128+
try:
129+
print(f"{msg}ing udev: Moving rule {src} -> {dst}")
130+
subprocess.check_call(["mount", "-o", "remount,rw", "/"])
131+
subprocess.check_call(["mv", src, dst])
132+
except Exception as e:
133+
sys.exit(
134+
f"ERROR: Could not {msg}e udev rule: {rule}\n{e}",
135+
)
136+
finally:
137+
subprocess.check_call(["mount", "-o", "remount,ro", "/"])
138+
subprocess.check_call(["udevadm", "control", "--reload-rules"])
139+
140+
141+
def check_dev_exist(port: str) -> bool:
142+
"""True if the device with the given port exists in /dev."""
143+
try:
144+
return subprocess.run(["ls", port], capture_output=True).returncode == 0
145+
except Exception:
146+
return False
147+
148+
149+
async def main(args: argparse.Namespace) -> None: # noqa: C901
150+
"""Entry point for script."""
151+
mod_name = MODULES[args.module]
152+
target_version = parse_version(args.file)
153+
if not os.path.exists(args.file):
154+
sys.exit(f"Invalid filepath: {args.file}")
155+
if not target_version:
156+
sys.exit(f"Target version could not be parsed from file: {args.file}")
157+
158+
print("Setting up...")
159+
loop = asyncio.get_running_loop()
160+
usb_bus = usb.USBBus(BoardRevision.FLEX_B2) # todo: get this from the robot
161+
print(f"Searching for {mod_name} modules in /dev/")
162+
mods = scan_connected_modules()
163+
mods = usb_bus.match_virtual_ports(mods) # type: ignore
164+
if not mods:
165+
print("No modules found")
166+
return
167+
168+
# Disable udev rules so modules aren't re-created by the server when they update
169+
teardown_modules: List[AbstractModule] = []
170+
enable_udev_rules(False)
171+
print("\n------------------------------------------")
172+
for mod in mods:
173+
if mod_name not in mod.name:
174+
continue
175+
176+
# Create an instance of the opentrons module
177+
print(f"Found mod: {mod.name} at {mod.port}")
178+
module = await build_module(mod, loop)
179+
if module is None:
180+
continue
181+
182+
name = module.name()
183+
version = module.device_info["version"]
184+
serial = module.device_info["serial"]
185+
model = module.device_info["model"]
186+
teardown_modules.append(module)
187+
print(f"Created module: {module.name()} {model} {serial} {version}")
188+
189+
# Check that the update file is for this module
190+
file_prefix = module.firmware_prefix()
191+
if file_prefix not in args.file:
192+
print(f"ERROR: Target module does not match file: {mod_name} {args.file}")
193+
continue
194+
195+
# Check if the module is one we care about
196+
if args.serial and serial not in args.serial:
197+
continue
198+
199+
# Check if the module needs an update
200+
if version == target_version and not args.force:
201+
print(f"Module {name} {serial} is up-to-date.")
202+
continue
203+
204+
print(f"Updating {name} {model} {serial}: {version} -> {target_version}")
205+
await update_firmware(module, args.file)
206+
207+
# wait for the device to come online
208+
for retry in range(ONLINE_RETRIES):
209+
if retry >= ONLINE_RETRIES:
210+
print(f"Module {serial} failed to come back online.")
211+
break
212+
213+
print(f"Checking if {name} at {module.port} is online...")
214+
await asyncio.sleep(DELAY_S)
215+
if not check_dev_exist(module.port):
216+
print("Not online")
217+
continue
218+
219+
# re-open serial connection
220+
if not await module._driver.is_connected(): # type: ignore
221+
await module._driver.connect() # type: ignore
222+
223+
# refresh the device info
224+
print(f"Device {module.port} is back online, refreshing device info.")
225+
device_info = (await module._driver.get_device_info()).to_dict() # type: ignore
226+
success = device_info["version"] == target_version
227+
msg = "updated successfully!" if success else "failed to update"
228+
print(f"Device {name} {serial} {msg}")
229+
break
230+
231+
print("------------------------------------------\n")
232+
print("Tearing down")
233+
# Enable udev rules and teardown the module so they can be pick-up by the robot-server
234+
enable_udev_rules(True)
235+
for module in teardown_modules:
236+
await teardown_module(module)
237+
print("Done")
238+
239+
240+
if __name__ == "__main__":
241+
parser = argparse.ArgumentParser(description="Module FW Update.")
242+
parser.add_argument(
243+
"--module",
244+
help="The module target to be updated.",
245+
type=str,
246+
required=True,
247+
choices=MODULES.keys(),
248+
)
249+
parser.add_argument(
250+
"--file",
251+
help="""Path to binary file containing the FW executable"""
252+
"""Must have format `[email protected]/hex`, ex, [email protected]""",
253+
type=str,
254+
required=True,
255+
)
256+
parser.add_argument(
257+
"--serial",
258+
help="The specific serial numbers of the devices to update.",
259+
type=str,
260+
nargs="+",
261+
)
262+
parser.add_argument(
263+
"--force",
264+
help="Force install the update, even if the versions are the same.",
265+
action="store_true",
266+
default=False,
267+
)
268+
args = parser.parse_args()
269+
try:
270+
asyncio.run(main(args))
271+
except Exception as e:
272+
print("ERROR: Unhandled Exception: ", e)
273+
# Re-enable udev rules
274+
enable_udev_rules(True)

0 commit comments

Comments
 (0)