1+ import inspect
2+ import logging
13import uuid
2- from typing import Dict , List , Optional , Tuple , Union , cast
4+ from typing import Any , Dict , List , Optional , Tuple , Union , cast
35
46from pylabrobot import utils
7+ from pylabrobot .io import LOG_LEVEL_IO
58from pylabrobot .liquid_handling .backends .backend import (
69 LiquidHandlerBackend ,
710)
3134try :
3235 import ot_api
3336
34- # for run cancellation
35- import ot_api .requestor as _req
36-
3737 USE_OT = True
3838except ImportError as e :
3939 USE_OT = False
4444# https://labautomation.io/t/connect-pylabrobot-to-ot2/2862/18
4545_OT_DECK_IS_ADDRESSABLE_AREA_VERSION = "7.1.0"
4646
47+ logger = logging .getLogger (__name__ )
48+
49+
50+ class _IOLogger :
51+ """Transparent proxy over the ``ot_api`` module that logs every call at
52+ ``LOG_LEVEL_IO``.
53+
54+ The OT-2 talks HTTP through ``ot_api`` rather than a pylabrobot.io transport, so
55+ this wrapper gives it the same wire-level logging every other backend gets from
56+ its io object. Submodules (``lh``, ``health``, ...) are wrapped recursively;
57+ plain attributes (e.g. ``run_id``) pass through untouched.
58+ """
59+
60+ def __init__ (self , target : Any , prefix : str = "" ):
61+ self ._target = target
62+ self ._prefix = prefix
63+
64+ def __getattr__ (self , name : str ) -> Any :
65+ attr = getattr (self ._target , name )
66+ qualified = f"{ self ._prefix } .{ name } " if self ._prefix else name
67+ if inspect .ismodule (attr ):
68+ return _IOLogger (attr , qualified )
69+ if callable (attr ):
70+
71+ def _logged (* args , ** kwargs ):
72+ parts = [repr (a ) for a in args ] + [f"{ k } ={ v !r} " for k , v in kwargs .items ()]
73+ logger .log (LOG_LEVEL_IO , "%s(%s)" , qualified , ", " .join (parts ))
74+ return attr (* args , ** kwargs )
75+
76+ return _logged
77+ return attr
78+
4779
4880class OpentronsOT2Backend (LiquidHandlerBackend ):
4981 """Backends for the Opentrons OT2 liquid handling robots."""
@@ -77,8 +109,13 @@ def __init__(self, host: str, port: int = 31950):
77109 self .host = host
78110 self .port = port
79111
80- ot_api .set_host (host )
81- ot_api .set_port (port )
112+ # All hardware I/O goes through this handle so a subclass (e.g. the chatterbox)
113+ # can dry-run the backend by swapping it for a recording stand-in. The real handle
114+ # wraps ot_api to log every HTTP call at LOG_LEVEL_IO, like other backends' io.
115+ self ._ot : Any = _IOLogger (ot_api )
116+
117+ self ._ot .set_host (host )
118+ self ._ot .set_port (port )
82119
83120 self .ot_api_version : Optional [str ] = None
84121 self .left_pipette : Optional [Dict [str , str ]] = None
@@ -97,16 +134,16 @@ def serialize(self) -> dict:
97134
98135 async def setup (self , skip_home : bool = False ):
99136 # create run
100- run_id = ot_api .runs .create ()
101- ot_api .set_run (run_id )
137+ run_id = self . _ot .runs .create ()
138+ self . _ot .set_run (run_id )
102139
103140 # get pipettes, then assign them
104- self .left_pipette , self .right_pipette = ot_api .lh .add_mounted_pipettes ()
141+ self .left_pipette , self .right_pipette = self . _ot .lh .add_mounted_pipettes ()
105142
106143 self .left_pipette_has_tip = self .right_pipette_has_tip = False
107144
108145 # get api version
109- health = ot_api .health .get ()
146+ health = self . _ot .health .get ()
110147 self .ot_api_version = health ["api_version" ]
111148
112149 if not skip_home :
@@ -124,16 +161,16 @@ async def stop(self):
124161 self .right_pipette = None
125162
126163 # cancel the HTTP-API run if it exists (helpful to make device available again in official Opentrons app)
127- run_id = getattr (ot_api , "run_id" , None )
164+ run_id = getattr (self . _ot , "run_id" , None )
128165 if run_id :
129166 try :
130- _req .post (f"/runs/{ run_id } /cancel" )
167+ self . _ot . requestor .post (f"/runs/{ run_id } /cancel" )
131168 except Exception :
132169 try :
133- _req .post (f"/runs/{ run_id } /actions/cancel" )
170+ self . _ot . requestor .post (f"/runs/{ run_id } /actions/cancel" )
134171 except Exception :
135172 try :
136- _req .delete (f"/runs/{ run_id } " )
173+ self . _ot . requestor .delete (f"/runs/{ run_id } " )
137174 except Exception :
138175 pass
139176
@@ -231,7 +268,7 @@ async def _assign_tip_rack(self, tip_rack: TipRack, tip: Tip):
231268 ],
232269 }
233270
234- data = ot_api .labware .define (lw )
271+ data = self . _ot .labware .define (lw )
235272 namespace , definition , version = data ["data" ]["definitionUri" ].split ("/" )
236273
237274 # assign labware to robot
@@ -244,7 +281,7 @@ async def _assign_tip_rack(self, tip_rack: TipRack, tip: Tip):
244281 slot = deck .get_slot (tip_rack )
245282 assert slot is not None , "tip rack must be on deck"
246283
247- ot_api .labware .add (
284+ self . _ot .labware .add (
248285 load_name = definition ,
249286 namespace = namespace ,
250287 ot_location = slot ,
@@ -323,7 +360,7 @@ async def pick_up_tips(self, ops: List[Pickup], use_channels: List[int]):
323360
324361 offset_z += op .tip .total_tip_length
325362
326- ot_api .lh .pick_up_tip (
363+ self . _ot .lh .pick_up_tip (
327364 labware_id = self .get_ot_name (tip_rack .name ),
328365 well_name = self .get_ot_name (op .resource .name ),
329366 pipette_id = pipette_id ,
@@ -363,15 +400,15 @@ async def drop_tips(self, ops: List[Drop], use_channels: List[int]):
363400 offset_z += 10
364401
365402 if use_fixed_trash :
366- ot_api .lh .move_to_addressable_area_for_drop_tip (
403+ self . _ot .lh .move_to_addressable_area_for_drop_tip (
367404 pipette_id = pipette_id ,
368405 offset_x = offset_x ,
369406 offset_y = offset_y ,
370407 offset_z = offset_z ,
371408 )
372- ot_api .lh .drop_tip_in_place (pipette_id = pipette_id )
409+ self . _ot .lh .drop_tip_in_place (pipette_id = pipette_id )
373410 else :
374- ot_api .lh .drop_tip (
411+ self . _ot .lh .drop_tip (
375412 labware_id ,
376413 well_name = self .get_ot_name (op .resource .name ),
377414 pipette_id = pipette_id ,
@@ -472,18 +509,18 @@ async def aspirate(self, ops: List[SingleChannelAspiration], use_channels: List[
472509
473510 if op .mix is not None :
474511 for _ in range (op .mix .repetitions ):
475- ot_api .lh .aspirate_in_place (
512+ self . _ot .lh .aspirate_in_place (
476513 volume = op .mix .volume ,
477514 flow_rate = op .mix .flow_rate ,
478515 pipette_id = pipette_id ,
479516 )
480- ot_api .lh .dispense_in_place (
517+ self . _ot .lh .dispense_in_place (
481518 volume = op .mix .volume ,
482519 flow_rate = op .mix .flow_rate ,
483520 pipette_id = pipette_id ,
484521 )
485522
486- ot_api .lh .aspirate_in_place (
523+ self . _ot .lh .aspirate_in_place (
487524 volume = volume ,
488525 flow_rate = flow_rate ,
489526 pipette_id = pipette_id ,
@@ -544,20 +581,20 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in
544581 pipette_id = pipette_id ,
545582 )
546583
547- ot_api .lh .dispense_in_place (
584+ self . _ot .lh .dispense_in_place (
548585 volume = volume ,
549586 flow_rate = flow_rate ,
550587 pipette_id = pipette_id ,
551588 )
552589
553590 if op .mix is not None :
554591 for _ in range (op .mix .repetitions ):
555- ot_api .lh .aspirate_in_place (
592+ self . _ot .lh .aspirate_in_place (
556593 volume = op .mix .volume ,
557594 flow_rate = op .mix .flow_rate ,
558595 pipette_id = pipette_id ,
559596 )
560- ot_api .lh .dispense_in_place (
597+ self . _ot .lh .dispense_in_place (
561598 volume = op .mix .volume ,
562599 flow_rate = op .mix .flow_rate ,
563600 pipette_id = pipette_id ,
@@ -574,7 +611,7 @@ async def dispense(self, ops: List[SingleChannelDispense], use_channels: List[in
574611 )
575612
576613 async def home (self ):
577- ot_api .health .home ()
614+ self . _ot .health .home ()
578615
579616 async def pick_up_tips96 (self , pickup : PickupTipRack ):
580617 raise NotImplementedError ("The Opentrons backend does not support the 96 head." )
@@ -601,7 +638,7 @@ async def drop_resource(self, drop: ResourceDrop):
601638
602639 async def list_connected_modules (self ) -> List [dict ]:
603640 """List all connected temperature modules."""
604- return cast (List [dict ], ot_api .modules .list_connected_modules ())
641+ return cast (List [dict ], self . _ot .modules .list_connected_modules ())
605642
606643 def _pipette_id_for_channel (self , channel : int ) -> str :
607644 pipettes = []
@@ -618,7 +655,7 @@ def _current_channel_position(self, channel: int) -> Tuple[str, Coordinate]:
618655
619656 pipette_id = self ._pipette_id_for_channel (channel )
620657 try :
621- res = ot_api .lh .save_position (pipette_id = pipette_id )
658+ res = self . _ot .lh .save_position (pipette_id = pipette_id )
622659 pos = res ["data" ]["result" ]["position" ]
623660 current = Coordinate (pos ["x" ], pos ["y" ], pos ["z" ])
624661 except Exception as exc : # noqa: BLE001
@@ -687,7 +724,7 @@ async def move_pipette_head(
687724 if pipette_id is None :
688725 raise ValueError ("No pipette id given or left/right pipette not available." )
689726
690- ot_api .lh .move_arm (
727+ self . _ot .lh .move_arm (
691728 pipette_id = pipette_id ,
692729 location_x = location .x ,
693730 location_y = location .y ,
0 commit comments