armnet-runtime 0.3.1__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/PKG-INFO +2 -2
  2. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/pyproject.toml +2 -2
  3. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/__init__.py +6 -0
  4. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/context.py +72 -2
  5. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/leaderboard.py +1 -1
  6. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/lerobot/__init__.py +8 -1
  7. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/lerobot/import_swap.py +20 -2
  8. armnet_runtime-0.3.2/src/armnet_runtime/lerobot/remote_yam.py +319 -0
  9. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/variation.py +61 -80
  10. armnet_runtime-0.3.2/tests/test_embodiment.py +166 -0
  11. armnet_runtime-0.3.2/tests/test_remote_yam.py +626 -0
  12. armnet_runtime-0.3.2/tests/test_scene_calibration_context.py +30 -0
  13. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_variation.py +71 -41
  14. armnet_runtime-0.3.1/tests/test_embodiment.py +0 -47
  15. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/.gitignore +0 -0
  16. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/LICENSE +0 -0
  17. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/README.md +0 -0
  18. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/proto/rerun.proto +0 -0
  19. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/scripts/gen_proto.sh +0 -0
  20. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/_rerun_pb2.py +0 -0
  21. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/cli.py +0 -0
  22. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/decorator.py +0 -0
  23. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/env.py +0 -0
  24. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/lerobot/_remote_protocol.py +0 -0
  25. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/lerobot/remote_arx5.py +0 -0
  26. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/lerobot/remote_camera.py +0 -0
  27. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/lerobot/remote_feetech.py +0 -0
  28. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/markers.py +0 -0
  29. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/rerun.py +0 -0
  30. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/src/armnet_runtime/volume_sync.py +0 -0
  31. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_cell_instrument.py +0 -0
  32. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_completion.py +0 -0
  33. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_context_arms.py +0 -0
  34. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_edge_goal_clamp.py +0 -0
  35. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_leaderboard.py +0 -0
  36. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_persistent_connection.py +0 -0
  37. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_proxy_method_cache.py +0 -0
  38. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_rail_recovery.py +0 -0
  39. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_rail_reset_restore.py +0 -0
  40. {armnet_runtime-0.3.1 → armnet_runtime-0.3.2}/tests/test_robot_telemetry.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: armnet-runtime
3
- Version: 0.3.1
3
+ Version: 0.3.2
4
4
  Summary: armnet runtime SDK — used by code running inside customer containers on a cell.
5
5
  Project-URL: Homepage, https://armnet.dev
6
6
  Project-URL: Documentation, https://docs.armnet.dev/packages/runtime/
@@ -8,7 +8,7 @@ Author: Ville Kuosmanen
8
8
  License-Expression: Apache-2.0
9
9
  License-File: LICENSE
10
10
  Requires-Python: <3.13,>=3.12
11
- Requires-Dist: armnet-core>=0.3.1
11
+ Requires-Dist: armnet-core>=0.3.2
12
12
  Requires-Dist: protobuf<7,>=5
13
13
  Requires-Dist: pydantic<3,>=2.6
14
14
  Provides-Extra: dev
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
  name = "armnet-runtime"
7
7
  # 0.2.0 follows armnet-core: ctx.cell now carries an environment and hands
8
8
  # reset/scoring to it, and CompletionStatus moved to armnet-core.
9
- version = "0.3.1"
9
+ version = "0.3.2"
10
10
  description = "armnet runtime SDK \u2014 used by code running inside customer containers on a cell."
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.12,<3.13"
@@ -15,7 +15,7 @@ license-files = ["LICENSE"]
15
15
  authors = [{ name = "Ville Kuosmanen" }]
16
16
 
17
17
  dependencies = [
18
- "armnet-core>=0.3.1",
18
+ "armnet-core>=0.3.2",
19
19
  "pydantic>=2.6,<3",
20
20
  # Wire format for Rerun visualization packets (armnet_runtime.rerun).
21
21
  "protobuf>=5,<7",
@@ -37,6 +37,7 @@ from armnet_core import (
37
37
 
38
38
  from armnet_runtime.context import (
39
39
  BIMANUAL_SO101_EMBODIMENT,
40
+ BIMANUAL_YAM_EMBODIMENT,
40
41
  SO101_EMBODIMENT,
41
42
  Cell,
42
43
  CompletionStatus,
@@ -45,6 +46,8 @@ from armnet_runtime.context import (
45
46
  RuntimeCameraMount,
46
47
  RuntimeLightbox,
47
48
  RuntimeRail,
49
+ require_eval_embodiment,
50
+ require_bimanual_yam_embodiment,
48
51
  require_so101_embodiment,
49
52
  )
50
53
  from armnet_runtime.decorator import MainRegistrationError, main
@@ -57,6 +60,7 @@ from armnet_runtime.variation import (
57
60
 
58
61
  __all__ = [
59
62
  "BIMANUAL_SO101_EMBODIMENT",
63
+ "BIMANUAL_YAM_EMBODIMENT",
60
64
  "SO101_EMBODIMENT",
61
65
  "Cell",
62
66
  "CompletionStatus",
@@ -65,6 +69,8 @@ __all__ = [
65
69
  "NothingToVary",
66
70
  "Scenario",
67
71
  "sample_scenario",
72
+ "require_eval_embodiment",
73
+ "require_bimanual_yam_embodiment",
68
74
  "require_so101_embodiment",
69
75
  "Embodiment",
70
76
  "Job",
@@ -22,6 +22,7 @@ from typing import Any, Iterable, Mapping, Optional
22
22
  from urllib.parse import urlparse
23
23
 
24
24
  from armnet_core import (
25
+ BIMANUAL_YAM_EMBODIMENT,
25
26
  CellDeviceConfig,
26
27
  CompletionStatus,
27
28
  Embodiment,
@@ -29,6 +30,7 @@ from armnet_core import (
29
30
  InstrumentedCell,
30
31
  Reading,
31
32
  RobotCellConfig,
33
+ SceneCalibration,
32
34
  Task,
33
35
  environment_for,
34
36
  )
@@ -322,6 +324,9 @@ class Cell:
322
324
  immovable.
323
325
  """
324
326
 
327
+ scene_calibration: Optional[SceneCalibration] = None
328
+ """Manipulation-area snapshot selected when this cell process started."""
329
+
325
330
  device_config: Optional[CellDeviceConfig] = None
326
331
  """What this cell may vary and within what bounds.
327
332
 
@@ -896,8 +901,17 @@ class Cell:
896
901
  if not confirm:
897
902
  return
898
903
 
899
- # 2. Operator confirmation on the cell-served operator-call endpoint
900
- # (fallback to the dev local-control endpoint).
904
+ self.confirm_scene_reset()
905
+
906
+ def confirm_scene_reset(self) -> None:
907
+ """Wait for operator scene staging without moving the robot first.
908
+
909
+ Most callers should use :meth:`reset`, which returns the robot to rest
910
+ before requesting confirmation. A runtime that deliberately starts
911
+ from a non-rest pose can call ``reset(confirm=False)``, move to that
912
+ pose, then call this method so the operator stages objects around the
913
+ robot's actual rollout start configuration.
914
+ """
901
915
  request = {"op": "reset", "request": {"kind": "manual"}}
902
916
  operator_endpoint = self.operator_call_endpoint or self.local_control_endpoint
903
917
  if operator_endpoint:
@@ -1595,6 +1609,13 @@ class Context:
1595
1609
  SO101_EMBODIMENT = "lerobot/so-101"
1596
1610
  BIMANUAL_SO101_EMBODIMENT = "lerobot/bimanual_so101"
1597
1611
 
1612
+ _EVAL_EMBODIMENTS = (
1613
+ SO101_EMBODIMENT,
1614
+ BIMANUAL_SO101_EMBODIMENT,
1615
+ BIMANUAL_YAM_EMBODIMENT,
1616
+ )
1617
+ _BIMANUAL_EVAL_EMBODIMENTS = (BIMANUAL_SO101_EMBODIMENT, BIMANUAL_YAM_EMBODIMENT)
1618
+
1598
1619
 
1599
1620
  def require_so101_embodiment(ctx: "Context", runtime_name: str) -> bool:
1600
1621
  """Validate the job's embodiment is a (single or bimanual) SO-101.
@@ -1624,6 +1645,52 @@ def require_so101_embodiment(ctx: "Context", runtime_name: str) -> bool:
1624
1645
  return expect_bimanual
1625
1646
 
1626
1647
 
1648
+ def require_eval_embodiment(ctx: "Context", runtime_name: str) -> None:
1649
+ """Validate the job's embodiment is one LeRobot eval can drive.
1650
+
1651
+ Accepts single SO-101, bimanual SO-101, and bimanual YAM. Both bimanual
1652
+ types must land on a cell wired with left and right arms.
1653
+
1654
+ Raises :class:`NotImplementedError` for any other embodiment, and
1655
+ :class:`RuntimeError` if the embodiment's arm count disagrees with the
1656
+ cell's actual wiring.
1657
+ """
1658
+ if ctx.embodiment not in _EVAL_EMBODIMENTS:
1659
+ supported = ", ".join(repr(name) for name in _EVAL_EMBODIMENTS)
1660
+ raise NotImplementedError(
1661
+ f"{runtime_name} supports {supported}, got {ctx.embodiment!r}"
1662
+ )
1663
+ expect_bimanual = ctx.embodiment in _BIMANUAL_EVAL_EMBODIMENTS
1664
+ if expect_bimanual != ctx.cell.is_bimanual:
1665
+ raise RuntimeError(
1666
+ f"embodiment {ctx.embodiment!r} expects "
1667
+ f"{'two arms (left+right)' if expect_bimanual else 'a single arm'}, "
1668
+ f"but the cell exposes arms={sorted(ctx.cell.arms)}"
1669
+ )
1670
+
1671
+
1672
+ def require_bimanual_yam_embodiment(ctx: "Context", runtime_name: str) -> None:
1673
+ """Validate the job targets the bimanual YAM cell exactly.
1674
+
1675
+ Requires ``ctx.embodiment`` to be :data:`BIMANUAL_YAM_EMBODIMENT` and the
1676
+ cell to expose exactly ``left`` and ``right`` arms — no other embodiment or
1677
+ arm layout is accepted.
1678
+
1679
+ Raises :class:`NotImplementedError` for any other embodiment, and
1680
+ :class:`RuntimeError` if the cell's arms are not exactly left and right.
1681
+ """
1682
+ if ctx.embodiment != BIMANUAL_YAM_EMBODIMENT:
1683
+ raise NotImplementedError(
1684
+ f"{runtime_name} supports {BIMANUAL_YAM_EMBODIMENT!r}, "
1685
+ f"got {ctx.embodiment!r}"
1686
+ )
1687
+ if set(ctx.cell.arms) != {"left", "right"}:
1688
+ raise RuntimeError(
1689
+ f"embodiment {ctx.embodiment!r} expects arms left and right exactly, "
1690
+ f"but the cell exposes arms={sorted(ctx.cell.arms)}"
1691
+ )
1692
+
1693
+
1627
1694
  def build_context() -> Context:
1628
1695
  """Read the cell-injected env vars and construct a :class:`Context`.
1629
1696
 
@@ -1698,6 +1765,9 @@ def build_context() -> Context:
1698
1765
  rails=_build_runtime_rails(cell_config, args),
1699
1766
  lightbox=_build_runtime_lightbox(cell_config),
1700
1767
  camera_mounts=_build_runtime_camera_mounts(cell_config),
1768
+ scene_calibration=(
1769
+ cell_config.scene_calibration if cell_config else None
1770
+ ),
1701
1771
  device_config=(
1702
1772
  CellDeviceConfig.from_cell_config(cell_config) if cell_config else None
1703
1773
  ),
@@ -31,7 +31,7 @@ from typing import Optional
31
31
  logger = logging.getLogger(__name__)
32
32
 
33
33
  # Shared with demo_space/leaderboard.py — the demo reads from the same dataset.
34
- LEADERBOARD_REPO = "villekuosmanen/armnet-demo-leaderboard"
34
+ LEADERBOARD_REPO = "armnet/armnet-demo-leaderboard"
35
35
  RUNS_FILE = "runs.jsonl"
36
36
 
37
37
 
@@ -4,5 +4,12 @@ from armnet_runtime.lerobot.import_swap import install_lerobot_remote_mode
4
4
  from armnet_runtime.lerobot.remote_arx5 import RemoteARX5Arm
5
5
  from armnet_runtime.lerobot.remote_camera import RemoteOpenCVCamera
6
6
  from armnet_runtime.lerobot.remote_feetech import RemoteFeetechMotorsBus
7
+ from armnet_runtime.lerobot.remote_yam import RemoteYAMFollower
7
8
 
8
- __all__ = ["RemoteARX5Arm", "RemoteFeetechMotorsBus", "RemoteOpenCVCamera", "install_lerobot_remote_mode"]
9
+ __all__ = [
10
+ "RemoteARX5Arm",
11
+ "RemoteFeetechMotorsBus",
12
+ "RemoteOpenCVCamera",
13
+ "RemoteYAMFollower",
14
+ "install_lerobot_remote_mode",
15
+ ]
@@ -10,6 +10,7 @@ from armnet_core import env as env_keys
10
10
  from armnet_runtime.lerobot.remote_camera import RemoteOpenCVCamera
11
11
  from armnet_runtime.lerobot.remote_arx5 import RemoteARX5Arm
12
12
  from armnet_runtime.lerobot.remote_feetech import RemoteFeetechMotorsBus
13
+ from armnet_runtime.lerobot.remote_yam import RemoteYAMFollower
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
@@ -25,6 +26,10 @@ _ARX5_MODULE_CANDIDATES = [
25
26
  "arx_common",
26
27
  "arx_common.arm",
27
28
  ]
29
+ _YAM_MODULE_CANDIDATES = [
30
+ "lerobot_robot_yam",
31
+ "lerobot_robot_yam.yam_follower",
32
+ ]
28
33
 
29
34
 
30
35
  _feetech_is_remote = False
@@ -88,13 +93,26 @@ def install_lerobot_remote_mode() -> None:
88
93
  setattr(module, "ARX5Arm", RemoteARX5Arm)
89
94
  patched.append(f"{module_name}.ARX5Arm")
90
95
 
96
+ for module_name in _YAM_MODULE_CANDIDATES:
97
+ try:
98
+ module = importlib.import_module(module_name)
99
+ except ImportError:
100
+ continue
101
+ for attr in ("YAMFollower", "YAMFollowerRobot"):
102
+ if hasattr(module, attr):
103
+ setattr(module, attr, RemoteYAMFollower)
104
+ patched.append(f"{module_name}.{attr}")
105
+
91
106
  if patched:
92
107
  global _feetech_is_remote
93
108
  _feetech_is_remote = any(attr.endswith("FeetechMotorsBus") for attr in patched)
94
109
  logger.info("installed armnet LeRobot remote mode: patched %s", patched)
95
110
  else:
96
111
  logger.warning(
97
- "LeRobot is installed, but no known Feetech bus, OpenCV camera, or ARX5 arm class was found to patch; "
112
+ "LeRobot is installed, but no known Feetech bus, OpenCV camera, ARX5 arm, or YAM follower class was found to patch; "
98
113
  "remote robot access may fail. Checked: %s",
99
- _FEETECH_MODULE_CANDIDATES + _OPENCV_CAMERA_MODULE_CANDIDATES + _ARX5_MODULE_CANDIDATES,
114
+ _FEETECH_MODULE_CANDIDATES
115
+ + _OPENCV_CAMERA_MODULE_CANDIDATES
116
+ + _ARX5_MODULE_CANDIDATES
117
+ + _YAM_MODULE_CANDIDATES,
100
118
  )
@@ -0,0 +1,319 @@
1
+ """Remote YAM follower used inside customer containers.
2
+
3
+ Device-specific code only. Wire-protocol plumbing lives in
4
+ :mod:`armnet_runtime.lerobot._remote_protocol`. This module does not import
5
+ LeRobot or yam_common at module import time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import time
12
+ from typing import Any
13
+ from urllib.parse import parse_qs, urlparse
14
+
15
+ from armnet_runtime.lerobot._remote_protocol import (
16
+ IS_CONNECTED_TTL_S,
17
+ PersistentConnection,
18
+ RemoteAttributeError,
19
+ RemoteContextManager,
20
+ check_response,
21
+ decode,
22
+ jsonable,
23
+ )
24
+ from armnet_runtime.lerobot.remote_camera import RemoteOpenCVCamera
25
+
26
+ __all__ = ["RemoteYAMFollower"]
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ ACTION_KEYS = (
31
+ "shoulder_pan.pos",
32
+ "shoulder_lift.pos",
33
+ "elbow_flex.pos",
34
+ "wrist_flex.pos",
35
+ "wrist_roll.pos",
36
+ "wrist_yaw.pos",
37
+ "gripper.pos",
38
+ )
39
+
40
+
41
+ class RemoteYAMFollower:
42
+ """LeRobot YAM follower replacement that keeps cameras on separate proxies."""
43
+
44
+ name = "yam_follower"
45
+
46
+ def __init__(self, config: Any, *args: Any, **kwargs: Any) -> None:
47
+ port = getattr(config, "port", None) or kwargs.get("port")
48
+ if not isinstance(port, str):
49
+ raise RuntimeError("RemoteYAMFollower requires config.port with ?arm=left or ?arm=right")
50
+ object.__setattr__(self, "_config", config)
51
+ object.__setattr__(self, "_socket_path", port)
52
+ object.__setattr__(self, "_arm_name", _arm_from_port(port))
53
+ object.__setattr__(self, "_object_id", None)
54
+ object.__setattr__(self, "_method_cache", set())
55
+ object.__setattr__(self, "_missing_cache", set())
56
+ object.__setattr__(self, "_connected", None)
57
+ object.__setattr__(self, "_connected_at", 0.0)
58
+ object.__setattr__(self, "_conn", PersistentConnection(self._socket_path))
59
+ object.__setattr__(self, "cameras", {})
60
+ object.__setattr__(
61
+ self,
62
+ "_object_id",
63
+ self._request(
64
+ {
65
+ "op": "create",
66
+ "class": "YAMFollower",
67
+ "arm": self._arm_name,
68
+ }
69
+ )["object_id"],
70
+ )
71
+ try:
72
+ cameras: dict[str, RemoteOpenCVCamera] = {}
73
+ for name, cam_cfg in (getattr(config, "cameras", None) or {}).items():
74
+ cameras[str(name)] = RemoteOpenCVCamera(cam_cfg)
75
+ object.__setattr__(self, "cameras", cameras)
76
+ except Exception:
77
+ try:
78
+ self.disconnect()
79
+ except Exception:
80
+ pass
81
+ object.__setattr__(self, "_object_id", None)
82
+ raise
83
+
84
+ @property
85
+ def is_calibrated(self) -> bool:
86
+ return True
87
+
88
+ @property
89
+ def observation_features(self) -> dict[str, Any]:
90
+ features: dict[str, Any] = {key: float for key in ACTION_KEYS}
91
+ for name, cam_cfg in (getattr(self._config, "cameras", None) or {}).items():
92
+ height = getattr(cam_cfg, "height", None)
93
+ width = getattr(cam_cfg, "width", None)
94
+ if isinstance(cam_cfg, dict):
95
+ height = cam_cfg.get("height", height)
96
+ width = cam_cfg.get("width", width)
97
+ features[str(name)] = (height, width, 3)
98
+ return features
99
+
100
+ @property
101
+ def action_features(self) -> dict[str, type]:
102
+ return {key: float for key in ACTION_KEYS}
103
+
104
+ @property
105
+ def is_connected(self) -> bool:
106
+ arm = self._arm_connected()
107
+ cameras_ok = all(getattr(cam, "is_connected", True) for cam in self.cameras.values())
108
+ return bool(arm) and cameras_ok
109
+
110
+ def connect(self, calibrate: bool = True) -> None: # noqa: ARG002
111
+ self._method_proxy("connect")()
112
+ try:
113
+ for cam in self.cameras.values():
114
+ cam.connect()
115
+ except Exception:
116
+ for cam in self.cameras.values():
117
+ try:
118
+ cam.disconnect()
119
+ except Exception:
120
+ pass
121
+ try:
122
+ self._method_proxy("emergency_cleanup")()
123
+ except Exception:
124
+ try:
125
+ self._method_proxy("disconnect")()
126
+ except Exception:
127
+ pass
128
+ object.__setattr__(self, "_connected", False)
129
+ raise
130
+ self._set_connected(True)
131
+
132
+ def disconnect(self) -> None:
133
+ for cam in self.cameras.values():
134
+ try:
135
+ cam.disconnect()
136
+ except Exception:
137
+ pass
138
+ try:
139
+ self._method_proxy("disconnect")()
140
+ except Exception:
141
+ pass
142
+ self._set_connected(False)
143
+
144
+ def get_observation(self) -> dict[str, Any]:
145
+ obs = dict(self._method_proxy("get_observation")() or {})
146
+ for name, cam in self.cameras.items():
147
+ obs[name] = cam.async_read()
148
+ return obs
149
+
150
+ def send_action(self, action: Any) -> Any:
151
+ return self._method_proxy("send_action")(action)
152
+
153
+ @property
154
+ def robot_type(self) -> str:
155
+ return str(getattr(self._config, "robot_type", None) or self.name)
156
+
157
+ @property
158
+ def config(self) -> Any:
159
+ return self._config
160
+
161
+ @property
162
+ def id(self) -> Any:
163
+ return getattr(self._config, "id", None)
164
+
165
+ @property
166
+ def calibration_dir(self) -> Any:
167
+ return getattr(self._config, "calibration_dir", None)
168
+
169
+ def calibrate(self) -> None:
170
+ return None
171
+
172
+ def configure(self) -> None:
173
+ return None
174
+
175
+ def close(self) -> None:
176
+ self.disconnect()
177
+
178
+ def zero_torque_mode(self) -> None:
179
+ return self._method_proxy("zero_torque_mode")()
180
+
181
+ def get_observations(self) -> Any:
182
+ return self._method_proxy("get_observations")()
183
+
184
+ def get_joint_pos(self) -> Any:
185
+ return self._method_proxy("get_joint_pos")()
186
+
187
+ def command_joint_pos(self, joint_pos: Any) -> Any:
188
+ return self._method_proxy("command_joint_pos")(joint_pos)
189
+
190
+ def __enter__(self) -> "RemoteYAMFollower":
191
+ self.connect()
192
+ return self
193
+
194
+ def __exit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001
195
+ self.disconnect()
196
+ return False
197
+
198
+ def _set_connected(self, value: bool) -> None:
199
+ object.__setattr__(self, "_connected", value)
200
+ object.__setattr__(self, "_connected_at", time.monotonic())
201
+
202
+ def _arm_connected(self) -> bool:
203
+ if (
204
+ self._connected is not None
205
+ and (time.monotonic() - self._connected_at) < IS_CONNECTED_TTL_S
206
+ ):
207
+ return bool(self._connected)
208
+ try:
209
+ response = self._request(
210
+ {"op": "getattr", "object_id": self._object_id, "name": "is_connected"}
211
+ )
212
+ value = bool(decode(response.get("value")))
213
+ self._set_connected(value)
214
+ return value
215
+ except Exception as exc:
216
+ self._mark_disconnected_if_stale(exc)
217
+ if _is_unknown_object_error(exc):
218
+ return False
219
+ return bool(self._connected)
220
+
221
+ def __del__(self) -> None:
222
+ try:
223
+ object_id = object.__getattribute__(self, "_object_id")
224
+ conn = object.__getattribute__(self, "_conn")
225
+ except AttributeError:
226
+ return
227
+ if object_id:
228
+ try:
229
+ self.disconnect()
230
+ except Exception:
231
+ pass
232
+ if conn is not None:
233
+ try:
234
+ conn.close()
235
+ except Exception:
236
+ pass
237
+
238
+ def __getattr__(self, name: str) -> Any:
239
+ if name.startswith("_"):
240
+ raise AttributeError(name)
241
+ if name in self._method_cache:
242
+ return self._method_proxy(name)
243
+ if name in self._missing_cache:
244
+ raise AttributeError(name)
245
+ try:
246
+ response = self._request(
247
+ {"op": "getattr", "object_id": self._object_id, "name": name}
248
+ )
249
+ if response.get("callable"):
250
+ self._method_cache.add(name)
251
+ return self._method_proxy(name)
252
+ value = decode(response["value"])
253
+ if name == "is_connected":
254
+ self._set_connected(bool(value))
255
+ return value
256
+ except RemoteAttributeError:
257
+ self._missing_cache.add(name)
258
+ raise
259
+
260
+ def __setattr__(self, name: str, value: Any) -> None:
261
+ if name.startswith("_") or name == "cameras":
262
+ object.__setattr__(self, name, value)
263
+ return
264
+ self._request(
265
+ {
266
+ "op": "setattr",
267
+ "object_id": self._object_id,
268
+ "name": name,
269
+ "value": jsonable(value),
270
+ }
271
+ )
272
+
273
+ def _method_proxy(self, name: str):
274
+ def method(*args: Any, **kwargs: Any) -> Any:
275
+ response = self._request(
276
+ {
277
+ "op": "call",
278
+ "object_id": self._object_id,
279
+ "method": name,
280
+ "args": jsonable(args),
281
+ "kwargs": jsonable(kwargs),
282
+ }
283
+ )
284
+ if name == "connect":
285
+ self._set_connected(True)
286
+ elif name in {"disconnect", "emergency_cleanup", "close"}:
287
+ self._set_connected(False)
288
+ if response.get("context_manager"):
289
+ return RemoteContextManager(
290
+ conn=self._conn,
291
+ object_id=response["object_id"],
292
+ )
293
+ return decode(response.get("value"))
294
+
295
+ return method
296
+
297
+ def _request(self, payload: dict[str, Any]) -> dict[str, Any]:
298
+ logger.debug("YAM request op=%r path=%r", payload.get("op"), self._socket_path)
299
+ try:
300
+ return check_response(self._conn.request(payload), error_message="remote YAM call failed")
301
+ except Exception as exc:
302
+ self._mark_disconnected_if_stale(exc)
303
+ raise
304
+
305
+ def _mark_disconnected_if_stale(self, exc: BaseException) -> None:
306
+ if _is_unknown_object_error(exc):
307
+ self._set_connected(False)
308
+
309
+
310
+ def _is_unknown_object_error(exc: BaseException) -> bool:
311
+ return "unknown connector object" in str(exc).lower()
312
+
313
+
314
+ def _arm_from_port(port: str) -> str:
315
+ parsed = urlparse(port)
316
+ arms = parse_qs(parsed.query).get("arm")
317
+ if not arms:
318
+ raise ValueError("RemoteYAMFollower requires ?arm=left or ?arm=right on config.port")
319
+ return str(arms[0])