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.
- bounded_embodiment_kit-0.1.0/.gitignore +15 -0
- bounded_embodiment_kit-0.1.0/PKG-INFO +39 -0
- bounded_embodiment_kit-0.1.0/README.md +14 -0
- bounded_embodiment_kit-0.1.0/pyproject.toml +35 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/__init__.py +2 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/__init__.py +29 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/base.py +192 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/feetech.py +204 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/lerobot.py +316 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/python.py +136 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/sim_rover.py +206 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/adapters/sim_so101.py +615 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/cli.py +23 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/__init__.py +1 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/accuracy.py +866 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/__init__.py +16 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/background.py +123 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/cli.py +319 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/fit.py +1346 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/mesh.py +253 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/model.py +316 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/register.py +368 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/markerless/synth.py +669 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/plan.py +279 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/record.py +2481 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/instruments/session.py +291 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/kinematics/__init__.py +5 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/kinematics/ik.py +109 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/kinematics/urdf.py +401 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/__init__.py +4 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/cli.py +170 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/doctor.py +128 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/feeds.py +315 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/interlocks.py +122 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/kin.py +75 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/limits.py +116 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/run.py +701 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/session.py +422 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/runner/watchdog.py +484 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/__init__.py +11 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/cli.py +96 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/files.py +127 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/ids.py +91 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/lab-config-0.1.json +50 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/labconfig.py +77 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/report.py +733 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/scrub.py +342 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/submit.py +328 -0
- bounded_embodiment_kit-0.1.0/src/bounded_embodiment_kit/submission/timebase.py +396 -0
- bounded_embodiment_kit-0.1.0/tests/_b6.py +401 -0
- bounded_embodiment_kit-0.1.0/tests/_markerless_support.py +166 -0
- bounded_embodiment_kit-0.1.0/tests/_record_fakes.py +359 -0
- bounded_embodiment_kit-0.1.0/tests/_runner_fakes.py +193 -0
- bounded_embodiment_kit-0.1.0/tests/_submission_support.py +233 -0
- bounded_embodiment_kit-0.1.0/tests/_virtual_time.py +273 -0
- bounded_embodiment_kit-0.1.0/tests/conftest.py +94 -0
- bounded_embodiment_kit-0.1.0/tests/data/bob_calibration.json +9 -0
- bounded_embodiment_kit-0.1.0/tests/data/so101_fk_golden.json +149 -0
- bounded_embodiment_kit-0.1.0/tests/test_b6_matrix.py +441 -0
- bounded_embodiment_kit-0.1.0/tests/test_instrument_check.py +498 -0
- bounded_embodiment_kit-0.1.0/tests/test_instrument_plan.py +169 -0
- bounded_embodiment_kit-0.1.0/tests/test_kinematics_urdf.py +95 -0
- bounded_embodiment_kit-0.1.0/tests/test_markerless_fit.py +312 -0
- bounded_embodiment_kit-0.1.0/tests/test_markerless_mesh.py +164 -0
- bounded_embodiment_kit-0.1.0/tests/test_markerless_register.py +159 -0
- bounded_embodiment_kit-0.1.0/tests/test_markerless_session.py +104 -0
- bounded_embodiment_kit-0.1.0/tests/test_record_board.py +98 -0
- bounded_embodiment_kit-0.1.0/tests/test_record_check.py +260 -0
- bounded_embodiment_kit-0.1.0/tests/test_record_cli.py +120 -0
- bounded_embodiment_kit-0.1.0/tests/test_record_oak.py +151 -0
- bounded_embodiment_kit-0.1.0/tests/test_record_session.py +252 -0
- bounded_embodiment_kit-0.1.0/tests/test_record_writers.py +163 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_adapter_cli.py +50 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_doctor.py +43 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_e2e_sim.py +262 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_feeds.py +95 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_interlocks.py +189 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_stop_ratchet.py +113 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_tripwires.py +171 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_watchdog.py +217 -0
- bounded_embodiment_kit-0.1.0/tests/test_runner_wrapper_connect.py +162 -0
- bounded_embodiment_kit-0.1.0/tests/test_submission_e2e_sim.py +275 -0
- bounded_embodiment_kit-0.1.0/tests/test_submission_files.py +117 -0
- bounded_embodiment_kit-0.1.0/tests/test_submission_labconfig.py +85 -0
- bounded_embodiment_kit-0.1.0/tests/test_submission_report.py +350 -0
- bounded_embodiment_kit-0.1.0/tests/test_submission_scrub.py +113 -0
- bounded_embodiment_kit-0.1.0/tests/test_submission_submit.py +248 -0
- bounded_embodiment_kit-0.1.0/tests/test_submission_timebase.py +167 -0
|
@@ -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,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
|