Skip to content

Commit 0f92db2

Browse files
authored
feat(robot-server, api): Implement RobotServerPyroResource providing proxies of server assets (#21240)
Introduces RobotServerPyroResource to provide hardware listener callbacks, camera provider, and file provider over pyro.
1 parent 258e06f commit 0f92db2

File tree

25 files changed

+1135
-77
lines changed

25 files changed

+1135
-77
lines changed

api/src/opentrons/__init__.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import logging
22
import os
33
import re
4+
import socket
5+
import time
46
from pathlib import Path
5-
from typing import Any, List, Tuple
7+
from typing import Any, List, Tuple, cast
8+
9+
import Pyro5.api as pyro
10+
import Pyro5.errors as pyro_errors
611

712
from ._version import version
813
from opentrons.config import (
@@ -15,19 +20,22 @@
1520
feature_flags as ff,
1621
)
1722
from opentrons.drivers.serial_communication import get_ports_by_name
23+
from opentrons.hardware_control import API as HardwareAPI
1824
from opentrons.hardware_control import (
19-
API as HardwareAPI,
20-
)
21-
from opentrons.hardware_control import (
25+
HardwareControlAPI,
2226
ThreadManagedHardware,
2327
ThreadManager,
2428
)
2529
from opentrons.hardware_control import (
2630
types as hw_types,
2731
)
32+
from opentrons.hardware_control.pyro_utils.serpent_type_registry import (
33+
register_hardware_types,
34+
)
2835
from opentrons.protocols.api_support.types import APIVersion
2936
from opentrons.protocols.types import ApiDeprecationError
3037
from opentrons.util import logging_config
38+
from opentrons.util.pyro.pyro_client_async_adapter import AsyncClientPyroObject
3139

3240
HERE = os.path.abspath(os.path.dirname(__file__))
3341
__version__ = version
@@ -151,3 +159,41 @@ async def initialize() -> ThreadManagedHardware:
151159
log.info(f"Robot Name: {name()}")
152160

153161
return await _create_thread_manager()
162+
163+
164+
def identify_hardware_process() -> HardwareControlAPI:
165+
"""
166+
Identify the Pyro Proxy for the OT3API and return a wrapped hardware instance.
167+
"""
168+
robot_conf = robot_configs.load()
169+
logging_config.log_init(robot_conf.log_level)
170+
pyro.config.COMMTIMEOUT = 100
171+
try:
172+
# Find the OT3API on the nameserver
173+
# todo(chb, 03-31-2026): for now this is using the same methodology as the DirectedRunProcess work, consolidate
174+
ot3_process_proxy = None
175+
start_time = time.monotonic()
176+
with pyro.locate_ns() as ns:
177+
while time.monotonic() - start_time < 60:
178+
if "OT3API" in ns.list():
179+
ot3_process_proxy = pyro.Proxy(ns.list()["OT3API"]) # type: ignore[no-untyped-call]
180+
break
181+
time.sleep(0.01)
182+
183+
if ot3_process_proxy is None:
184+
raise pyro_errors.CommunicationError(
185+
"Opentrons-robot-server could not find OT3API URI on Pyro5 Nameserver."
186+
)
187+
else:
188+
ot3_process_async_client = AsyncClientPyroObject(ot3_process_proxy)
189+
hardware_api = cast(HardwareControlAPI, ot3_process_async_client)
190+
# Register hardware types for the robot server process
191+
register_hardware_types()
192+
log.info("Opentrons Hardware API Subprocess identified and ready for use.")
193+
194+
return hardware_api
195+
196+
except (pyro_errors.NamingError, pyro_errors.CommunicationError, socket.timeout):
197+
raise pyro_errors.CommunicationError(
198+
"Opentrons Pyro5 Nameserver not found within 60 seconds."
199+
)

api/src/opentrons/hardware_control/ot3api.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Sequence,
1919
Set,
2020
Tuple,
21+
Type,
2122
TypeVar,
2223
Union,
2324
cast,
@@ -92,6 +93,7 @@
9293
from .ot3_calibration import OT3RobotCalibrationProvider, OT3Transforms
9394
from .pause_manager import PauseManager
9495
from .protocols import FlexHardwareControlInterface
96+
from .protocols.types import FlexRobotType
9597
from .types import (
9698
AsynchronousModuleErrorNotification,
9799
Axis,
@@ -144,6 +146,7 @@
144146
from opentrons.util.pyro.pyro_synchronous_adapter import (
145147
convert_result_to_proxy,
146148
convert_result_to_wrapped_dict,
149+
convert_type_to_instance,
147150
pyro_behavior,
148151
)
149152

@@ -256,6 +259,10 @@ def estop_cb(event: HardwareEvent) -> None:
256259
OT3RobotCalibrationProvider.__init__(self, self._config)
257260
ExecutionManagerProvider.__init__(self, isinstance(backend, OT3Simulator))
258261

262+
@pyro_behavior(specialty_func=convert_type_to_instance, apply_local=False)
263+
def get_robot_type(self) -> Type[FlexRobotType]:
264+
return FlexRobotType
265+
259266
def is_idle_mount(self, mount: Union[top_types.Mount, OT3Mount]) -> bool:
260267
"""Only the gripper mount or the 96-channel pipette mount would be idle
261268
(disengaged).
@@ -527,12 +534,14 @@ def is_simulator(self) -> bool:
527534
"""`True` if this is a simulator; `False` otherwise."""
528535
return isinstance(self._backend, OT3Simulator)
529536

537+
@pyro_behavior(specialty_func=convert_result_to_proxy, apply_local=False)
530538
def register_callback(self, cb: HardwareEventHandler) -> Callable[[], None]:
531539
"""Allows the caller to register a callback, and returns a closure
532540
that can be used to unregister the provided callback
533541
"""
534542
self._callbacks.add(cb)
535543

544+
# todo(chb: 04-08-2026): Do we need to add a LOCAL @pyro_behavior to this, which will destroy the proxy object when the time comes?
536545
def unregister() -> None:
537546
self._callbacks.remove(cb)
538547

@@ -2693,7 +2702,8 @@ def get_attached_instrument(
26932702
return self.get_attached_pipette(mount)
26942703

26952704
@property
2696-
def attached_instruments(self) -> Any:
2705+
@pyro_behavior(specialty_func=convert_result_to_wrapped_dict, apply_local=False)
2706+
def attached_instruments(self) -> Dict[top_types.Mount, PipetteDict]:
26972707
# Warning: don't use this in new code, used `attached_pipettes` instead
26982708
return self.attached_pipettes
26992709

api/src/opentrons/hardware_control/protocols/__init__.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,6 @@ class FlexHardwareControlInterface(
8181
with some additional functionality and parameterization not supported on the OT-2.
8282
"""
8383

84-
def get_robot_type(self) -> Type[FlexRobotType]:
85-
return FlexRobotType
86-
8784

8885
__all__ = [
8986
"HardwareControlInterface",

api/src/opentrons/hardware_control/pyro_utils/serpent_type_registry.py

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Registry for use with a Pyro Daemon client and server to allow serialization of Opentrons Hardware types and classes."""
22

3+
import datetime
34
from typing import Dict
45

56
import opentrons.config.types
67
import opentrons.hardware_control.dev_types
8+
import opentrons.hardware_control.instruments.ot3.instrument_calibration
9+
import opentrons.hardware_control.protocols.types
710
import opentrons.hardware_control.types
811
import opentrons.types
912
from opentrons.util.pyro.pyro_serialization import (
@@ -40,6 +43,135 @@ def _estop_overall_status_class_to_dict(obj) -> Dict: # type: ignore
4043
}
4144

4245

46+
# GRIPPER CALIBRATION
47+
# todo(chb, 04-08-2026): This should be consumed into an automated registry process
48+
def _GripperCalibrationOffset_dict_to_class( # type: ignore
49+
classname, d
50+
) -> opentrons.hardware_control.instruments.ot3.instrument_calibration.GripperCalibrationOffset:
51+
modified = (
52+
None
53+
if d["last_modified"] is None
54+
else datetime.datetime.fromisoformat(d["last_modified"])
55+
)
56+
markedAt = (
57+
None
58+
if d["status_markedAt"] is None
59+
else datetime.datetime.fromisoformat(d["status_markedAt"])
60+
)
61+
status_source = (
62+
None
63+
if d["status_source"] is None
64+
else opentrons.hardware_control.instruments.ot3.instrument_calibration.SourceType(
65+
d["status_source"]
66+
)
67+
)
68+
return opentrons.hardware_control.instruments.ot3.instrument_calibration.GripperCalibrationOffset(
69+
offset=opentrons.types.Point(x=d["offset_x"], y=d["offset_y"], z=d["offset_z"]),
70+
source=opentrons.hardware_control.instruments.ot3.instrument_calibration.SourceType(
71+
d["source"]
72+
),
73+
status=opentrons.hardware_control.instruments.ot3.instrument_calibration.CalibrationStatus(
74+
markedBad=(d["status_markedBad"] == "True"),
75+
source=status_source,
76+
markedAt=markedAt,
77+
),
78+
last_modified=modified,
79+
)
80+
81+
82+
def _GripperCalibrationOffset_class_to_dict(obj) -> Dict: # type: ignore
83+
if isinstance(obj.last_modified, datetime.datetime):
84+
modified = obj.last_modified.isoformat()
85+
else:
86+
modified = None
87+
if isinstance(obj.status.markedAt, datetime.datetime):
88+
markedAt = obj.status.markedAt.isoformat()
89+
else:
90+
markedAt = None
91+
return {
92+
"__class__": "opentrons.hardware_control.instruments.ot3.instrument_calibration.GripperCalibrationOffset",
93+
"offset_x": obj.offset.x,
94+
"offset_y": obj.offset.y,
95+
"offset_z": obj.offset.z,
96+
"source": obj.source,
97+
"status_markedBad": obj.status.markedBad,
98+
"status_source": obj.status.source,
99+
"status_markedAt": markedAt,
100+
"last_modified": modified,
101+
}
102+
103+
104+
# PIPETTER CALIBRATION
105+
# todo(chb, 04-08-2026): This should be consumed into an automated registry process
106+
def _PipetteOffsetSummary_dict_to_class( # type: ignore
107+
classname, d
108+
) -> opentrons.hardware_control.instruments.ot3.instrument_calibration.PipetteOffsetSummary:
109+
modified = (
110+
None
111+
if d["last_modified"] is None
112+
else datetime.datetime.fromisoformat(d["last_modified"])
113+
)
114+
markedAt = (
115+
None
116+
if d["status_markedAt"] is None
117+
else datetime.datetime.fromisoformat(d["status_markedAt"])
118+
)
119+
status_source = (
120+
None
121+
if d["status_source"] is None
122+
else opentrons.hardware_control.instruments.ot3.instrument_calibration.SourceType(
123+
d["status_source"]
124+
)
125+
)
126+
return opentrons.hardware_control.instruments.ot3.instrument_calibration.PipetteOffsetSummary(
127+
offset=opentrons.types.Point(x=d["offset_x"], y=d["offset_y"], z=d["offset_z"]),
128+
source=opentrons.hardware_control.instruments.ot3.instrument_calibration.SourceType(
129+
d["source"]
130+
),
131+
status=opentrons.hardware_control.instruments.ot3.instrument_calibration.CalibrationStatus(
132+
markedBad=(d["status_markedBad"] == "True"),
133+
source=status_source,
134+
markedAt=markedAt,
135+
),
136+
last_modified=modified,
137+
reasonability_check_failures=[], # todo(chb: 04-09-2026): These are skipped for integration simplicity, they should be handled by automatic process
138+
)
139+
140+
141+
def _PipetteOffsetSummary_class_to_dict(obj) -> Dict: # type: ignore
142+
if isinstance(obj.last_modified, datetime.datetime):
143+
modified = obj.last_modified.isoformat()
144+
else:
145+
modified = None
146+
if isinstance(obj.status.markedAt, datetime.datetime):
147+
markedAt = obj.status.markedAt.isoformat()
148+
else:
149+
markedAt = None
150+
return {
151+
"__class__": "opentrons.hardware_control.instruments.ot3.instrument_calibration.PipetteOffsetSummary",
152+
"offset_x": obj.offset.x,
153+
"offset_y": obj.offset.y,
154+
"offset_z": obj.offset.z,
155+
"source": obj.source,
156+
"status_markedBad": obj.status.markedBad,
157+
"status_source": obj.status.source,
158+
"status_markedAt": markedAt,
159+
"last_modified": modified,
160+
"reasonability_check_failures": None, # todo(chb: 04-09-2026): These are skipped for integration simplicity, they should be handled by automatic process
161+
}
162+
163+
164+
# Robot type registry - of note, this is meant to return a "pure" type
165+
def _robot_type_class_to_dict(obj) -> Dict: # type: ignore
166+
return {"__class__": ".".join((obj.__module__, obj.__class__.__name__))}
167+
168+
169+
def _robot_type_dict_to_class( # type: ignore
170+
classname, d
171+
) -> type[opentrons.hardware_control.protocols.types.FlexRobotType]:
172+
return opentrons.hardware_control.protocols.types.FlexRobotType
173+
174+
43175
# Handy function to map all registries for the Hardware controller
44176
def register_hardware_types() -> None:
45177
"""Registers serialize and deserialize behavior for Opentrons Hardware types and classes.
@@ -59,7 +191,12 @@ def register_hardware_types() -> None:
59191
OpentronsPyroSerializer.register_enum(enum_type)
60192

61193
opentrons_pydantic_types = find_pydantic_classes_in_packages(
62-
[opentrons.types, opentrons.config.types, opentrons.hardware_control.types]
194+
[
195+
opentrons.types,
196+
opentrons.config.types,
197+
opentrons.hardware_control.types,
198+
opentrons.hardware_control.protocols.types,
199+
]
63200
)
64201
for pydantic_type in opentrons_pydantic_types:
65202
OpentronsPyroSerializer.register_pydantic_model(pydantic_type)
@@ -72,9 +209,32 @@ def register_hardware_types() -> None:
72209

73210
OpentronsPyroSerializer.register_unhashable_dicts()
74211

212+
# Specialized registrations:
213+
register_type_to_serpent(
214+
class_type=opentrons.hardware_control.protocols.types.FlexRobotType,
215+
dict_to_class=_robot_type_dict_to_class,
216+
class_to_dict=_robot_type_class_to_dict,
217+
)
218+
219+
# todo(chb, 04-03-2026): This one should probably be removed and classes like it converted to an appropriate, automated format
75220
# E-Stop Overall registration
76221
register_type_to_serpent(
77222
class_type=opentrons.hardware_control.types.EstopOverallStatus,
78223
dict_to_class=_estop_overall_status_dict_to_class,
79224
class_to_dict=_estop_overall_status_class_to_dict,
80225
)
226+
227+
# todo(chb: 04-09-2026): These are termporary direct serializations to support the initial robot server intergration, replace with automated solution
228+
# gripper calibration
229+
register_type_to_serpent(
230+
class_type=opentrons.hardware_control.instruments.ot3.instrument_calibration.GripperCalibrationOffset,
231+
dict_to_class=_GripperCalibrationOffset_dict_to_class,
232+
class_to_dict=_GripperCalibrationOffset_class_to_dict,
233+
)
234+
235+
# pipette calibration
236+
register_type_to_serpent(
237+
class_type=opentrons.hardware_control.instruments.ot3.instrument_calibration.PipetteOffsetSummary,
238+
dict_to_class=_PipetteOffsetSummary_dict_to_class,
239+
class_to_dict=_PipetteOffsetSummary_class_to_dict,
240+
)

0 commit comments

Comments
 (0)