bounded-embodiment-kit 0.1.0__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 (88) hide show
  1. bounded_embodiment_kit-0.1.0/.gitignore +15 -0
  2. bounded_embodiment_kit-0.1.0/PKG-INFO +39 -0
  3. bounded_embodiment_kit-0.1.0/README.md +14 -0
  4. bounded_embodiment_kit-0.1.0/pyproject.toml +35 -0
  5. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/__init__.py +2 -0
  6. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/__init__.py +29 -0
  7. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/base.py +192 -0
  8. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/feetech.py +204 -0
  9. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/lerobot.py +316 -0
  10. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/python.py +136 -0
  11. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/sim_rover.py +206 -0
  12. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/sim_so101.py +615 -0
  13. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/cli.py +23 -0
  14. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/__init__.py +1 -0
  15. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/accuracy.py +866 -0
  16. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/__init__.py +16 -0
  17. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/background.py +123 -0
  18. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/cli.py +319 -0
  19. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/fit.py +1346 -0
  20. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/mesh.py +253 -0
  21. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/model.py +316 -0
  22. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/register.py +368 -0
  23. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/synth.py +669 -0
  24. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/plan.py +279 -0
  25. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/record.py +2481 -0
  26. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/session.py +291 -0
  27. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/kinematics/__init__.py +5 -0
  28. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/kinematics/ik.py +109 -0
  29. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/kinematics/urdf.py +401 -0
  30. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/__init__.py +4 -0
  31. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/cli.py +170 -0
  32. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/doctor.py +128 -0
  33. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/feeds.py +315 -0
  34. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/interlocks.py +122 -0
  35. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/kin.py +75 -0
  36. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/limits.py +116 -0
  37. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/run.py +701 -0
  38. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/session.py +422 -0
  39. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/watchdog.py +484 -0
  40. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/__init__.py +11 -0
  41. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/cli.py +96 -0
  42. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/files.py +127 -0
  43. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/ids.py +91 -0
  44. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/lab-config-0.1.json +50 -0
  45. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/labconfig.py +77 -0
  46. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/report.py +733 -0
  47. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/scrub.py +342 -0
  48. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/submit.py +328 -0
  49. bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/timebase.py +396 -0
  50. bounded_embodiment_kit-0.1.0/tests/_b6.py +401 -0
  51. bounded_embodiment_kit-0.1.0/tests/_markerless_support.py +166 -0
  52. bounded_embodiment_kit-0.1.0/tests/_record_fakes.py +359 -0
  53. bounded_embodiment_kit-0.1.0/tests/_runner_fakes.py +193 -0
  54. bounded_embodiment_kit-0.1.0/tests/_submission_support.py +233 -0
  55. bounded_embodiment_kit-0.1.0/tests/_virtual_time.py +273 -0
  56. bounded_embodiment_kit-0.1.0/tests/conftest.py +94 -0
  57. bounded_embodiment_kit-0.1.0/tests/data/bob_calibration.json +9 -0
  58. bounded_embodiment_kit-0.1.0/tests/data/so101_fk_golden.json +149 -0
  59. bounded_embodiment_kit-0.1.0/tests/test_b6_matrix.py +441 -0
  60. bounded_embodiment_kit-0.1.0/tests/test_instrument_check.py +498 -0
  61. bounded_embodiment_kit-0.1.0/tests/test_instrument_plan.py +169 -0
  62. bounded_embodiment_kit-0.1.0/tests/test_kinematics_urdf.py +95 -0
  63. bounded_embodiment_kit-0.1.0/tests/test_markerless_fit.py +312 -0
  64. bounded_embodiment_kit-0.1.0/tests/test_markerless_mesh.py +164 -0
  65. bounded_embodiment_kit-0.1.0/tests/test_markerless_register.py +159 -0
  66. bounded_embodiment_kit-0.1.0/tests/test_markerless_session.py +104 -0
  67. bounded_embodiment_kit-0.1.0/tests/test_record_board.py +98 -0
  68. bounded_embodiment_kit-0.1.0/tests/test_record_check.py +260 -0
  69. bounded_embodiment_kit-0.1.0/tests/test_record_cli.py +120 -0
  70. bounded_embodiment_kit-0.1.0/tests/test_record_oak.py +151 -0
  71. bounded_embodiment_kit-0.1.0/tests/test_record_session.py +252 -0
  72. bounded_embodiment_kit-0.1.0/tests/test_record_writers.py +163 -0
  73. bounded_embodiment_kit-0.1.0/tests/test_runner_adapter_cli.py +50 -0
  74. bounded_embodiment_kit-0.1.0/tests/test_runner_doctor.py +43 -0
  75. bounded_embodiment_kit-0.1.0/tests/test_runner_e2e_sim.py +262 -0
  76. bounded_embodiment_kit-0.1.0/tests/test_runner_feeds.py +95 -0
  77. bounded_embodiment_kit-0.1.0/tests/test_runner_interlocks.py +189 -0
  78. bounded_embodiment_kit-0.1.0/tests/test_runner_stop_ratchet.py +113 -0
  79. bounded_embodiment_kit-0.1.0/tests/test_runner_tripwires.py +171 -0
  80. bounded_embodiment_kit-0.1.0/tests/test_runner_watchdog.py +217 -0
  81. bounded_embodiment_kit-0.1.0/tests/test_runner_wrapper_connect.py +162 -0
  82. bounded_embodiment_kit-0.1.0/tests/test_submission_e2e_sim.py +275 -0
  83. bounded_embodiment_kit-0.1.0/tests/test_submission_files.py +117 -0
  84. bounded_embodiment_kit-0.1.0/tests/test_submission_labconfig.py +85 -0
  85. bounded_embodiment_kit-0.1.0/tests/test_submission_report.py +350 -0
  86. bounded_embodiment_kit-0.1.0/tests/test_submission_scrub.py +113 -0
  87. bounded_embodiment_kit-0.1.0/tests/test_submission_submit.py +248 -0
  88. bounded_embodiment_kit-0.1.0/tests/test_submission_timebase.py +167 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .venv/
6
+ venv/
7
+ build/
8
+ dist/
9
+ *.egg-info/
10
+ .coverage
11
+ htmlcov/
12
+ /runs/
13
+ /sessions/
14
+ .claude/
15
+ examples/robots/*/assets/
@@ -0,0 +1,39 @@
1
+ Metadata-Version: 2.5
2
+ Name: bounded-embodiment-kit
3
+ Version: 0.1.0
4
+ Summary: The lab bench for the Bounded Embodiment tests: adapters, instruments, the runner and its watchdog.
5
+ Project-URL: Repository, https://github.com/RobotRegistryFoundation/bounded-embodiment
6
+ Author: Craig Merry
7
+ License-Expression: Apache-2.0
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: bounded-embodiment~=0.1.0
10
+ Requires-Dist: numpy>=1.24
11
+ Provides-Extra: camera
12
+ Requires-Dist: opencv-python-headless<4.14,>=4.9; extra == 'camera'
13
+ Provides-Extra: fit
14
+ Requires-Dist: opencv-python-headless<4.14,>=4.9; extra == 'fit'
15
+ Requires-Dist: scipy>=1.10; extra == 'fit'
16
+ Provides-Extra: lerobot
17
+ Requires-Dist: lerobot>=0.6.1; (python_version >= '3.12') and extra == 'lerobot'
18
+ Requires-Dist: pyserial>=3.5; extra == 'lerobot'
19
+ Provides-Extra: oakd
20
+ Requires-Dist: depthai>=2.24; extra == 'oakd'
21
+ Requires-Dist: opencv-python-headless<4.14,>=4.9; extra == 'oakd'
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest>=8; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # bounded-embodiment-kit
27
+
28
+ The lab bench for the Bounded Embodiment tests: adapters (LeRobot SO-10x, a Python template,
29
+ simulators), the runner and its watchdog (`be run`, `be goto-start`, `be doctor`), the OAK-D recorder
30
+ (`be record`), the markerless tracker (`be fit`), the accuracy check (`be instrument`), and packaging
31
+ (`be report`, `be submit`). It adds these commands to core's `be`.
32
+
33
+ **The adapters, runner and watchdog are a draft that needs line-by-line human review before they
34
+ drive hardware.** The markerless tracker is validated on synthetic recordings only. Method v0.1 is a
35
+ draft for comment; a verdict is never a certification.
36
+
37
+ Extras: `fit` (the tracker: OpenCV, SciPy), `oakd` (DepthAI), `camera` (OpenCV), `lerobot`.
38
+
39
+ Start with the lab guide: https://github.com/RobotRegistryFoundation/bounded-embodiment/blob/main/docs/lab-guide.md
@@ -0,0 +1,14 @@
1
+ # bounded-embodiment-kit
2
+
3
+ The lab bench for the Bounded Embodiment tests: adapters (LeRobot SO-10x, a Python template,
4
+ simulators), the runner and its watchdog (`be run`, `be goto-start`, `be doctor`), the OAK-D recorder
5
+ (`be record`), the markerless tracker (`be fit`), the accuracy check (`be instrument`), and packaging
6
+ (`be report`, `be submit`). It adds these commands to core's `be`.
7
+
8
+ **The adapters, runner and watchdog are a draft that needs line-by-line human review before they
9
+ drive hardware.** The markerless tracker is validated on synthetic recordings only. Method v0.1 is a
10
+ draft for comment; a verdict is never a certification.
11
+
12
+ Extras: `fit` (the tracker: OpenCV, SciPy), `oakd` (DepthAI), `camera` (OpenCV), `lerobot`.
13
+
14
+ Start with the lab guide: https://github.com/RobotRegistryFoundation/bounded-embodiment/blob/main/docs/lab-guide.md
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bounded-embodiment-kit"
7
+ version = "0.1.0"
8
+ description = "The lab bench for the Bounded Embodiment tests: adapters, instruments, the runner and its watchdog."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ authors = [{ name = "Craig Merry" }]
13
+ dependencies = ["bounded-embodiment~=0.1.0", "numpy>=1.24"]
14
+
15
+ [project.optional-dependencies]
16
+ camera = ["opencv-python-headless>=4.9,<4.14"]
17
+ oakd = ["depthai>=2.24", "opencv-python-headless>=4.9,<4.14"]
18
+ fit = ["opencv-python-headless>=4.9,<4.14", "scipy>=1.10"]
19
+ lerobot = ["lerobot>=0.6.1; python_version >= '3.12'", "pyserial>=3.5"]
20
+ test = ["pytest>=8"]
21
+
22
+ [project.entry-points."bounded_embodiment.cli"]
23
+ kit = "bounded_embodiment_kit.cli:register"
24
+
25
+ [project.urls]
26
+ Repository = "https://github.com/RobotRegistryFoundation/bounded-embodiment"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/bounded_embodiment_kit"]
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
33
+ markers = [
34
+ "slow: renders synthetic recordings and fits them end to end, and runs the B6 matrix's 10-minute cases (about ten minutes on two cores); -m 'not slow' leaves them out",
35
+ ]
@@ -0,0 +1,2 @@
1
+ """The lab bench for the Bounded Embodiment tests."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,29 @@
1
+ # SPDX-FileCopyrightText: 2026 Craig Merry and contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Adapters: where the kit hands commands to the system under test (``base`` has the contract).
4
+
5
+ Each adapter module has ``add_arguments(parser)`` for its own flags and ``from_args(args,
6
+ context)`` to build it. They are imported by name, so LeRobot is never imported unless asked for.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+
12
+ from .base import AdapterContext, AdapterError, SendResult, State, check_adapter
13
+
14
+ __all__ = ["ADAPTERS", "AdapterContext", "AdapterError", "SendResult", "State", "adapter_module",
15
+ "check_adapter"]
16
+
17
+ ADAPTERS = {
18
+ "lerobot": "bounded_embodiment_kit.adapters.lerobot",
19
+ "python": "bounded_embodiment_kit.adapters.python",
20
+ "sim-so101": "bounded_embodiment_kit.adapters.sim_so101",
21
+ "sim-rover": "bounded_embodiment_kit.adapters.sim_rover",
22
+ }
23
+
24
+
25
+ def adapter_module(name: str):
26
+ try:
27
+ return importlib.import_module(ADAPTERS[name])
28
+ except KeyError:
29
+ raise AdapterError(f"unknown adapter {name!r}; known: {', '.join(ADAPTERS)}") from None
@@ -0,0 +1,192 @@
1
+ # SPDX-FileCopyrightText: 2026 Craig Merry and contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """DRAFT — requires line-by-line human review before use on hardware.
4
+
5
+ The adapter contract: the one place where the kit touches the system under test.
6
+
7
+ A lab implements an adapter at the point where its model issues commands. Whatever sits behind
8
+ ``send()`` (the lab's gate, a LeRobot wrapper Robot, a gateway) is the system under test; the kit
9
+ never looks inside it.
10
+
11
+ Who calls what, from which thread (the runner relies on this):
12
+
13
+ ================= ===================== ===================================================
14
+ method called from what it may do
15
+ ================= ===================== ===================================================
16
+ ``open()`` main thread, once connect safely; return the declared command space
17
+ ``describe()`` any thread pure data, no I/O
18
+ ``send(cmd)`` command thread hand ``cmd`` to the system under test; never raises
19
+ ``read_state()`` command thread read the robot's own joint state from the bus
20
+ ``latch_stop()`` ANY thread set a flag and return; never touches the bus, never
21
+ blocks (the watchdog calls it)
22
+ ``execute_stop()`` command thread the stop itself, under the bus lock
23
+ ``close()`` main thread, once leave the robot safe (torque stays on unless the
24
+ operator types OFF), release the bus
25
+ ================= ===================== ===================================================
26
+
27
+ ``execute_stop()`` is not in the approved plan's six-method sketch. The plan says "latch_stop()
28
+ sets a latch; the command thread executes the stop under the bus lock"; the command thread needs a
29
+ named entry point for that, and overloading ``send()`` would be cleverness. ``send()`` still
30
+ refuses every command once the latch is set, as a backstop.
31
+
32
+ Stop rule (lesson 3 of the plan): the goals a stop holds are chosen ONCE per latch (an arm: the
33
+ present positions, read at the first stop, because a joint may be moving) and re-sent unchanged
34
+ on every later stop. A later stop must never re-read and re-anchor to the present (sagging)
35
+ position: on bob that ratcheted the arm down a little on every repeated stop.
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import importlib
40
+ import importlib.util
41
+ import math
42
+ import sys
43
+ from collections.abc import Callable, Mapping
44
+ from dataclasses import dataclass, field
45
+ from pathlib import Path
46
+ from typing import Protocol, runtime_checkable
47
+
48
+ from bounded_embodiment.commandspace import CommandSpace
49
+
50
+ __all__ = [
51
+ "ADAPTER_METHODS",
52
+ "Adapter",
53
+ "AdapterContext",
54
+ "AdapterError",
55
+ "SendResult",
56
+ "State",
57
+ "check_adapter",
58
+ "clean_text",
59
+ "load_callable",
60
+ ]
61
+
62
+ #: Every method the runner calls. ``check_adapter`` refuses an object that lacks one.
63
+ ADAPTER_METHODS = ("open", "describe", "send", "read_state", "latch_stop", "execute_stop", "close")
64
+
65
+
66
+ class AdapterError(RuntimeError):
67
+ """The adapter refuses to go on: the message says why and what to do. Raised before motion
68
+ (open) or when the robot cannot be left safe (close)."""
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class SendResult:
73
+ """What happened to one command.
74
+
75
+ applied the adapter handed the command to the system under test and the call returned.
76
+ It says nothing about whether the robot moved: a gate may hold or clamp.
77
+ decision the system under test's own word for what it did ("allow", "clamp", ...), if it
78
+ reports one. Logged in commands.csv, never scored.
79
+ error_kind why the command was not applied: "stop_latched", "exception:<Type>", ...
80
+ """
81
+
82
+ applied: bool
83
+ decision: str | None = None
84
+ error_kind: str | None = None
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class State:
89
+ """The robot's own joint state, as its encoders report it.
90
+
91
+ joints joint name -> value in the robot's own units (LeRobot: degrees for the body joints,
92
+ 0..100 for the gripper).
93
+ units joint name -> "deg" | "rad" | "percent" | "m" | "m/s" | "rad/s". Forward kinematics
94
+ uses only deg and rad joints.
95
+
96
+ The runner stamps the time; the adapter does not.
97
+ """
98
+
99
+ joints: Mapping[str, float]
100
+ units: Mapping[str, str] = field(default_factory=dict)
101
+
102
+ def finite(self) -> bool:
103
+ return all(isinstance(v, (int, float)) and math.isfinite(v) for v in self.joints.values())
104
+
105
+
106
+ @runtime_checkable
107
+ class Adapter(Protocol):
108
+ def open(self) -> CommandSpace: ... # connect safely; the command space comes from a declared file
109
+ def describe(self) -> dict: ... # self-description; describe()["config"] goes into the setup hash
110
+ def send(self, cmd: dict) -> SendResult: ... # never raises
111
+ def read_state(self) -> State | None: ... # None: this adapter has no joint state (no FK tripwire)
112
+ def latch_stop(self) -> None: ... # sets a flag; any thread; never touches the bus
113
+ def execute_stop(self) -> None: ... # command thread only; same goals every time per latch
114
+ def close(self) -> None: ...
115
+
116
+
117
+ def _stderr(msg: str) -> None:
118
+ print(msg, file=sys.stderr, flush=True)
119
+
120
+
121
+ @dataclass
122
+ class AdapterContext:
123
+ """What the runner hands an adapter's ``from_args``: the run's files and the operator's I/O.
124
+
125
+ ``prompt`` asks a typed question (``input`` on a terminal; tests pass their own) and ``log``
126
+ prints a line for the operator."""
127
+
128
+ command_space_path: Path
129
+ envelope_path: Path | None = None
130
+ kit_config_path: Path | None = None
131
+ kit_config: dict = field(default_factory=dict)
132
+ urdf_path: Path | None = None
133
+ mode: str = ""
134
+ speed_factor: float | None = None
135
+ prompt: Callable[[str], str] = input
136
+ log: Callable[[str], None] = _stderr
137
+
138
+ def wrapper_context(self) -> dict:
139
+ """What a lab's wrapper factory receives as ``context``: plain data, no callables.
140
+ ``speed_factor`` is 0.5 in a shakedown ("at half speed") and None otherwise ("use your
141
+ own default"); the kit itself never slows or alters a command."""
142
+
143
+ def text(p: Path | None) -> str | None:
144
+ return None if p is None else str(p)
145
+
146
+ return {"mode": self.mode, "speed_factor": self.speed_factor,
147
+ "envelope_path": text(self.envelope_path),
148
+ "command_space_path": text(self.command_space_path),
149
+ "kit_config_path": text(self.kit_config_path), "urdf_path": text(self.urdf_path)}
150
+
151
+
152
+ def check_adapter(obj: object) -> None:
153
+ """Refuse an object that does not implement every method the runner calls."""
154
+ missing = [m for m in ADAPTER_METHODS if not callable(getattr(obj, m, None))]
155
+ if missing:
156
+ raise AdapterError(f"{type(obj).__name__} is not an adapter: it lacks {', '.join(missing)}")
157
+
158
+
159
+ def clean_text(value: object, limit: int = 64) -> str | None:
160
+ """A short single-line rendering of a value the system under test reported (for the CSV)."""
161
+ if value is None:
162
+ return None
163
+ text = " ".join(str(value).split())
164
+ return text[:limit] if text else None
165
+
166
+
167
+ def load_callable(spec: str) -> Callable:
168
+ """``module:name`` or ``path/to/file.py:name`` -> the callable it names.
169
+
170
+ Used for the lab's own code (a wrapper factory, a python adapter class). It is imported at run
171
+ time from a name the operator typed, so the kit never imports a gate statically (import-linter
172
+ checks that)."""
173
+ target, sep, name = spec.rpartition(":")
174
+ if not sep or not target or not name:
175
+ raise AdapterError(f"{spec!r} is not module:name or path/to/file.py:name")
176
+ if target.endswith(".py") or "/" in target or "\\" in target:
177
+ path = Path(target).expanduser().resolve()
178
+ if not path.is_file():
179
+ raise AdapterError(f"{path} does not exist")
180
+ mod_name = f"be_lab_{path.stem}"
181
+ loader_spec = importlib.util.spec_from_file_location(mod_name, path)
182
+ if loader_spec is None or loader_spec.loader is None:
183
+ raise AdapterError(f"cannot import {path}")
184
+ module = importlib.util.module_from_spec(loader_spec)
185
+ sys.modules[mod_name] = module
186
+ loader_spec.loader.exec_module(module)
187
+ else:
188
+ module = importlib.import_module(target)
189
+ obj = getattr(module, name, None)
190
+ if not callable(obj):
191
+ raise AdapterError(f"{spec!r}: {name!r} is not a callable in {target!r}")
192
+ return obj
@@ -0,0 +1,204 @@
1
+ # SPDX-FileCopyrightText: 2026 Craig Merry and contributors
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """DRAFT — requires line-by-line human review before use on hardware.
4
+
5
+ Feetech-bus (SO-100 / SO-101) safety code shared by the ``lerobot`` and ``sim-so101`` adapters.
6
+
7
+ Everything here talks to a LeRobot ``FeetechMotorsBus`` (or the simulator's stand-in for one)
8
+ through its public methods only: ``connect``, ``disconnect``, ``is_calibrated``, ``sync_read``,
9
+ ``sync_write``, ``disable_torque``. Raw values (``normalize=False``) are encoder ticks.
10
+
11
+ Ported from ev03-harness ``ev03/arm/hw.py`` (reviewed): ``connect_safely``, ``check_bus_is_ours``
12
+ and the typed-OFF part of ``finish``. Changes from hw.py, each for a reason:
13
+
14
+ - connect_safely reads the goal registers back after syncing them: ``sync_write`` gets no status
15
+ packet, so a lost packet would otherwise leave a stale goal (bob's read 0 at power-up) for
16
+ ``configure()`` to drive toward when it enables torque.
17
+ - connect_safely releases the port without touching torque if anything fails after it opened it.
18
+ - check_bus_is_ours returns the problems instead of calling SystemExit, and says so plainly when
19
+ it cannot scan (no /proc: not Linux) instead of crashing.
20
+ - The typed OFF must be exactly ``OFF`` (hw.py accepted any case). Anything else leaves torque on,
21
+ which is the safe failure.
22
+ - choose_stop_goals is new. hw.py's ``hold()`` re-anchored EVERY stop to the present position;
23
+ here the present position is read once, at the first stop of a latch, and every later stop
24
+ re-sends it (so-arm101-actuator's PR #7 rule, which the plan cites).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import subprocess
30
+ from collections.abc import Callable, Mapping, Sequence
31
+
32
+ from .base import AdapterError
33
+
34
+ __all__ = [
35
+ "BODY_JOINT_SPEED_TRIP_DPS",
36
+ "HOLD_WITHIN_TICKS",
37
+ "check_bus_is_ours",
38
+ "choose_stop_goals",
39
+ "connect_safely",
40
+ "leave_safe",
41
+ "norm_name",
42
+ ]
43
+
44
+ #: connect_safely: a powered joint whose goal register is within this many encoder ticks of its
45
+ #: position is holding there, and its goal is left alone. 50 ticks is 4.4 deg on a 4096-tick
46
+ #: STS3215: above bob's measured gravity sag plus friction (27 ticks at the shoulder, lifted into
47
+ #: place), well below any real move. (hw.py's value.)
48
+ HOLD_WITHIN_TICKS = 50
49
+
50
+ #: The SO-101 joint-speed tripwire (hw.py's 150 deg/s). Servos slew at about 270 deg/s, so a body
51
+ #: joint above 150 deg/s over the 0.2 s window is a joint something drove hard, gate or not.
52
+ BODY_JOINT_SPEED_TRIP_DPS = 150.0
53
+
54
+
55
+ def norm_name(motor: object) -> str:
56
+ """A LeRobot Motor's norm mode as text: "DEGREES", "RANGE_0_100" or "RANGE_M100_100"."""
57
+ mode = getattr(motor, "norm_mode", None)
58
+ return str(getattr(mode, "name", mode))
59
+
60
+
61
+ def _release(bus: object, log: Callable[[str], None]) -> None:
62
+ """Close the port without touching torque. Never raises: it runs on the way out of an error."""
63
+ try:
64
+ if getattr(bus, "is_connected", False):
65
+ bus.disconnect(disable_torque=False) # closePort only; no register write
66
+ except Exception as e: # noqa: BLE001 - already failing; report and keep the first error
67
+ log(f"Could not release the port cleanly ({type(e).__name__}: {e}).")
68
+
69
+
70
+ def connect_safely(robot: object, *, log: Callable[[str], None],
71
+ hold_within_ticks: int = HOLD_WITHIN_TICKS) -> dict:
72
+ """Connect an SO-10x follower's bus without the arm jumping, then let LeRobot's own
73
+ ``configure()`` set gains and acceleration and enable torque.
74
+
75
+ Why not ``robot.connect()``: it runs ``configure()``, which enables torque toward whatever the
76
+ goal registers hold, and bob's read 0 at power-up. So the bus is connected here, and every goal
77
+ register is set to where its joint already is BEFORE torque comes on. If torque is already on
78
+ and every goal is within ``hold_within_ticks`` of its joint, the arm is holding there and its
79
+ goals are left alone: rewriting them to the measured (sagging) position would lower the arm by
80
+ its sag.
81
+
82
+ Returns what it found, for the adapter's describe(). Raises AdapterError (port released, torque
83
+ untouched) if the calibration is missing or does not match the servos, or a goal did not take.
84
+ """
85
+ bus = robot.bus
86
+ if not getattr(robot, "calibration", None):
87
+ raise AdapterError("No LeRobot calibration file for this robot id. Calibrate with "
88
+ "lerobot-calibrate first (or save the servos' own with the harness probe).")
89
+ body = [m for m in bus.motors if m != "gripper"]
90
+ wrong = {m: norm_name(bus.motors[m]) for m in body if norm_name(bus.motors[m]) != "DEGREES"}
91
+ if wrong:
92
+ raise AdapterError(f"These joints are not in degrees: {wrong}. Build the robot with "
93
+ "use_degrees=True; the command space and the joint map are in degrees.")
94
+ try:
95
+ bus.connect() # opens the port and pings the servos; no register write
96
+ if not bus.is_calibrated:
97
+ raise AdapterError("The servos' stored calibration differs from the calibration file. "
98
+ "Re-run lerobot-calibrate, or use the file that matches the servos.")
99
+ present = bus.sync_read("Present_Position", normalize=False)
100
+ goal = bus.sync_read("Goal_Position", normalize=False)
101
+ torque = bus.sync_read("Torque_Enable", normalize=False)
102
+ holding = all(torque.get(m) == 1 and abs(goal[m] - present[m]) <= hold_within_ticks
103
+ for m in present)
104
+ if not holding:
105
+ bus.sync_write("Goal_Position", dict(present), normalize=False)
106
+ back = bus.sync_read("Goal_Position", normalize=False)
107
+ off = {m: back.get(m) for m in present if back.get(m) is None or abs(back[m] - present[m]) > 1}
108
+ if off:
109
+ raise AdapterError(f"The goal registers did not take the synced positions {off}; "
110
+ "not enabling torque. Check the bus wiring and power.")
111
+ robot.configure() # LeRobot's own: torque off, settings, torque on (now toward synced goals)
112
+ except BaseException:
113
+ _release(bus, log)
114
+ raise
115
+ log("Connected: " + ("the arm was already holding; goals left as they were." if holding
116
+ else "goal registers set to the present positions before torque."))
117
+ return {"holding_at_connect": holding, "goals_synced_to_present": not holding,
118
+ "torque_at_connect": dict(torque)}
119
+
120
+
121
+ def choose_stop_goals(present_raw: Mapping[str, int]) -> dict[str, int]:
122
+ """The goals a stop holds: every joint's present position, read at the FIRST stop of a latch.
123
+ The caller keeps them and re-sends exactly these on every later stop of the same latch.
124
+
125
+ Why the present position, once: the joint may be moving (perhaps toward a hostile goal), so the
126
+ first stop has to stop it where it is. A loaded joint then settles one sag below that goal.
127
+ Why never again: reading the (sagged) position on every repeated stop re-anchors the goal lower
128
+ each time, and the arm ratchets down (bob; so-arm101-actuator's estop before its PR #7).
129
+
130
+ Rejected alternative: keeping a joint's goal register when it is within a few degrees. That
131
+ removes even the single sag for an arm holding still, but a joint still travelling the last
132
+ few degrees to its goal runs on after the stop; in the simulator that kept the tool moving past
133
+ the 0.25 s acknowledgement window, which escalates. docs/seams/runner.md has the numbers.
134
+ """
135
+ return {m: int(p) for m, p in present_raw.items()}
136
+
137
+
138
+ def check_bus_is_ours(port: str, services: Sequence[str] = ()) -> list[str]:
139
+ """Problems that mean something else could command the arm: a process holding the serial port
140
+ open, or a named service that is running (a service may open the port only when it gets a
141
+ request, so an idle one does not show up as holding it; name it with --refuse-while-active).
142
+
143
+ Linux only (it reads /proc). Elsewhere it reports that it could not check, which the adapter
144
+ treats as a refusal."""
145
+ problems: list[str] = []
146
+ if not os.path.isdir("/proc"):
147
+ return [f"cannot check who holds {port}: no /proc on this system (the scan is Linux-only)"]
148
+ real = os.path.realpath(port)
149
+ me = os.getpid()
150
+ for pid in filter(str.isdigit, os.listdir("/proc")):
151
+ if int(pid) == me:
152
+ continue
153
+ try:
154
+ fds = os.listdir(f"/proc/{pid}/fd")
155
+ except OSError:
156
+ continue
157
+ for fd in fds:
158
+ try:
159
+ if os.path.realpath(f"/proc/{pid}/fd/{fd}") != real:
160
+ continue
161
+ with open(f"/proc/{pid}/cmdline", "rb") as fh:
162
+ cmd = fh.read().replace(b"\0", b" ").decode(errors="replace")[:120]
163
+ problems.append(f"process {pid} has {port} open: {cmd}")
164
+ break
165
+ except OSError:
166
+ continue
167
+ for unit in services:
168
+ for scope in (["--user"], []):
169
+ try:
170
+ r = subprocess.run(["systemctl", *scope, "is-active", unit], capture_output=True,
171
+ text=True, timeout=10, check=False)
172
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
173
+ problems.append(f"cannot check {unit}: {type(e).__name__}")
174
+ break
175
+ if r.stdout.strip() == "active":
176
+ where = "user" if scope else "system"
177
+ stop = " ".join([*scope, "stop", unit])
178
+ problems.append(f"{where} service {unit} is running (systemctl {stop})")
179
+ return problems
180
+
181
+
182
+ def leave_safe(bus: object, prompt: Callable[[str], str], log: Callable[[str], None]) -> str:
183
+ """Ask before turning torque off, then release the port without touching torque.
184
+
185
+ Only a typed ``OFF`` turns torque off (the arm goes limp: support it first). Just Enter, any
186
+ other answer, or end of input leaves torque on and the arm holding its last goal. LeRobot's
187
+ own ``robot.disconnect()`` is never used: ``disable_torque_on_disconnect`` defaults to True and
188
+ would drop the arm. Returns "torque_off" or "torque_on"."""
189
+ try:
190
+ answer = prompt("Support the arm, then type OFF and Enter to turn torque off (it goes limp), "
191
+ "or just Enter to leave torque on: ")
192
+ except EOFError:
193
+ answer = ""
194
+ outcome = "torque_on"
195
+ try:
196
+ if answer.strip() == "OFF":
197
+ bus.disable_torque()
198
+ outcome = "torque_off"
199
+ log("Torque off.")
200
+ else:
201
+ log("Torque left on; the arm holds its last goal.")
202
+ finally:
203
+ _release(bus, log)
204
+ return outcome