litegrip-python 1.1.2__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.
- litegrip/__init__.py +298 -0
- litegrip/actions.py +1444 -0
- litegrip/calibration_normal.json +9 -0
- litegrip/calibration_reverse.json +9 -0
- litegrip/can/__init__.py +41 -0
- litegrip/can/controller.py +413 -0
- litegrip/can/motor.py +188 -0
- litegrip/can/protocol.py +380 -0
- litegrip/can/transport.py +365 -0
- litegrip/constants.py +146 -0
- litegrip/exceptions.py +47 -0
- litegrip/factory_calibration.json +16 -0
- litegrip/gripper.py +2678 -0
- litegrip/models.py +210 -0
- litegrip/protocols/__init__.py +10 -0
- litegrip/protocols/can_bus.py +621 -0
- litegrip/py.typed +0 -0
- litegrip/teleop.py +1469 -0
- litegrip/trajectory.py +985 -0
- litegrip/zenoh_link.py +400 -0
- litegrip_python-1.1.2.dist-info/METADATA +636 -0
- litegrip_python-1.1.2.dist-info/RECORD +25 -0
- litegrip_python-1.1.2.dist-info/WHEEL +5 -0
- litegrip_python-1.1.2.dist-info/licenses/LICENSE +202 -0
- litegrip_python-1.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
"""LiteGrip CAN bus layer — self-contained, no damiao_socketcan dependency.
|
|
2
|
+
|
|
3
|
+
Wraps the litegrip.can subpackage (transport + protocol + motor + controller)
|
|
4
|
+
into a simplified interface tailored for a single gripper motor.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
import time
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from ..can.transport import CanTransport, CanMode as TransportMode
|
|
15
|
+
from ..can.controller import MotorController
|
|
16
|
+
from ..can.motor import MotorState, MotorType
|
|
17
|
+
from ..can.protocol import (
|
|
18
|
+
ControlMode,
|
|
19
|
+
ControlModeCode,
|
|
20
|
+
DM_REG,
|
|
21
|
+
)
|
|
22
|
+
from ..constants import GripperParams, DefaultParams
|
|
23
|
+
from ..exceptions import (CommandError, CommError, ConnectError, HardwareError,
|
|
24
|
+
NotInitializedError)
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger("litegrip.can_bus")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LiteGripCAN:
|
|
30
|
+
"""LiteGrip CAN communication client.
|
|
31
|
+
|
|
32
|
+
Manages a single gripper motor (DM4310 by default) over SocketCAN.
|
|
33
|
+
Entirely self-contained — does NOT import damiao_socketcan or any
|
|
34
|
+
arm-specific library.
|
|
35
|
+
|
|
36
|
+
Typical usage::
|
|
37
|
+
|
|
38
|
+
can = LiteGripCAN(channel="can0")
|
|
39
|
+
can.connect()
|
|
40
|
+
can.register_gripper(can_id=0x08)
|
|
41
|
+
can.initialize()
|
|
42
|
+
can.control_mit_stream(target_position, kp=100, kd=2, duration_s=1.0)
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
channel: str = DefaultParams.CAN_CHANNEL,
|
|
48
|
+
canfd_mode: bool = DefaultParams.CANFD_MODE,
|
|
49
|
+
):
|
|
50
|
+
self._channel = channel
|
|
51
|
+
self._canfd_mode = canfd_mode
|
|
52
|
+
self._transport: Optional[CanTransport] = None
|
|
53
|
+
self._controller: Optional[MotorController] = None
|
|
54
|
+
self._motor: Optional[MotorState] = None
|
|
55
|
+
self._connected = False
|
|
56
|
+
self._initialized = False
|
|
57
|
+
# 上一次 MIT 下发为什么没出去 —— 见 last_send_error。
|
|
58
|
+
self._last_send_error: Optional[Exception] = None
|
|
59
|
+
|
|
60
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
61
|
+
# Connection
|
|
62
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
63
|
+
|
|
64
|
+
def connect(self) -> bool:
|
|
65
|
+
"""Open the CAN transport. Auto-detects CAN/CAN-FD mode."""
|
|
66
|
+
if self._connected:
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
# None → let CanTransport auto-detect via interface MTU
|
|
70
|
+
mode = TransportMode.CANFD if self._canfd_mode else TransportMode.CAN
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
self._transport = CanTransport(self._channel, mode)
|
|
74
|
+
self._transport.open()
|
|
75
|
+
except OSError as e:
|
|
76
|
+
raise ConnectError(f"CAN 接口 '{self._channel}' 不可用: {e}")
|
|
77
|
+
|
|
78
|
+
self._controller = MotorController(self._transport)
|
|
79
|
+
self._connected = True
|
|
80
|
+
log.info("LiteGripCAN connected on %s (mode=%s)",
|
|
81
|
+
self._channel, self._transport.mode.name)
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
def disconnect(self, disable: bool = True) -> None:
|
|
85
|
+
"""Close the CAN transport and release resources.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
disable: Send a disable command first (default). ``False``
|
|
89
|
+
leaves the motor enabled after the transport is closed.
|
|
90
|
+
"""
|
|
91
|
+
if not self._connected:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if self._initialized and disable:
|
|
95
|
+
try:
|
|
96
|
+
self.disable()
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
if self._controller:
|
|
101
|
+
self._controller.close()
|
|
102
|
+
elif self._transport:
|
|
103
|
+
self._transport.close()
|
|
104
|
+
|
|
105
|
+
self._transport = None
|
|
106
|
+
self._controller = None
|
|
107
|
+
self._motor = None
|
|
108
|
+
self._connected = False
|
|
109
|
+
self._initialized = False
|
|
110
|
+
|
|
111
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
112
|
+
# Motor registration
|
|
113
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
114
|
+
|
|
115
|
+
def register_gripper(
|
|
116
|
+
self,
|
|
117
|
+
can_id: int = GripperParams.CAN_ID,
|
|
118
|
+
mst_id: Optional[int] = None,
|
|
119
|
+
motor_type: MotorType = MotorType.DM4310,
|
|
120
|
+
) -> int:
|
|
121
|
+
"""Register the gripper motor.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
can_id: Motor CAN ID (usually 0x08).
|
|
125
|
+
mst_id: Master ID. None = auto-detect via register read / scan.
|
|
126
|
+
motor_type: Motor model (default DM4310).
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
The actual (possibly auto-detected) MST_ID.
|
|
130
|
+
"""
|
|
131
|
+
if not self._connected or self._controller is None:
|
|
132
|
+
raise CommError("CAN 未连接")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
self._motor = self._controller.add_motor(
|
|
136
|
+
can_id=can_id,
|
|
137
|
+
mst_id=mst_id,
|
|
138
|
+
motor_type=motor_type,
|
|
139
|
+
control_mode=ControlMode.MIT_MODE,
|
|
140
|
+
)
|
|
141
|
+
return self._motor.mst_id
|
|
142
|
+
except Exception as e:
|
|
143
|
+
raise CommError(f"注册夹爪电机失败: {e}")
|
|
144
|
+
|
|
145
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
146
|
+
# Initialization / enable / disable
|
|
147
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
148
|
+
|
|
149
|
+
def initialize(self) -> bool:
|
|
150
|
+
"""Full initialization: disable → switch to MIT → enable → verify feedback.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
True if the motor reports ``err == 1`` (enabled). ``err == 0``
|
|
154
|
+
means the enable command did not take effect — the attempt is
|
|
155
|
+
retried rather than reported as success.
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
HardwareError: on timeout, persistent fault, no feedback, or if
|
|
159
|
+
every attempt leaves the motor not enabled (``err == 0``).
|
|
160
|
+
"""
|
|
161
|
+
if not self._connected or self._controller is None or self._motor is None:
|
|
162
|
+
raise NotInitializedError("未连接或未注册")
|
|
163
|
+
|
|
164
|
+
ctrl = self._controller
|
|
165
|
+
m = self._motor
|
|
166
|
+
last_err = -1
|
|
167
|
+
|
|
168
|
+
for attempt in range(GripperParams.FAULT_CLEAR_RETRIES):
|
|
169
|
+
try:
|
|
170
|
+
# 1. Disable
|
|
171
|
+
ctrl.disable(m)
|
|
172
|
+
time.sleep(0.01)
|
|
173
|
+
|
|
174
|
+
# 2. Switch to MIT mode
|
|
175
|
+
if not ctrl.switch_control_mode(m, ControlModeCode.MIT):
|
|
176
|
+
log.warning("switchControlMode verification failed; proceeding anyway")
|
|
177
|
+
time.sleep(0.05)
|
|
178
|
+
|
|
179
|
+
# 3. Enable
|
|
180
|
+
ctrl.enable(m)
|
|
181
|
+
|
|
182
|
+
# 4. Send zero-torque MIT frame and poll for feedback
|
|
183
|
+
prev_rx = m.rx_count
|
|
184
|
+
ctrl.control_mit(m, kp=0, kd=0, q=0, dq=0, tau=0)
|
|
185
|
+
|
|
186
|
+
deadline = time.monotonic() + DefaultParams.INIT_TIMEOUT_S
|
|
187
|
+
while time.monotonic() < deadline:
|
|
188
|
+
ctrl.poll(timeout_s=0.01)
|
|
189
|
+
if m.rx_count > prev_rx:
|
|
190
|
+
err = m.error
|
|
191
|
+
if err == 1:
|
|
192
|
+
self._initialized = True
|
|
193
|
+
return True
|
|
194
|
+
# err == 0 → the enable frame was lost; retry.
|
|
195
|
+
# anything else → a real fault; retry after clearing.
|
|
196
|
+
last_err = err
|
|
197
|
+
break
|
|
198
|
+
time.sleep(0.005)
|
|
199
|
+
else:
|
|
200
|
+
last_err = -2 # timeout, no feedback
|
|
201
|
+
|
|
202
|
+
except Exception:
|
|
203
|
+
last_err = -3
|
|
204
|
+
|
|
205
|
+
# Retry: clear fault before next attempt
|
|
206
|
+
if attempt < GripperParams.FAULT_CLEAR_RETRIES - 1:
|
|
207
|
+
ctrl.clear_fault(m)
|
|
208
|
+
time.sleep(0.005)
|
|
209
|
+
ctrl.enable(m)
|
|
210
|
+
time.sleep(0.01)
|
|
211
|
+
|
|
212
|
+
# All retries exhausted → raise
|
|
213
|
+
if last_err == 0x9:
|
|
214
|
+
raise HardwareError(
|
|
215
|
+
"电机欠压故障 (UV_FAULT),请检查夹爪 24V 供电", error_code=0x9)
|
|
216
|
+
elif last_err == 0:
|
|
217
|
+
raise HardwareError(
|
|
218
|
+
"使能未生效(err=0,enable 命令可能丢帧)", error_code=0)
|
|
219
|
+
elif last_err == -2:
|
|
220
|
+
raise HardwareError(
|
|
221
|
+
f"电机无反馈(超时 {DefaultParams.INIT_TIMEOUT_S}s),"
|
|
222
|
+
"请检查夹爪供电和 CAN 连接")
|
|
223
|
+
elif last_err != -3:
|
|
224
|
+
raise HardwareError(
|
|
225
|
+
f"电机故障 0x{last_err:X} 无法清除", error_code=last_err)
|
|
226
|
+
else:
|
|
227
|
+
raise HardwareError("初始化失败:所有重试耗尽")
|
|
228
|
+
|
|
229
|
+
def enable(self) -> bool:
|
|
230
|
+
"""Send enable command only (no full init). Prefer initialize()."""
|
|
231
|
+
if self._controller is None or self._motor is None:
|
|
232
|
+
return False
|
|
233
|
+
try:
|
|
234
|
+
self._controller.enable(self._motor)
|
|
235
|
+
self._controller.control_mit(self._motor, kp=0, kd=0, q=0)
|
|
236
|
+
self._controller.poll(timeout_s=0.05)
|
|
237
|
+
return self._motor.error in (0, 1)
|
|
238
|
+
except Exception:
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
def disable(self) -> bool:
|
|
242
|
+
"""Send disable command."""
|
|
243
|
+
if self._controller is None or self._motor is None:
|
|
244
|
+
return False
|
|
245
|
+
try:
|
|
246
|
+
self._controller.disable(self._motor)
|
|
247
|
+
self._initialized = False
|
|
248
|
+
return True
|
|
249
|
+
except Exception:
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
def set_zero(self) -> bool:
|
|
253
|
+
"""Make the current position the motor's encoder zero (CMD 0xFE).
|
|
254
|
+
|
|
255
|
+
This is the raw frame only. A DM motor **ignores 0xFE while it is
|
|
256
|
+
enabled**, so the caller must send :meth:`disable` first (and re-enable
|
|
257
|
+
afterwards if it wants to keep moving) — that is the order the proven
|
|
258
|
+
bench tool uses. The motor must already be sitting at the position you
|
|
259
|
+
want to call zero before this is sent; this method sends the command but
|
|
260
|
+
does not verify, so the caller reads the position back to confirm.
|
|
261
|
+
"""
|
|
262
|
+
if self._controller is None or self._motor is None:
|
|
263
|
+
return False
|
|
264
|
+
try:
|
|
265
|
+
self._controller.set_zero(self._motor)
|
|
266
|
+
return True
|
|
267
|
+
except Exception:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
271
|
+
# Fault handling
|
|
272
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
273
|
+
|
|
274
|
+
def _clear_fault(self) -> bool:
|
|
275
|
+
"""[Deprecated] Use :meth:`clear_fault` instead."""
|
|
276
|
+
return self.clear_fault()
|
|
277
|
+
|
|
278
|
+
def clear_fault(self) -> bool:
|
|
279
|
+
"""Clear latched fault and re-enable the motor.
|
|
280
|
+
|
|
281
|
+
Tries two strategies:
|
|
282
|
+
1. Direct clear (0xFB) + enable (0xFC) — works for UV/OC/OT faults
|
|
283
|
+
where the motor is already effectively disabled.
|
|
284
|
+
2. Full disable → clear → enable cycle.
|
|
285
|
+
"""
|
|
286
|
+
if self._controller is None or self._motor is None:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
ctrl = self._controller
|
|
290
|
+
m = self._motor
|
|
291
|
+
|
|
292
|
+
for _ in range(GripperParams.FAULT_CLEAR_RETRIES):
|
|
293
|
+
# Strategy 1: Direct clear + enable (proven for UV_FAULT)
|
|
294
|
+
ctrl.clear_fault(m)
|
|
295
|
+
time.sleep(0.005)
|
|
296
|
+
ctrl.enable(m)
|
|
297
|
+
time.sleep(0.01)
|
|
298
|
+
|
|
299
|
+
# Verify
|
|
300
|
+
ctrl.control_mit(m, kp=0, kd=0, q=0)
|
|
301
|
+
for _ in range(20):
|
|
302
|
+
ctrl.poll(timeout_s=0.01)
|
|
303
|
+
time.sleep(0.003)
|
|
304
|
+
if m.error in (0, 1):
|
|
305
|
+
return True
|
|
306
|
+
|
|
307
|
+
# Strategy 2: Full disable → clear → enable
|
|
308
|
+
ctrl.disable(m)
|
|
309
|
+
time.sleep(0.01)
|
|
310
|
+
ctrl.clear_fault(m)
|
|
311
|
+
time.sleep(0.01)
|
|
312
|
+
ctrl.enable(m)
|
|
313
|
+
time.sleep(0.01)
|
|
314
|
+
|
|
315
|
+
ctrl.control_mit(m, kp=0, kd=0, q=0)
|
|
316
|
+
for _ in range(20):
|
|
317
|
+
ctrl.poll(timeout_s=0.01)
|
|
318
|
+
time.sleep(0.003)
|
|
319
|
+
if m.error in (0, 1):
|
|
320
|
+
return True
|
|
321
|
+
|
|
322
|
+
return False
|
|
323
|
+
|
|
324
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
325
|
+
# Motion control
|
|
326
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
327
|
+
|
|
328
|
+
def _check_motion_command(self, q_target: float, kp: float, kd: float,
|
|
329
|
+
dq_target: float,
|
|
330
|
+
tau_feedforward: float) -> None:
|
|
331
|
+
"""拒收一帧 MIT 不该承载的指令(见 :meth:`control_mit`)。
|
|
332
|
+
|
|
333
|
+
这一层是**所有**下发的必经之路 —— 引擎的每一帧、``goto_rad`` 的整段
|
|
334
|
+
流、teleop 的连续帧,最后都落在这里 —— 所以数值边界放在这里一次
|
|
335
|
+
就够。上层的判据(领先上限、保力预算)管的是「走多快、压多硬」,这里
|
|
336
|
+
管的是「这一帧本身是不是一个指令」。
|
|
337
|
+
"""
|
|
338
|
+
for name, value in (("q_target", q_target), ("kp", kp), ("kd", kd),
|
|
339
|
+
("dq_target", dq_target),
|
|
340
|
+
("tau_feedforward", tau_feedforward)):
|
|
341
|
+
if not math.isfinite(value):
|
|
342
|
+
raise CommandError(
|
|
343
|
+
f"MIT 指令的 {name} 不是有限数:{value!r}。非有限值在编码"
|
|
344
|
+
"器里会被映射到量程的一端(如 q → +12.5 rad、tau → "
|
|
345
|
+
"+10 Nm),那是一个调用方没要过的真实指令,所以拒绝下发。")
|
|
346
|
+
|
|
347
|
+
# 帧量程按型号来(DM4310 是 10 Nm,DM4340 是 28 Nm)。鸭子类型的替身
|
|
348
|
+
# 没带 limits,退回 DM4310 —— 本 SDK 的夹爪就是它。
|
|
349
|
+
limits = getattr(self._motor, "limits", None)
|
|
350
|
+
tau_max = limits.tau_max if limits is not None else 10.0
|
|
351
|
+
if abs(tau_feedforward) > tau_max:
|
|
352
|
+
raise CommandError(
|
|
353
|
+
f"前馈力矩 {tau_feedforward} Nm 超出本型号的帧量程 "
|
|
354
|
+
f"±{tau_max} Nm。超量程会被静默钳到端值 —— 那是另一个力,"
|
|
355
|
+
"不是要的那个,所以拒绝下发。")
|
|
356
|
+
|
|
357
|
+
def control_mit(self, q_target: float, kp: float, kd: float,
|
|
358
|
+
dq_target: float = 0.0,
|
|
359
|
+
tau_feedforward: float = 0.0) -> bool:
|
|
360
|
+
"""Send a single MIT control frame.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
q_target: Target position (rad).
|
|
364
|
+
kp: Position stiffness.
|
|
365
|
+
kd: Velocity damping.
|
|
366
|
+
dq_target: Target velocity (rad/s).
|
|
367
|
+
tau_feedforward: Feed-forward torque (Nm).
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
True if the frame was sent, False when there is no connection or the
|
|
371
|
+
send failed. ``False`` alone cannot tell "not enabled" from
|
|
372
|
+
"the buffer was full for 100 ms", and those need different
|
|
373
|
+
fixes, so the reason is kept for :attr:`last_send_error` and
|
|
374
|
+
logged rather than raised.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
CommandError: a field is NaN/infinite, or the torque exceeds the
|
|
378
|
+
frame's range. Those are caller bugs rather than transport
|
|
379
|
+
conditions, so they are raised instead of being folded into the
|
|
380
|
+
``False`` return.
|
|
381
|
+
"""
|
|
382
|
+
self._check_motion_command(q_target, kp, kd, dq_target,
|
|
383
|
+
tau_feedforward)
|
|
384
|
+
if self._controller is None or self._motor is None:
|
|
385
|
+
self._last_send_error = NotInitializedError("未连接或未注册")
|
|
386
|
+
return False
|
|
387
|
+
try:
|
|
388
|
+
self._controller.control_mit(
|
|
389
|
+
self._motor, kp=kp, kd=kd,
|
|
390
|
+
q=q_target, dq=dq_target, tau=tau_feedforward)
|
|
391
|
+
self._last_send_error = None
|
|
392
|
+
return True
|
|
393
|
+
except Exception as e:
|
|
394
|
+
self._last_send_error = e
|
|
395
|
+
log.warning("MIT 帧下发失败(%s):%r", self._channel, e)
|
|
396
|
+
return False
|
|
397
|
+
|
|
398
|
+
def poll(self, timeout_s: float = 0.0) -> bool:
|
|
399
|
+
"""Poll for one CAN frame and update motor state if relevant.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
timeout_s: Max wait time (0 = non-blocking).
|
|
403
|
+
|
|
404
|
+
Returns:
|
|
405
|
+
True if a frame for our motor was received.
|
|
406
|
+
"""
|
|
407
|
+
if self._controller is None:
|
|
408
|
+
return False
|
|
409
|
+
return self._controller.poll(timeout_s=timeout_s) is not None
|
|
410
|
+
|
|
411
|
+
def control_mit_stream(
|
|
412
|
+
self,
|
|
413
|
+
q_target: float,
|
|
414
|
+
kp: float,
|
|
415
|
+
kd: float,
|
|
416
|
+
duration_s: float,
|
|
417
|
+
dq_target: float = 0.0,
|
|
418
|
+
tau_feedforward: float = 0.0,
|
|
419
|
+
interval_s: float = 0.005,
|
|
420
|
+
) -> bool:
|
|
421
|
+
"""Stream MIT frames for a duration (blocking).
|
|
422
|
+
|
|
423
|
+
DM motors require continuous MIT frames to sustain motion.
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
True only when every frame of the stream went out. A stream with
|
|
427
|
+
failed frames returns False and logs how many — a single dropped
|
|
428
|
+
frame no longer aborts the stream, because the motor needs the rest
|
|
429
|
+
of the frames, but it must not come back as success either.
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
CommandError: a field is NaN/infinite, the torque exceeds the
|
|
433
|
+
frame's range, or the timing is not usable — the same rule as
|
|
434
|
+
:meth:`control_mit`, checked once before the first frame rather
|
|
435
|
+
than per frame.
|
|
436
|
+
CommError: not one frame could be sent. The bus or the enable is
|
|
437
|
+
broken, and streaming nothing for the whole duration while
|
|
438
|
+
returning success is exactly the silent failure this method used
|
|
439
|
+
to have.
|
|
440
|
+
"""
|
|
441
|
+
self._check_motion_command(q_target, kp, kd, dq_target,
|
|
442
|
+
tau_feedforward)
|
|
443
|
+
for name, value in (("duration_s", duration_s),
|
|
444
|
+
("interval_s", interval_s)):
|
|
445
|
+
if not math.isfinite(value):
|
|
446
|
+
raise CommandError(
|
|
447
|
+
f"{name} 不是有限数:{value!r}。拒绝下发:duration_s 为 "
|
|
448
|
+
"NaN 时 ``while now < deadline`` 恒为假 —— 整段一帧都不发"
|
|
449
|
+
"却返回 True,调用方以为夹爪动了。")
|
|
450
|
+
if duration_s <= 0.0:
|
|
451
|
+
# 与上面 NaN 同一个洞:0 或负数让循环体一次都不进,一帧不发。
|
|
452
|
+
raise CommandError(
|
|
453
|
+
f"duration_s 必须为正:{duration_s!r}。这个时长里一帧都不会"
|
|
454
|
+
"下发,而返回成功就是谎报运动。")
|
|
455
|
+
if interval_s < 0.0:
|
|
456
|
+
raise CommandError(f"interval_s 不能为负:{interval_s!r}。")
|
|
457
|
+
if self._controller is None or self._motor is None:
|
|
458
|
+
self._last_send_error = NotInitializedError("未连接或未注册")
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
ctrl = self._controller
|
|
462
|
+
m = self._motor
|
|
463
|
+
deadline = time.monotonic() + duration_s
|
|
464
|
+
|
|
465
|
+
attempts = 0
|
|
466
|
+
sent = 0
|
|
467
|
+
last_error: Optional[Exception] = None
|
|
468
|
+
while time.monotonic() < deadline:
|
|
469
|
+
attempts += 1
|
|
470
|
+
try:
|
|
471
|
+
ctrl.control_mit(m, kp=kp, kd=kd,
|
|
472
|
+
q=q_target, dq=dq_target,
|
|
473
|
+
tau=tau_feedforward)
|
|
474
|
+
sent += 1
|
|
475
|
+
except Exception as e:
|
|
476
|
+
last_error = e
|
|
477
|
+
ctrl.poll(timeout_s=0.0)
|
|
478
|
+
time.sleep(interval_s)
|
|
479
|
+
|
|
480
|
+
failed = attempts - sent
|
|
481
|
+
# 与 control_mit 同一口径:整段没发出去是失败,部分丢帧也是失败,
|
|
482
|
+
# 两种情况都把原因留给 last_send_error;全部发出去就清空。
|
|
483
|
+
self._last_send_error = last_error if failed else None
|
|
484
|
+
if failed:
|
|
485
|
+
log.warning("%s:%d 帧里 %d 帧没发出去(最后一次:%r)",
|
|
486
|
+
self._channel, attempts, failed, last_error)
|
|
487
|
+
if sent == 0:
|
|
488
|
+
raise CommError(
|
|
489
|
+
f"MIT 流一帧都没发出去({failed} 次尝试全部失败,最后一次:"
|
|
490
|
+
f"{last_error!r})。整段 {duration_s:.3f} s 一帧都没下发 —— "
|
|
491
|
+
"报成功就是谎报运动。")
|
|
492
|
+
return failed == 0
|
|
493
|
+
|
|
494
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
495
|
+
# Status
|
|
496
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
497
|
+
|
|
498
|
+
@property
|
|
499
|
+
def last_send_error(self) -> Optional[Exception]:
|
|
500
|
+
"""Why the last :meth:`control_mit` / :meth:`control_mit_stream` did not
|
|
501
|
+
send, or ``None`` if it sent.
|
|
502
|
+
|
|
503
|
+
The ``False`` return carries no reason, and the warning that does is
|
|
504
|
+
only as visible as the caller's logging setup. This keeps the exception
|
|
505
|
+
itself — type and all — so a layer above can report *why*, and can tell
|
|
506
|
+
"not registered" from "the write failed" without guessing.
|
|
507
|
+
"""
|
|
508
|
+
return self._last_send_error
|
|
509
|
+
|
|
510
|
+
@property
|
|
511
|
+
def last_update(self) -> Optional[float]:
|
|
512
|
+
"""Monotonic time of the newest status frame, or ``None`` if none ever
|
|
513
|
+
arrived since the motor was registered.
|
|
514
|
+
|
|
515
|
+
The readings :meth:`get_position` and friends return are a cache, and
|
|
516
|
+
the cache is only as good as this stamp. Subtract from
|
|
517
|
+
``time.monotonic()`` for an age; do not treat the value as a wall-clock
|
|
518
|
+
time (see :attr:`last_frame_time` for that one).
|
|
519
|
+
"""
|
|
520
|
+
return self._motor_stamp("last_update")
|
|
521
|
+
|
|
522
|
+
@property
|
|
523
|
+
def last_frame_time(self) -> Optional[float]:
|
|
524
|
+
"""Wall-clock time of the newest status frame, or ``None``.
|
|
525
|
+
|
|
526
|
+
The stamp to put on a snapshot a human reads.
|
|
527
|
+
"""
|
|
528
|
+
return self._motor_stamp("last_frame_time")
|
|
529
|
+
|
|
530
|
+
def _motor_stamp(self, name: str) -> Optional[float]:
|
|
531
|
+
"""Read one of the motor's frame stamps, mapping "never" to ``None``.
|
|
532
|
+
|
|
533
|
+
``0.0`` is what an un-updated :class:`MotorState` holds, and it is not a
|
|
534
|
+
time — a caller comparing it against a clock would get a nonsense age
|
|
535
|
+
rather than "no frame yet".
|
|
536
|
+
"""
|
|
537
|
+
motor = self._motor
|
|
538
|
+
if motor is None:
|
|
539
|
+
return None
|
|
540
|
+
stamp = getattr(motor, name, 0.0)
|
|
541
|
+
return stamp if stamp else None
|
|
542
|
+
|
|
543
|
+
def update_state(self, timeout_s: float = 0.05) -> bool:
|
|
544
|
+
"""Poll until at least one new status frame arrives."""
|
|
545
|
+
if self._controller is None or self._motor is None:
|
|
546
|
+
return False
|
|
547
|
+
return self._controller.poll_until(self._motor, timeout_s=timeout_s)
|
|
548
|
+
|
|
549
|
+
def refresh_status(self) -> bool:
|
|
550
|
+
"""Ask the motor for a status frame (0xCC).
|
|
551
|
+
|
|
552
|
+
A DM motor answers a control frame with a status frame, so a gripper
|
|
553
|
+
that is being driven reports on its own. One that is idle — holding a
|
|
554
|
+
pose, sitting disabled — sends nothing, and then
|
|
555
|
+
:meth:`update_state` has nothing to hear and the readings stay cached
|
|
556
|
+
however long the caller waits. This is the request that gives it
|
|
557
|
+
something to answer, and it does not change the motor's output.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
True if the request went out, False when nothing is registered or
|
|
561
|
+
the write failed (the same ``False``-with-a-warning shape as
|
|
562
|
+
:meth:`enable` — this is a request, not a motion command).
|
|
563
|
+
"""
|
|
564
|
+
if self._controller is None or self._motor is None:
|
|
565
|
+
return False
|
|
566
|
+
try:
|
|
567
|
+
self._controller.refresh_status(self._motor)
|
|
568
|
+
return True
|
|
569
|
+
except Exception as e:
|
|
570
|
+
log.warning("状态帧请求下发失败(%s):%r", self._channel, e)
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
def get_position(self) -> float:
|
|
574
|
+
"""Current position in rad."""
|
|
575
|
+
if self._motor is None:
|
|
576
|
+
return 0.0
|
|
577
|
+
return self._motor.position
|
|
578
|
+
|
|
579
|
+
def get_velocity(self) -> float:
|
|
580
|
+
"""Current velocity in rad/s."""
|
|
581
|
+
if self._motor is None:
|
|
582
|
+
return 0.0
|
|
583
|
+
return self._motor.velocity
|
|
584
|
+
|
|
585
|
+
def get_torque(self) -> float:
|
|
586
|
+
"""Current torque in Nm."""
|
|
587
|
+
if self._motor is None:
|
|
588
|
+
return 0.0
|
|
589
|
+
return self._motor.torque
|
|
590
|
+
|
|
591
|
+
def get_error(self) -> int:
|
|
592
|
+
"""Error code (0=disabled, 1=enabled, 0x9=UV, etc.)."""
|
|
593
|
+
if self._motor is None:
|
|
594
|
+
return -1
|
|
595
|
+
return self._motor.error
|
|
596
|
+
|
|
597
|
+
def get_temperature(self) -> tuple[int, int]:
|
|
598
|
+
"""MOS and coil temperatures in °C."""
|
|
599
|
+
if self._motor is None:
|
|
600
|
+
return 0, 0
|
|
601
|
+
return self._motor.t_mos, self._motor.t_coil
|
|
602
|
+
|
|
603
|
+
def read_param(self, rid: int, timeout_s: float = 0.5) -> float:
|
|
604
|
+
"""Read a motor register by RID."""
|
|
605
|
+
if self._controller is None or self._motor is None:
|
|
606
|
+
raise NotInitializedError("未连接或未注册")
|
|
607
|
+
return self._controller.read_param(self._motor, DM_REG(rid),
|
|
608
|
+
timeout_s=timeout_s)
|
|
609
|
+
|
|
610
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
611
|
+
# Properties
|
|
612
|
+
# ═══════════════════════════════════════════════════════════════════
|
|
613
|
+
|
|
614
|
+
@property
|
|
615
|
+
def is_initialized(self) -> bool:
|
|
616
|
+
return self._initialized
|
|
617
|
+
|
|
618
|
+
@property
|
|
619
|
+
def motor(self) -> Optional[MotorState]:
|
|
620
|
+
"""Direct access to the underlying MotorState (expert use)."""
|
|
621
|
+
return self._motor
|
litegrip/py.typed
ADDED
|
File without changes
|