so101-workshop 0.1.0__py3-none-any.whl
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.
- so101_control/__init__.py +5 -0
- so101_control/arm.py +369 -0
- so101_control/backend.py +106 -0
- so101_control/estop.py +15 -0
- so101_control/feetech.py +632 -0
- so101_control/kinematics.py +838 -0
- so101_control/motion.py +120 -0
- so101_control/teleop.py +308 -0
- so101_utils/__init__.py +1 -0
- so101_utils/assets.py +200 -0
- so101_utils/auto_hand_eye.py +26 -0
- so101_utils/camera.py +643 -0
- so101_utils/click_app.py +112 -0
- so101_utils/click_view.py +172 -0
- so101_utils/config.py +183 -0
- so101_utils/hand_eye_flow.py +36 -0
- so101_utils/intrinsics.py +133 -0
- so101_utils/keys.py +54 -0
- so101_utils/model.py +69 -0
- so101_utils/moves.py +103 -0
- so101_utils/ports.py +59 -0
- so101_utils/tags.py +98 -0
- so101_utils/transforms.py +160 -0
- so101_utils/views.py +477 -0
- so101_workshop/__init__.py +1 -0
- so101_workshop/agent.py +1373 -0
- so101_workshop/cli.py +74 -0
- so101_workshop/detections.py +191 -0
- so101_workshop/hooks.py +22 -0
- so101_workshop/server.py +328 -0
- so101_workshop/static/index.html +2139 -0
- so101_workshop/station.py +46 -0
- so101_workshop/templates/camera.yaml +21 -0
- so101_workshop/templates/camera_demo.yaml +21 -0
- so101_workshop/templates/camera_logi.yaml +21 -0
- so101_workshop/templates/camera_realsense.yaml +15 -0
- so101_workshop/templates/camera_recamera.yaml +21 -0
- so101_workshop/templates/camera_usbcam.yaml +21 -0
- so101_workshop/templates/robot.yaml +26 -0
- so101_workshop/templates/workshop.yaml +16 -0
- so101_workshop/viewer.py +204 -0
- so101_workshop-0.1.0.dist-info/METADATA +175 -0
- so101_workshop-0.1.0.dist-info/RECORD +46 -0
- so101_workshop-0.1.0.dist-info/WHEEL +5 -0
- so101_workshop-0.1.0.dist-info/entry_points.txt +2 -0
- so101_workshop-0.1.0.dist-info/top_level.txt +3 -0
so101_control/arm.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Public arm API. Serial, offsets, and IK stay behind this class."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from so101_utils.config import joint_offsets, load_robot, validated_limits
|
|
7
|
+
from so101_utils.model import JOINTS, tool_rpy
|
|
8
|
+
|
|
9
|
+
from .backend import MockBackend, OffsetBackend, connect_live, release_overload
|
|
10
|
+
from .kinematics import DEFAULT_TOLERANCE_M, Kinematics, TURN_TOLERANCE_M
|
|
11
|
+
from .motion import MotionError, joint_state, move_joints
|
|
12
|
+
|
|
13
|
+
MAX_SPEED_DEG_S = 90.0
|
|
14
|
+
MIN_SPEED_DEG_S = 1.0
|
|
15
|
+
MAX_JOINT_STEP_DEG = 2.0
|
|
16
|
+
MOVE_TIMEOUT_S = 60.0
|
|
17
|
+
|
|
18
|
+
# Servo loop periods, seconds. The mock is slower so Rerun can draw each pose.
|
|
19
|
+
MOCK_PERIOD_S = 0.03
|
|
20
|
+
LIVE_PERIOD_S = 0.02
|
|
21
|
+
|
|
22
|
+
# Jaw settle: stop early once the position has held for GRIPPER_STABLE_S.
|
|
23
|
+
GRIPPER_TIMEOUT_S = 2.0
|
|
24
|
+
GRIPPER_TOLERANCE_PERCENT = 8.0
|
|
25
|
+
GRIPPER_MOVE_PERCENT = 0.8
|
|
26
|
+
GRIPPER_MIN_HOLD_S = 0.25
|
|
27
|
+
GRIPPER_STABLE_S = 0.2
|
|
28
|
+
GRIPPER_POLL_S = 0.05
|
|
29
|
+
|
|
30
|
+
# Metres along gripper_frame_link +Z.
|
|
31
|
+
DEFAULT_EE_TOOL_OFFSET_M = 0.020
|
|
32
|
+
|
|
33
|
+
# Gripper percent. 0 is closed and 100 is fully open. Opening stops halfway.
|
|
34
|
+
OPEN_PERCENT = 50.0
|
|
35
|
+
|
|
36
|
+
# Folded pose, degrees. The gripper is closed separately.
|
|
37
|
+
REST_JOINTS = {
|
|
38
|
+
"shoulder_pan": 0.0,
|
|
39
|
+
"shoulder_lift": -90.0,
|
|
40
|
+
"elbow_flex": 90.0,
|
|
41
|
+
"wrist_flex": 45.0,
|
|
42
|
+
"wrist_roll": 0.0,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Pose:
|
|
47
|
+
"""Gripper pose in the robot base frame."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, x: float, y: float, z: float, roll: float, pitch: float, yaw: float):
|
|
50
|
+
self.x, self.y, self.z = float(x), float(y), float(z)
|
|
51
|
+
self.roll, self.pitch, self.yaw = float(roll), float(pitch), float(yaw)
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
return (f"Pose(x={self.x:.4f} m, y={self.y:.4f} m, z={self.z:.4f} m, "
|
|
55
|
+
f"roll={self.roll:.1f} deg, pitch={self.pitch:.1f} deg, yaw={self.yaw:.1f} deg)")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SO101:
|
|
59
|
+
"""Connect, move, and read the follower. Use `with` so the port is closed.
|
|
60
|
+
|
|
61
|
+
`mock=True` keeps an in-memory arm: same IK, no serial.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, backend, kinematics: Kinematics, limits, *, mock: bool,
|
|
65
|
+
speed_deg_s: float = MAX_SPEED_DEG_S,
|
|
66
|
+
ee_tool_offset: float = DEFAULT_EE_TOOL_OFFSET_M):
|
|
67
|
+
self._backend = backend
|
|
68
|
+
self._kin = kinematics
|
|
69
|
+
self._limits = limits
|
|
70
|
+
self.mock = mock
|
|
71
|
+
self._goal = None
|
|
72
|
+
self._sent = None
|
|
73
|
+
self._target_pose = None
|
|
74
|
+
self._on_motion = None
|
|
75
|
+
self._on_target = None
|
|
76
|
+
self.torque_on = True
|
|
77
|
+
offset = float(ee_tool_offset)
|
|
78
|
+
if not math.isfinite(offset):
|
|
79
|
+
raise ValueError("ee_tool_offset must be finite")
|
|
80
|
+
self.ee_tool_offset = offset
|
|
81
|
+
if kinematics is not None:
|
|
82
|
+
kinematics.ee_tool_offset = offset
|
|
83
|
+
self.set_speed(speed_deg_s)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def connect(cls, mock: bool = False, config_path: str | None = None, *,
|
|
87
|
+
ee_tool_offset: float = DEFAULT_EE_TOOL_OFFSET_M,
|
|
88
|
+
require_calibration: bool = True) -> "SO101":
|
|
89
|
+
config = load_robot(config_path)
|
|
90
|
+
limits = validated_limits(config)
|
|
91
|
+
kinematics = Kinematics(config["urdf"], ee_tool_offset=ee_tool_offset)
|
|
92
|
+
if mock:
|
|
93
|
+
backend = MockBackend()
|
|
94
|
+
else:
|
|
95
|
+
backend = OffsetBackend(
|
|
96
|
+
connect_live(config, require_calibration=require_calibration),
|
|
97
|
+
joint_offsets(config),
|
|
98
|
+
)
|
|
99
|
+
return cls(backend, kinematics, limits, mock=mock, ee_tool_offset=ee_tool_offset)
|
|
100
|
+
|
|
101
|
+
def __enter__(self) -> "SO101":
|
|
102
|
+
return self
|
|
103
|
+
|
|
104
|
+
def __exit__(self, *_args) -> None:
|
|
105
|
+
self.close()
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
# A jaw driven into its stop latches an overload fault. Dropping the fault
|
|
109
|
+
# first lets torque-off finish and the port close.
|
|
110
|
+
self._unload_motors()
|
|
111
|
+
try:
|
|
112
|
+
self._backend.disconnect()
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
bus = getattr(self._backend, "bus", None)
|
|
115
|
+
if bus is not None and getattr(bus, "is_connected", False):
|
|
116
|
+
try:
|
|
117
|
+
bus.disconnect(False)
|
|
118
|
+
except Exception:
|
|
119
|
+
port = getattr(bus, "port_handler", None)
|
|
120
|
+
if port is not None:
|
|
121
|
+
try:
|
|
122
|
+
port.closePort()
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
print(f"Port closed. Torque-off stopped early: {exc}")
|
|
126
|
+
|
|
127
|
+
def _unload_motors(self) -> None:
|
|
128
|
+
if self.mock:
|
|
129
|
+
return
|
|
130
|
+
bus = getattr(self._backend, "bus", None)
|
|
131
|
+
if bus is None or not getattr(bus, "is_connected", False):
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
release_overload(bus)
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def track(self, callback) -> None:
|
|
139
|
+
"""Call `callback()` on connect and after every motion step."""
|
|
140
|
+
self._on_motion = callback
|
|
141
|
+
callback()
|
|
142
|
+
|
|
143
|
+
def refresh(self) -> None:
|
|
144
|
+
"""Run the `track` callback once, if one is registered."""
|
|
145
|
+
if self._on_motion is not None:
|
|
146
|
+
self._on_motion()
|
|
147
|
+
|
|
148
|
+
def on_target(self, callback) -> None:
|
|
149
|
+
"""Call `callback(pose)` with the 4x4 goal pose solved by each `move_to`."""
|
|
150
|
+
self._on_target = callback
|
|
151
|
+
|
|
152
|
+
def set_speed(self, deg_per_s: float) -> None:
|
|
153
|
+
"""Joint speed for later moves, in degrees per second (1 to 90)."""
|
|
154
|
+
speed = float(deg_per_s)
|
|
155
|
+
if not math.isfinite(speed) or not MIN_SPEED_DEG_S <= speed <= MAX_SPEED_DEG_S:
|
|
156
|
+
raise ValueError(f"Speed must be from {MIN_SPEED_DEG_S:.0f} to {MAX_SPEED_DEG_S:.0f} deg/s")
|
|
157
|
+
self._speed = speed
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def speed(self) -> float:
|
|
161
|
+
"""Joint speed used by later moves, in degrees per second."""
|
|
162
|
+
return self._speed
|
|
163
|
+
|
|
164
|
+
def get_joints(self) -> dict[str, float]:
|
|
165
|
+
"""Arm joints in degrees. Gripper is percent: 0 closed, 100 fully open."""
|
|
166
|
+
return joint_state(self._backend)
|
|
167
|
+
|
|
168
|
+
def gripper_frame_pose(self, joints: dict[str, float] | None = None):
|
|
169
|
+
"""4x4 pose of gripper_frame_link. `joints` defaults to the arm right now."""
|
|
170
|
+
return self._kin.fk(self.get_joints() if joints is None else joints)
|
|
171
|
+
|
|
172
|
+
def gripper_link_pose(self, joints: dict[str, float] | None = None):
|
|
173
|
+
"""4x4 pose of gripper_link, where the AprilTag is mounted."""
|
|
174
|
+
return self._kin.fk_gripper_link(self.get_joints() if joints is None else joints)
|
|
175
|
+
|
|
176
|
+
def get_pose(self) -> Pose:
|
|
177
|
+
"""Tool pose: metres and XYZ Euler degrees about the base axes.
|
|
178
|
+
|
|
179
|
+
Position is the point `ee_tool_offset` along gripper_frame_link +Z, the
|
|
180
|
+
same point `move_to` drives to. The frame drawn in Rerun stays on
|
|
181
|
+
gripper_frame_link.
|
|
182
|
+
"""
|
|
183
|
+
return self.pose_at(self.get_joints())
|
|
184
|
+
|
|
185
|
+
def pose_at(self, joints: dict[str, float]) -> Pose:
|
|
186
|
+
"""Tool pose for a joint sample already read from the arm."""
|
|
187
|
+
return self._pose_of(self._kin.fk(joints))
|
|
188
|
+
|
|
189
|
+
def _pose_of(self, matrix) -> Pose:
|
|
190
|
+
roll, pitch, yaw = tool_rpy(matrix[:3, :3])
|
|
191
|
+
x, y, z = self._kin.tool_point(matrix)
|
|
192
|
+
return Pose(x, y, z, roll, pitch, yaw)
|
|
193
|
+
|
|
194
|
+
def target_pose(self) -> Pose | None:
|
|
195
|
+
"""Tool pose of the last IK solution, before the move settled.
|
|
196
|
+
|
|
197
|
+
Teleop steps from this point: using the request instead lets the solve
|
|
198
|
+
error accumulate until the next step is a jump. None before a move.
|
|
199
|
+
"""
|
|
200
|
+
return self._target_pose
|
|
201
|
+
|
|
202
|
+
def move_to(self, x: float, y: float, z: float,
|
|
203
|
+
roll: float | None = None, pitch: float | None = None, yaw: float | None = None, *,
|
|
204
|
+
wait: bool = True, cancel=None, downward: bool = False,
|
|
205
|
+
single_goal: bool = False, hold_point: bool = False) -> bool:
|
|
206
|
+
"""Move the tool to a pose in the base frame.
|
|
207
|
+
|
|
208
|
+
x, y, z are metres of the tool point: `ee_tool_offset` along
|
|
209
|
+
gripper_frame_link +Z. roll, pitch, yaw are base-frame degrees; pass all
|
|
210
|
+
three or none. Pitch +90 points the fingers straight down with a fixed
|
|
211
|
+
twist. None keeps the current orientation, unless `downward` holds the
|
|
212
|
+
finger axis inside the down cone and leaves the twist free, which is what
|
|
213
|
+
reaches the edge of the table. `hold_point` keeps the tool point within a
|
|
214
|
+
couple of millimetres, lets yaw follow the pan, and refuses a branch
|
|
215
|
+
jump; a turn that cannot stay there raises instead of moving the arm
|
|
216
|
+
somewhere else. Returns False when `cancel` stops the move.
|
|
217
|
+
"""
|
|
218
|
+
point = (float(x), float(y), float(z))
|
|
219
|
+
given = (roll, pitch, yaw)
|
|
220
|
+
if any(value is not None for value in given) and any(value is None for value in given):
|
|
221
|
+
raise ValueError("roll, pitch, and yaw are one pose")
|
|
222
|
+
if hold_point and roll is None:
|
|
223
|
+
raise ValueError("hold_point needs roll, pitch, and yaw")
|
|
224
|
+
rpy = None if roll is None else (float(roll), float(pitch), float(yaw))
|
|
225
|
+
# Seed IK from the last command so a small step does not pick a new elbow
|
|
226
|
+
# while the encoders are still catching up. Skip the read when it covers
|
|
227
|
+
# every joint; a keyboard nudge should not wait on the serial port.
|
|
228
|
+
if self._sent and set(JOINTS) <= set(self._sent):
|
|
229
|
+
state = {name: float(self._sent[name]) for name in JOINTS}
|
|
230
|
+
else:
|
|
231
|
+
state = self.get_joints()
|
|
232
|
+
targets = self._kin.position_ik(
|
|
233
|
+
state, point, self._limits, downward=downward, rpy=rpy,
|
|
234
|
+
follow_yaw=hold_point, local_only=hold_point,
|
|
235
|
+
tolerance_m=TURN_TOLERANCE_M if hold_point else DEFAULT_TOLERANCE_M)
|
|
236
|
+
matrix = self._kin.fk(targets)
|
|
237
|
+
self._target_pose = self._pose_of(matrix)
|
|
238
|
+
if self._on_target is not None:
|
|
239
|
+
self._on_target(matrix)
|
|
240
|
+
return self._move(targets, wait=wait, cancel=cancel, single_goal=single_goal)
|
|
241
|
+
|
|
242
|
+
def move_joint(self, name: str, delta_deg: float) -> None:
|
|
243
|
+
"""Step one arm joint. The step must be non-zero and at most 2 degrees."""
|
|
244
|
+
delta = float(delta_deg)
|
|
245
|
+
if (name not in JOINTS or not math.isfinite(delta) or delta == 0
|
|
246
|
+
or abs(delta) > MAX_JOINT_STEP_DEG):
|
|
247
|
+
raise ValueError(f"Use an arm joint and a step of at most {MAX_JOINT_STEP_DEG:.0f} degrees")
|
|
248
|
+
if self._sent and name in self._sent:
|
|
249
|
+
base = float(self._sent[name])
|
|
250
|
+
else:
|
|
251
|
+
base = self.get_joints()[name]
|
|
252
|
+
self._move({name: base + delta})
|
|
253
|
+
|
|
254
|
+
def home(self, *, wait: bool = True, cancel=None) -> bool:
|
|
255
|
+
"""Move to the calibrated midpoint: every arm joint at 0 degrees."""
|
|
256
|
+
return self._move({name: 0.0 for name in JOINTS}, wait=wait, cancel=cancel)
|
|
257
|
+
|
|
258
|
+
def rest(self, *, wait: bool = True, cancel=None) -> bool:
|
|
259
|
+
"""Fold the arm and close the gripper.
|
|
260
|
+
|
|
261
|
+
shoulder_lift -90°, elbow_flex 90°, wrist_flex 45°, pan and roll at 0°.
|
|
262
|
+
Returns False when `cancel` stops the fold.
|
|
263
|
+
"""
|
|
264
|
+
arrived = self._move(dict(REST_JOINTS), wait=wait, cancel=cancel)
|
|
265
|
+
if arrived:
|
|
266
|
+
self.grip()
|
|
267
|
+
return arrived
|
|
268
|
+
|
|
269
|
+
def grip(self) -> None:
|
|
270
|
+
"""Close the gripper."""
|
|
271
|
+
self._gripper(0.0)
|
|
272
|
+
|
|
273
|
+
def release(self) -> None:
|
|
274
|
+
"""Open the gripper to the default stop, halfway."""
|
|
275
|
+
self._gripper(OPEN_PERCENT)
|
|
276
|
+
|
|
277
|
+
def wait(self) -> None:
|
|
278
|
+
"""Block until the last move is within the settle tolerance."""
|
|
279
|
+
if self._goal:
|
|
280
|
+
self._move(self._goal, wait=True)
|
|
281
|
+
|
|
282
|
+
def clear_commands(self) -> None:
|
|
283
|
+
"""Forget the last goal and command cache so the next move starts at the encoders."""
|
|
284
|
+
self._goal = None
|
|
285
|
+
self._sent = None
|
|
286
|
+
|
|
287
|
+
def relax(self) -> None:
|
|
288
|
+
"""Torque off. Support the arm; it can fall. Not an emergency stop."""
|
|
289
|
+
try:
|
|
290
|
+
if not self.mock:
|
|
291
|
+
self._backend.bus.disable_torque()
|
|
292
|
+
finally:
|
|
293
|
+
self.clear_commands()
|
|
294
|
+
self.torque_on = False
|
|
295
|
+
print("Torque OFF. Support the arm; it can fall.")
|
|
296
|
+
|
|
297
|
+
def hold(self) -> None:
|
|
298
|
+
"""Torque on, holding the position the arm is in right now."""
|
|
299
|
+
try:
|
|
300
|
+
if not self.mock:
|
|
301
|
+
bus = self._backend.bus
|
|
302
|
+
bus.sync_write("Goal_Position", bus.sync_read("Present_Position"))
|
|
303
|
+
bus.enable_torque()
|
|
304
|
+
finally:
|
|
305
|
+
self.clear_commands()
|
|
306
|
+
self.torque_on = True
|
|
307
|
+
print("Torque ON at the measured position.")
|
|
308
|
+
|
|
309
|
+
def _move(self, targets: dict[str, float], *, wait: bool = True, cancel=None,
|
|
310
|
+
single_goal: bool = False) -> bool:
|
|
311
|
+
self._goal = targets
|
|
312
|
+
start = None
|
|
313
|
+
if self._sent and set(targets) <= set(self._sent):
|
|
314
|
+
start = {name: float(self._sent[name]) for name in targets}
|
|
315
|
+
sent: dict[str, float] = {}
|
|
316
|
+
try:
|
|
317
|
+
done = move_joints(
|
|
318
|
+
self._backend, targets, self._limits,
|
|
319
|
+
speed_deg_s=self._speed, period_s=MOCK_PERIOD_S if self.mock else LIVE_PERIOD_S,
|
|
320
|
+
timeout_s=MOVE_TIMEOUT_S, wait=wait, on_step=self._on_motion, cancel=cancel,
|
|
321
|
+
start=start, commanded=sent, single_goal=single_goal,
|
|
322
|
+
)
|
|
323
|
+
except Exception:
|
|
324
|
+
self.clear_commands()
|
|
325
|
+
raise
|
|
326
|
+
if done is None:
|
|
327
|
+
# A cancelled move leaves the arm somewhere between the start and
|
|
328
|
+
# the goal. Never resume from that stale command.
|
|
329
|
+
self.clear_commands()
|
|
330
|
+
return False
|
|
331
|
+
if sent:
|
|
332
|
+
if self._sent is None:
|
|
333
|
+
self._sent = {}
|
|
334
|
+
self._sent.update(sent)
|
|
335
|
+
return True
|
|
336
|
+
|
|
337
|
+
def _gripper(self, percent: float) -> None:
|
|
338
|
+
"""Command the gripper (0 closed, 100 fully open).
|
|
339
|
+
|
|
340
|
+
A jaw that stops short is on the stop or an object; the goal is rewritten
|
|
341
|
+
to that position so the servo is not left in an overload fault.
|
|
342
|
+
"""
|
|
343
|
+
self._backend.send_action({"gripper.pos": percent})
|
|
344
|
+
self.refresh()
|
|
345
|
+
if self.mock:
|
|
346
|
+
return
|
|
347
|
+
deadline = time.monotonic() + GRIPPER_TIMEOUT_S
|
|
348
|
+
started = time.monotonic()
|
|
349
|
+
previous = None
|
|
350
|
+
stable_since = started
|
|
351
|
+
while time.monotonic() < deadline:
|
|
352
|
+
measured = self.get_joints()["gripper"]
|
|
353
|
+
if abs(measured - percent) <= GRIPPER_TOLERANCE_PERCENT:
|
|
354
|
+
return
|
|
355
|
+
now = time.monotonic()
|
|
356
|
+
if previous is None or abs(measured - previous) >= GRIPPER_MOVE_PERCENT:
|
|
357
|
+
stable_since = now
|
|
358
|
+
previous = measured
|
|
359
|
+
if now - started >= GRIPPER_MIN_HOLD_S and now - stable_since >= GRIPPER_STABLE_S:
|
|
360
|
+
self._hold_gripper(measured)
|
|
361
|
+
return
|
|
362
|
+
time.sleep(GRIPPER_POLL_S)
|
|
363
|
+
self._hold_gripper(self.get_joints()["gripper"])
|
|
364
|
+
raise MotionError("Gripper did not settle")
|
|
365
|
+
|
|
366
|
+
def _hold_gripper(self, percent: float) -> None:
|
|
367
|
+
self._backend.send_action({"gripper.pos": percent})
|
|
368
|
+
self.refresh()
|
|
369
|
+
print(f"Gripper holding at {percent:.0f}%.")
|
so101_control/backend.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Live Feetech follower and an in-memory stand-in. Both speak joint degrees."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
|
|
5
|
+
from so101_utils.model import JOINTS
|
|
6
|
+
|
|
7
|
+
# Time for a goal rewrite to reach the servo before torque-off.
|
|
8
|
+
OVERLOAD_RELEASE_S = 0.1
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class MockBackend:
|
|
12
|
+
"""Joint angles in the same frame as a calibrated arm. No serial port."""
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.positions = {f"{joint}.pos": 0.0 for joint in (*JOINTS, "gripper")}
|
|
16
|
+
self.connected = True
|
|
17
|
+
|
|
18
|
+
def get_observation(self) -> dict:
|
|
19
|
+
if not self.connected:
|
|
20
|
+
raise RuntimeError("Mock arm is closed")
|
|
21
|
+
return self.positions.copy()
|
|
22
|
+
|
|
23
|
+
def send_action(self, action: dict) -> dict:
|
|
24
|
+
if not self.connected:
|
|
25
|
+
raise RuntimeError("Mock arm is closed")
|
|
26
|
+
if not set(action) <= set(self.positions):
|
|
27
|
+
raise ValueError("Unknown joint")
|
|
28
|
+
self.positions.update(action)
|
|
29
|
+
return action.copy()
|
|
30
|
+
|
|
31
|
+
def disconnect(self) -> None:
|
|
32
|
+
self.connected = False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OffsetBackend:
|
|
36
|
+
"""Add station joint offsets on read, and subtract them before a motor goal."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, robot, offsets: dict[str, float]):
|
|
39
|
+
self.robot = robot
|
|
40
|
+
self.offsets = offsets
|
|
41
|
+
|
|
42
|
+
def _correct(self, values: dict, sign: float) -> dict:
|
|
43
|
+
corrected = {}
|
|
44
|
+
for key, value in values.items():
|
|
45
|
+
name = key[:-4] if key.endswith(".pos") else None
|
|
46
|
+
corrected[key] = value + sign * self.offsets[name] if name in self.offsets else value
|
|
47
|
+
return corrected
|
|
48
|
+
|
|
49
|
+
def get_observation(self) -> dict:
|
|
50
|
+
return self._correct(self.robot.get_observation(), 1.0)
|
|
51
|
+
|
|
52
|
+
def send_action(self, action: dict) -> dict:
|
|
53
|
+
return self._correct(self.robot.send_action(self._correct(action, -1.0)), 1.0)
|
|
54
|
+
|
|
55
|
+
def disconnect(self) -> None:
|
|
56
|
+
self.robot.disconnect()
|
|
57
|
+
|
|
58
|
+
def __getattr__(self, name):
|
|
59
|
+
return getattr(self.robot, name)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def release_overload(bus) -> None:
|
|
63
|
+
"""Point every goal at the measured angle so a stalled jaw can drop its fault."""
|
|
64
|
+
bus.sync_write("Goal_Position", bus.sync_read("Present_Position"))
|
|
65
|
+
time.sleep(OVERLOAD_RELEASE_S)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def connect_follower(follower) -> None:
|
|
69
|
+
"""Connect, and if a jaw is latched in overload, drop that fault and configure again."""
|
|
70
|
+
try:
|
|
71
|
+
follower.connect(calibrate=False)
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
# configure() turns torque off. A jaw left against its stop rejects that write.
|
|
74
|
+
if not (follower.bus.is_connected and "Overload" in str(exc)):
|
|
75
|
+
raise
|
|
76
|
+
release_overload(follower.bus)
|
|
77
|
+
follower.configure()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def connect_live(config: dict, *, require_calibration: bool = True):
|
|
81
|
+
"""Open the follower on `port` with the thin STS3215 bus."""
|
|
82
|
+
port = config.get("port")
|
|
83
|
+
if not port:
|
|
84
|
+
raise ValueError("Set port in config/robot.yaml")
|
|
85
|
+
from pathlib import Path
|
|
86
|
+
|
|
87
|
+
from so101_utils.config import CONFIG
|
|
88
|
+
|
|
89
|
+
from .feetech import FeetechFollower
|
|
90
|
+
|
|
91
|
+
calibration_path = CONFIG / "motors.json"
|
|
92
|
+
follower = FeetechFollower(port, calibration_path=Path(calibration_path))
|
|
93
|
+
try:
|
|
94
|
+
follower.connect()
|
|
95
|
+
if require_calibration and not follower.is_calibrated:
|
|
96
|
+
raise RuntimeError(
|
|
97
|
+
f"No motor calibration for {port}. config/motors.json needs a homing offset "
|
|
98
|
+
"and a range for every joint."
|
|
99
|
+
)
|
|
100
|
+
except BaseException:
|
|
101
|
+
try:
|
|
102
|
+
follower.disconnect(False)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
raise
|
|
106
|
+
return follower
|
so101_control/estop.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Local torque-off. The event is set before any serial call."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def trip(event: threading.Event, torque_off: Callable[[], None] | None) -> None:
|
|
8
|
+
"""Latch e-stop, then turn torque off.
|
|
9
|
+
|
|
10
|
+
`torque_off` may block on the serial lock. Callers observe `event` as set
|
|
11
|
+
while that wait is still in progress.
|
|
12
|
+
"""
|
|
13
|
+
event.set()
|
|
14
|
+
if torque_off is not None:
|
|
15
|
+
torque_off()
|