9999)
100100from pylabrobot.resources.hamilton.hamilton_decks import (
101101 HamiltonCoreGrippers,
102+ HamiltonSTARDeck,
102103 rails_for_x_coordinate,
103104)
104105from pylabrobot.resources.liquid import Liquid
105106from pylabrobot.resources.rotation import Rotation
106107from pylabrobot.resources.tip_tracker import does_tip_tracking
107108from pylabrobot.resources.trash import Trash
109+ from pylabrobot.resources.x_arm import XArm
110+ from pylabrobot.resources.x_arm_tracker import XArmTracker
108111
109112T = TypeVar("T")
110113
@@ -2230,6 +2233,8 @@ async def set_up_arm_modules():
22302233 # the core grippers.
22312234 self._core_parked = True
22322235
2236+ await self._setup_x_arms()
2237+
22332238 self._setup_done = True
22342239
22352240 async def stop(self):
@@ -2281,13 +2286,81 @@ async def experimental_x_arm_move(
22812286 f"current_protection_limiter must be between 0 and 7, is {current_protection_limiter}"
22822287 )
22832288
2284- return await self.send_command(
2285- module="X0",
2286- command="XP",
2287- la=f"{round(x * 10):05}",
2288- lr=str(acceleration_level),
2289- lw=str(current_protection_limiter),
2290- )
2289+ tracker = self.left_x_arm_tracker
2290+ if tracker is not None and not tracker.is_disabled:
2291+ tracker.set_x(x, commit=False)
2292+ try:
2293+ resp = await self.send_command(
2294+ module="X0",
2295+ command="XP",
2296+ la=f"{round(x * 10):05}",
2297+ lr=str(acceleration_level),
2298+ lw=str(current_protection_limiter),
2299+ )
2300+ except Exception:
2301+ if tracker is not None and not tracker.is_disabled:
2302+ tracker.invalidate()
2303+ raise
2304+ if tracker is not None and not tracker.is_disabled:
2305+ tracker.commit()
2306+ return resp
2307+
2308+ @property
2309+ def left_x_arm_tracker(self) -> Optional[XArmTracker]:
2310+ """The left X-arm's tracker, owned by the deck's left X-arm (created at
2311+ setup). None before setup or without a deck."""
2312+ return self._x_arm_tracker("left_x_arm")
2313+
2314+ @property
2315+ def right_x_arm_tracker(self) -> Optional[XArmTracker]:
2316+ """The right X-arm's tracker, owned by the deck's right X-arm. None when no
2317+ right arm is installed, or before setup."""
2318+ return self._x_arm_tracker("right_x_arm")
2319+
2320+ def _x_arm_tracker(self, name: str) -> Optional[XArmTracker]:
2321+ if self._deck is not None and self._deck.has_resource(name):
2322+ x_arm = self._deck.get_resource(name)
2323+ if isinstance(x_arm, XArm):
2324+ return x_arm.tracker
2325+ return None
2326+
2327+ def get_x_arm_position(self) -> Tuple[Optional[float], Optional[float]]:
2328+ """Tracked X-arm carriage reference points in mm, as (left, right).
2329+
2330+ Either element is None while that X-arm's position is unknown: no tracked move or
2331+ position query has completed for it yet, the last tracked move failed, or that
2332+ X-arm is not present.
2333+ """
2334+ left_tracker = self.left_x_arm_tracker
2335+ right_tracker = self.right_x_arm_tracker
2336+ left = left_tracker.get_x() if left_tracker is not None and left_tracker.is_known else None
2337+ right = right_tracker.get_x() if right_tracker is not None and right_tracker.is_known else None
2338+ return left, right
2339+
2340+ async def _setup_x_arms(self) -> None:
2341+ """Create the deck-owned X-arm for each present arm and seed its tracker.
2342+
2343+ The X-arm (an ``XArm``) owns its ``XArmTracker`` and reports it as state, so the
2344+ Visualizer positions it; this asks the deck to create it (sized and modelled from
2345+ the resolved X-drive configuration) and seeds the tracker from a position read (its
2346+ resting home in simulation, its true position on hardware). No-op without a deck.
2347+ """
2348+ if self._deck is None:
2349+ return
2350+ deck = cast(HamiltonSTARDeck, self.deck)
2351+ drives = {
2352+ "left": self.extended_conf.left_x_drive,
2353+ "right": self.extended_conf.right_x_drive,
2354+ }
2355+ queries = {"left": self.request_left_x_arm_position, "right": self.request_right_x_arm_position}
2356+ for position in ("left", "right"):
2357+ arm = drives[position]
2358+ if arm is None:
2359+ continue
2360+ if arm.width is None:
2361+ raise RuntimeError(f"{position} X-arm geometry not resolved")
2362+ deck.get_or_create_x_arm(f"{position}_x_arm", arm.width, arm.model, arm.reference_point)
2363+ await queries[position]()
22912364
22922365 def _check_x_arm_reachable(self, x: float, position: Literal["left", "right"] = "left") -> None:
22932366 """Raise if x (mm) is outside the X-drive's travel range (``x_range``).
@@ -6208,11 +6281,22 @@ async def position_left_x_arm_(self, x_position: int = 0):
62086281
62096282 assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000"
62106283
6211- return await self.send_command(
6212- module="C0",
6213- command="JX",
6214- xs=f"{x_position:05}",
6215- )
6284+ tracker = self.left_x_arm_tracker
6285+ if tracker is not None and not tracker.is_disabled:
6286+ tracker.set_x(x_position / 10, commit=False)
6287+ try:
6288+ resp = await self.send_command(
6289+ module="C0",
6290+ command="JX",
6291+ xs=f"{x_position:05}",
6292+ )
6293+ except Exception:
6294+ if tracker is not None and not tracker.is_disabled:
6295+ tracker.invalidate()
6296+ raise
6297+ if tracker is not None and not tracker.is_disabled:
6298+ tracker.commit()
6299+ return resp
62166300
62176301 async def position_right_x_arm_(self, x_position: int = 0):
62186302 """Position right X-Arm
@@ -6225,11 +6309,22 @@ async def position_right_x_arm_(self, x_position: int = 0):
62256309
62266310 assert 0 <= x_position <= 30000, "x_position_ must be between 0 and 30000"
62276311
6228- return await self.send_command(
6229- module="C0",
6230- command="JS",
6231- xs=f"{x_position:05}",
6232- )
6312+ tracker = self.right_x_arm_tracker
6313+ if tracker is not None and not tracker.is_disabled:
6314+ tracker.set_x(x_position / 10, commit=False)
6315+ try:
6316+ resp = await self.send_command(
6317+ module="C0",
6318+ command="JS",
6319+ xs=f"{x_position:05}",
6320+ )
6321+ except Exception:
6322+ if tracker is not None and not tracker.is_disabled:
6323+ tracker.invalidate()
6324+ raise
6325+ if tracker is not None and not tracker.is_disabled:
6326+ tracker.commit()
6327+ return resp
62336328
62346329 async def move_left_x_arm_to_position_with_all_attached_components_in_z_safety_position(
62356330 self, x_position: int = 0
@@ -6338,13 +6433,21 @@ async def release_all_occupied_areas(self):
63386433 async def request_left_x_arm_position(self) -> float:
63396434 """Request left X-Arm position"""
63406435 resp_dmm = await self.send_command(module="C0", command="RX", fmt="rx#####")
6341- return cast(float, resp_dmm["rx"]) / 10
6436+ x = cast(float, resp_dmm["rx"]) / 10
6437+ tracker = self.left_x_arm_tracker
6438+ if tracker is not None and not tracker.is_disabled:
6439+ tracker.set_x(x)
6440+ return x
63426441
63436442 async def request_right_x_arm_position(self) -> float:
63446443 """Request right X-Arm position"""
63456444
63466445 resp_dmm = await self.send_command(module="C0", command="QX", fmt="rx#####")
6347- return cast(float, resp_dmm["rx"]) / 10
6446+ x = cast(float, resp_dmm["rx"]) / 10
6447+ tracker = self.right_x_arm_tracker
6448+ if tracker is not None and not tracker.is_disabled:
6449+ tracker.set_x(x)
6450+ return x
63486451
63496452 async def request_maximal_ranges_of_x_drives(self) -> Dict[str, Tuple[float, float]]:
63506453 """Request the maximal travel range of each X drive.
0 commit comments