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
litegrip/can/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""LiteGrip CAN subpackage — self-contained DM motor protocol over SocketCAN.
|
|
2
|
+
|
|
3
|
+
No dependency on damiao_socketcan or any arm-specific library.
|
|
4
|
+
Uses only Python stdlib + socket (Linux SocketCAN).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .transport import CanTransport, CanMode, CanFrame
|
|
8
|
+
from .protocol import (
|
|
9
|
+
pack_mit_frame,
|
|
10
|
+
unpack_status_frame,
|
|
11
|
+
pack_command_frame,
|
|
12
|
+
pack_refresh_frame,
|
|
13
|
+
pack_read_param_frame,
|
|
14
|
+
pack_write_param_frame,
|
|
15
|
+
pack_save_param_frame,
|
|
16
|
+
unpack_param_response,
|
|
17
|
+
float_to_uint,
|
|
18
|
+
uint_to_float,
|
|
19
|
+
)
|
|
20
|
+
from .motor import MotorState, MotorType, MotorParams
|
|
21
|
+
from .controller import MotorController
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"CanTransport",
|
|
25
|
+
"CanMode",
|
|
26
|
+
"CanFrame",
|
|
27
|
+
"pack_mit_frame",
|
|
28
|
+
"unpack_status_frame",
|
|
29
|
+
"pack_command_frame",
|
|
30
|
+
"pack_refresh_frame",
|
|
31
|
+
"pack_read_param_frame",
|
|
32
|
+
"pack_write_param_frame",
|
|
33
|
+
"pack_save_param_frame",
|
|
34
|
+
"unpack_param_response",
|
|
35
|
+
"float_to_uint",
|
|
36
|
+
"uint_to_float",
|
|
37
|
+
"MotorState",
|
|
38
|
+
"MotorType",
|
|
39
|
+
"MotorParams",
|
|
40
|
+
"MotorController",
|
|
41
|
+
]
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Motor controller — manages one or more DM motors over a CAN transport.
|
|
2
|
+
|
|
3
|
+
Replaces the damiao_socketcan Motor_Control class with a self-contained
|
|
4
|
+
implementation that has zero dependencies outside this package.
|
|
5
|
+
|
|
6
|
+
Supports:
|
|
7
|
+
- Motor registration / deregistration
|
|
8
|
+
- MIT control frame streaming
|
|
9
|
+
- Command dispatch (enable / disable / clear fault / set zero)
|
|
10
|
+
- Parameter read / write / save
|
|
11
|
+
- Status polling with automatic state decode
|
|
12
|
+
- Control mode switching (MIT ↔ POS_VEL ↔ VEL ↔ POS_FORCE)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import time
|
|
19
|
+
from typing import Dict, Optional
|
|
20
|
+
|
|
21
|
+
from ..exceptions import CANTimeoutError
|
|
22
|
+
from .transport import CanTransport
|
|
23
|
+
from .protocol import (
|
|
24
|
+
BROADCAST_ID,
|
|
25
|
+
ControlMode,
|
|
26
|
+
ControlModeCode,
|
|
27
|
+
DM_REG,
|
|
28
|
+
pack_mit_frame,
|
|
29
|
+
pack_command_frame,
|
|
30
|
+
pack_refresh_frame,
|
|
31
|
+
pack_read_param_frame,
|
|
32
|
+
pack_write_param_frame,
|
|
33
|
+
pack_save_param_frame,
|
|
34
|
+
unpack_status_frame,
|
|
35
|
+
unpack_param_response,
|
|
36
|
+
CMD_ENABLE,
|
|
37
|
+
CMD_DISABLE,
|
|
38
|
+
CMD_CLEAR_FAULT,
|
|
39
|
+
CMD_SET_ZERO,
|
|
40
|
+
)
|
|
41
|
+
from .motor import MotorState, MotorParams, MotorType
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger("litegrip.can.controller")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MotorController:
|
|
47
|
+
"""Manages DM motors over a CAN transport.
|
|
48
|
+
|
|
49
|
+
Typical usage for a single gripper motor::
|
|
50
|
+
|
|
51
|
+
transport = CanTransport("can0")
|
|
52
|
+
transport.open()
|
|
53
|
+
|
|
54
|
+
controller = MotorController(transport)
|
|
55
|
+
motor = controller.add_motor(can_id=0x08)
|
|
56
|
+
|
|
57
|
+
controller.enable(motor)
|
|
58
|
+
controller.control_mit(motor, q=0.5, kp=100, kd=2)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, transport: CanTransport):
|
|
62
|
+
self._transport = transport
|
|
63
|
+
self._motors: Dict[int, MotorState] = {} # keyed by mst_id
|
|
64
|
+
|
|
65
|
+
# ── motor management ────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def add_motor(
|
|
68
|
+
self,
|
|
69
|
+
can_id: int,
|
|
70
|
+
mst_id: Optional[int] = None,
|
|
71
|
+
motor_type: MotorType = MotorType.DM4310,
|
|
72
|
+
control_mode: ControlMode = ControlMode.MIT_MODE,
|
|
73
|
+
) -> MotorState:
|
|
74
|
+
"""Register a motor. If mst_id is None, auto-detect via register read.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The MotorState instance (also accessible via get_motor()).
|
|
78
|
+
"""
|
|
79
|
+
if mst_id is None:
|
|
80
|
+
mst_id = self._detect_mst_id(can_id)
|
|
81
|
+
|
|
82
|
+
params = MotorParams(
|
|
83
|
+
motor_type=motor_type,
|
|
84
|
+
can_id=can_id,
|
|
85
|
+
mst_id=mst_id,
|
|
86
|
+
control_mode=control_mode,
|
|
87
|
+
)
|
|
88
|
+
motor = MotorState(params)
|
|
89
|
+
self._motors[mst_id] = motor
|
|
90
|
+
log.info("Motor registered: can_id=0x%02X mst_id=0x%02X type=%s",
|
|
91
|
+
can_id, mst_id, motor_type.name)
|
|
92
|
+
|
|
93
|
+
# Install a hardware RX filter for all registered motors' mst_ids.
|
|
94
|
+
# Done AFTER mst_id detection (detection needs unfiltered RX). On a
|
|
95
|
+
# shared bus this stops foreign frames from flooding the RX buffer and
|
|
96
|
+
# starving this motor's status frames — the root cause of "state
|
|
97
|
+
# read-back freezes while the motor keeps moving". Param responses for
|
|
98
|
+
# our motor also arrive on its mst_id, so read_param still works.
|
|
99
|
+
try:
|
|
100
|
+
self._transport.set_id_filter(self._motors.keys())
|
|
101
|
+
except Exception as e: # never let filtering break registration
|
|
102
|
+
log.warning("Could not set CAN RX filter: %s", e)
|
|
103
|
+
|
|
104
|
+
return motor
|
|
105
|
+
|
|
106
|
+
def remove_motor(self, motor: MotorState) -> None:
|
|
107
|
+
"""Deregister a motor."""
|
|
108
|
+
self._motors.pop(motor.mst_id, None)
|
|
109
|
+
try:
|
|
110
|
+
self._transport.set_id_filter(self._motors.keys())
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
def get_motor(self, mst_id: int) -> Optional[MotorState]:
|
|
115
|
+
"""Get a motor by its mst_id."""
|
|
116
|
+
return self._motors.get(mst_id)
|
|
117
|
+
|
|
118
|
+
def get_motor_by_can_id(self, can_id: int) -> Optional[MotorState]:
|
|
119
|
+
"""Get the first motor with the given can_id."""
|
|
120
|
+
for m in self._motors.values():
|
|
121
|
+
if m.can_id == can_id:
|
|
122
|
+
return m
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def motors(self) -> Dict[int, MotorState]:
|
|
127
|
+
return dict(self._motors)
|
|
128
|
+
|
|
129
|
+
# ── command dispatch ────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
def send_command(self, motor: MotorState, cmd: int, count: int = 5,
|
|
132
|
+
interval_s: float = 0.002) -> None:
|
|
133
|
+
"""Send a command (enable/disable/clear fault/set zero) to a motor.
|
|
134
|
+
|
|
135
|
+
Commands are sent multiple times for reliability (per DM protocol).
|
|
136
|
+
"""
|
|
137
|
+
can_id = motor.can_id + motor.mode_offset()
|
|
138
|
+
data = pack_command_frame(cmd)
|
|
139
|
+
self._transport.send_multi(can_id, data, count, interval_s)
|
|
140
|
+
|
|
141
|
+
def enable(self, motor: MotorState) -> None:
|
|
142
|
+
"""Send enable command (0xFC)."""
|
|
143
|
+
self.send_command(motor, CMD_ENABLE)
|
|
144
|
+
|
|
145
|
+
def disable(self, motor: MotorState) -> None:
|
|
146
|
+
"""Send disable command (0xFD)."""
|
|
147
|
+
self.send_command(motor, CMD_DISABLE)
|
|
148
|
+
|
|
149
|
+
def clear_fault(self, motor: MotorState) -> None:
|
|
150
|
+
"""Send clear-fault command (0xFB)."""
|
|
151
|
+
self.send_command(motor, CMD_CLEAR_FAULT)
|
|
152
|
+
|
|
153
|
+
def set_zero(self, motor: MotorState) -> None:
|
|
154
|
+
"""Set current position as zero (0xFE)."""
|
|
155
|
+
self.send_command(motor, CMD_SET_ZERO)
|
|
156
|
+
|
|
157
|
+
def refresh_status(self, motor: MotorState) -> None:
|
|
158
|
+
"""Request a status frame refresh (0xCC). Does not change motor output."""
|
|
159
|
+
data = pack_refresh_frame(motor.can_id)
|
|
160
|
+
self._transport.send(BROADCAST_ID, data)
|
|
161
|
+
|
|
162
|
+
# ── control modes ───────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def switch_control_mode(self, motor: MotorState,
|
|
165
|
+
mode_code: ControlModeCode) -> bool:
|
|
166
|
+
"""Switch motor control mode (MIT ↔ POS_VEL ↔ VEL ↔ POS_FORCE).
|
|
167
|
+
|
|
168
|
+
Writes the CTRL_MODE register and verifies the change.
|
|
169
|
+
"""
|
|
170
|
+
self.write_param(motor, DM_REG.CTRL_MODE, int(mode_code))
|
|
171
|
+
time.sleep(0.02)
|
|
172
|
+
# Verify
|
|
173
|
+
try:
|
|
174
|
+
actual = int(self.read_param(motor, DM_REG.CTRL_MODE))
|
|
175
|
+
if actual == int(mode_code):
|
|
176
|
+
# Map code → mode offset
|
|
177
|
+
mode_map = {
|
|
178
|
+
ControlModeCode.MIT: ControlMode.MIT_MODE,
|
|
179
|
+
ControlModeCode.POS_VEL: ControlMode.POS_VEL_MODE,
|
|
180
|
+
ControlModeCode.VEL: ControlMode.VEL_MODE,
|
|
181
|
+
ControlModeCode.POS_FORCE: ControlMode.POS_FORCE_MODE,
|
|
182
|
+
}
|
|
183
|
+
motor.set_mode(mode_map.get(mode_code, ControlMode.MIT_MODE))
|
|
184
|
+
return True
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# ── MIT control ─────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def control_mit(self, motor: MotorState, kp: float, kd: float,
|
|
192
|
+
q: float, dq: float = 0.0, tau: float = 0.0) -> None:
|
|
193
|
+
"""Send a single MIT control frame.
|
|
194
|
+
|
|
195
|
+
Call in a loop at ≥200 Hz for smooth motion.
|
|
196
|
+
"""
|
|
197
|
+
limits = motor.limits
|
|
198
|
+
data = pack_mit_frame(q, dq, kp, kd, tau,
|
|
199
|
+
q_max=limits.q_max,
|
|
200
|
+
dq_max=limits.dq_max,
|
|
201
|
+
tau_max=limits.tau_max)
|
|
202
|
+
can_id = motor.can_id + motor.mode_offset()
|
|
203
|
+
self._transport.send(can_id, data)
|
|
204
|
+
|
|
205
|
+
# ── parameter access ────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
def read_param(self, motor: MotorState, rid: DM_REG,
|
|
208
|
+
timeout_s: float = 0.5) -> float:
|
|
209
|
+
"""Read a motor register. Returns the decoded value (float).
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
CANTimeoutError: if no response within timeout.
|
|
213
|
+
"""
|
|
214
|
+
data = pack_read_param_frame(motor.can_id, rid)
|
|
215
|
+
self._transport.send(BROADCAST_ID, data)
|
|
216
|
+
|
|
217
|
+
deadline = time.monotonic() + timeout_s
|
|
218
|
+
while time.monotonic() < deadline:
|
|
219
|
+
frame = self._transport.recv(timeout_s=0.01)
|
|
220
|
+
if frame is None:
|
|
221
|
+
continue
|
|
222
|
+
# Check if frame is from our motor
|
|
223
|
+
resp = unpack_param_response(frame.data)
|
|
224
|
+
if resp is not None and resp.can_id == (motor.can_id & 0x0F):
|
|
225
|
+
if resp.opcode in (0x33, 0x55) and resp.rid == rid:
|
|
226
|
+
return resp.value
|
|
227
|
+
raise CANTimeoutError(
|
|
228
|
+
f"read_param timeout: can_id=0x{motor.can_id:02X} rid={rid}")
|
|
229
|
+
|
|
230
|
+
def write_param(self, motor: MotorState, rid: DM_REG,
|
|
231
|
+
value) -> None:
|
|
232
|
+
"""Write a motor register. Does not wait for confirmation."""
|
|
233
|
+
data = pack_write_param_frame(motor.can_id, rid, value)
|
|
234
|
+
self._transport.send(BROADCAST_ID, data)
|
|
235
|
+
|
|
236
|
+
def save_params(self, motor: MotorState) -> None:
|
|
237
|
+
"""Save all parameters to flash. Motor must be disabled first."""
|
|
238
|
+
data = pack_save_param_frame(motor.can_id)
|
|
239
|
+
self._transport.send(BROADCAST_ID, data)
|
|
240
|
+
|
|
241
|
+
# ── polling ──────────────────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
def poll(self, timeout_s: float = 0.0) -> Optional[MotorState]:
|
|
244
|
+
"""Poll for one CAN frame. If it's a status frame for a registered
|
|
245
|
+
motor, decode it and update that MotorState.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
The MotorState that was updated, or None if no relevant frame arrived.
|
|
249
|
+
"""
|
|
250
|
+
frame = self._transport.recv(timeout_s=timeout_s)
|
|
251
|
+
if frame is None:
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
# Match frame.can_id against registered motors' mst_id
|
|
255
|
+
motor = self._motors.get(frame.can_id)
|
|
256
|
+
if motor is None:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
if len(frame.data) < 8:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
# Guard: skip param response frames (0x33/0x55/0xAA) — they share the
|
|
263
|
+
# motor's mst_id but have a different byte layout and would corrupt
|
|
264
|
+
# motor state if parsed as status.
|
|
265
|
+
#
|
|
266
|
+
# A status frame's data[2] is the velocity low byte, which can collide
|
|
267
|
+
# with an opcode value (e.g. 0x55) — so data[2] alone is NOT a reliable
|
|
268
|
+
# discriminator. A param response is structurally distinct:
|
|
269
|
+
# data[0] = can_id (high nibble 0) status: (err<<4)|can_id
|
|
270
|
+
# data[1] = can_id high byte status: position high byte
|
|
271
|
+
# data[2] = opcode status: velocity low byte
|
|
272
|
+
# Require all three to match before discarding, so a normal (enabled,
|
|
273
|
+
# err>=1) status frame is never dropped regardless of its position bytes.
|
|
274
|
+
opcode = frame.data[2] if len(frame.data) > 2 else 0
|
|
275
|
+
if (opcode in (0x33, 0x55, 0xAA)
|
|
276
|
+
and (frame.data[0] >> 4) == 0
|
|
277
|
+
and frame.data[0] == (motor.can_id & 0xFF)
|
|
278
|
+
and frame.data[1] == ((motor.can_id >> 8) & 0xFF)):
|
|
279
|
+
return None
|
|
280
|
+
|
|
281
|
+
# Decode as a status frame
|
|
282
|
+
try:
|
|
283
|
+
limits = motor.limits
|
|
284
|
+
status = unpack_status_frame(
|
|
285
|
+
frame.data,
|
|
286
|
+
q_max=limits.q_max,
|
|
287
|
+
dq_max=limits.dq_max,
|
|
288
|
+
tau_max=limits.tau_max,
|
|
289
|
+
)
|
|
290
|
+
motor.update_from_status(
|
|
291
|
+
position=status.q,
|
|
292
|
+
velocity=status.dq,
|
|
293
|
+
torque=status.tau,
|
|
294
|
+
error=status.err,
|
|
295
|
+
t_mos=status.t_mos,
|
|
296
|
+
t_coil=status.t_coil,
|
|
297
|
+
timestamp=frame.timestamp,
|
|
298
|
+
)
|
|
299
|
+
return motor
|
|
300
|
+
except ValueError:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
def poll_until(self, motor: MotorState,
|
|
306
|
+
timeout_s: float = 1.0) -> bool:
|
|
307
|
+
"""Poll until at least one new status frame arrives for the given motor.
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
True if a frame was received, False on timeout.
|
|
311
|
+
"""
|
|
312
|
+
prev_rx = motor.rx_count
|
|
313
|
+
deadline = time.monotonic() + timeout_s
|
|
314
|
+
while time.monotonic() < deadline:
|
|
315
|
+
updated = self.poll(timeout_s=0.01)
|
|
316
|
+
if updated is motor and motor.rx_count > prev_rx:
|
|
317
|
+
return True
|
|
318
|
+
time.sleep(0.001)
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
# ── internal ────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
# Default MST_ID fallback — LiteGrip ships with mst_id=0x18 (24).
|
|
324
|
+
_DEFAULT_MST_ID = 0x18
|
|
325
|
+
|
|
326
|
+
def _detect_mst_id(self, can_id: int,
|
|
327
|
+
timeout_s: float = 0.5) -> int:
|
|
328
|
+
"""Auto-detect motor MST_ID.
|
|
329
|
+
|
|
330
|
+
Strategy 1: Send read-param for MST_ID register (RID=7) to broadcast.
|
|
331
|
+
The motor responds on its real mst_id; we listen for ALL frames and
|
|
332
|
+
match by the can_id embedded in the parameter response.
|
|
333
|
+
|
|
334
|
+
Strategy 2: Send a status-refresh command (0xCC) and listen for the
|
|
335
|
+
status frame — its arrival CAN ID *is* the mst_id.
|
|
336
|
+
|
|
337
|
+
Falls back to a hardcoded default if neither strategy succeeds.
|
|
338
|
+
|
|
339
|
+
Note: detection must see frames on IDs not yet known, so any active
|
|
340
|
+
RX id-filter is cleared for the duration and restored afterward.
|
|
341
|
+
"""
|
|
342
|
+
can_id_low = can_id & 0x0F
|
|
343
|
+
|
|
344
|
+
# Clear any active RX filter so detection can see frames on the
|
|
345
|
+
# not-yet-known mst_id; add_motor() reinstalls the filter afterward.
|
|
346
|
+
try:
|
|
347
|
+
self._transport.set_id_filter(None)
|
|
348
|
+
except Exception:
|
|
349
|
+
pass
|
|
350
|
+
|
|
351
|
+
# Strategy 1: Read MST_ID register
|
|
352
|
+
try:
|
|
353
|
+
self._transport.drain()
|
|
354
|
+
self._transport.send(BROADCAST_ID,
|
|
355
|
+
pack_read_param_frame(can_id, DM_REG.MST_ID))
|
|
356
|
+
|
|
357
|
+
deadline = time.monotonic() + timeout_s
|
|
358
|
+
while time.monotonic() < deadline:
|
|
359
|
+
frame = self._transport.recv(timeout_s=0.01)
|
|
360
|
+
if frame is None:
|
|
361
|
+
continue
|
|
362
|
+
resp = unpack_param_response(frame.data)
|
|
363
|
+
if resp is not None and resp.rid == DM_REG.MST_ID:
|
|
364
|
+
if resp.can_id == can_id_low:
|
|
365
|
+
mst = int(resp.value)
|
|
366
|
+
if 1 <= mst <= 0xFE:
|
|
367
|
+
log.info("Detected MST_ID=0x%02X via register read", mst)
|
|
368
|
+
return mst
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
# Strategy 2: Send status-refresh; catch the status frame.
|
|
373
|
+
# The status frame arrives on the motor's mst_id → frame.can_id = mst_id.
|
|
374
|
+
log.info("Register read failed; trying status refresh...")
|
|
375
|
+
try:
|
|
376
|
+
self._transport.drain()
|
|
377
|
+
self._transport.send(BROADCAST_ID, pack_refresh_frame(can_id))
|
|
378
|
+
|
|
379
|
+
deadline = time.monotonic() + timeout_s
|
|
380
|
+
while time.monotonic() < deadline:
|
|
381
|
+
frame = self._transport.recv(timeout_s=0.01)
|
|
382
|
+
if frame is None:
|
|
383
|
+
continue
|
|
384
|
+
if len(frame.data) < 8:
|
|
385
|
+
continue
|
|
386
|
+
# Status frame: data[0] low nibble = can_id
|
|
387
|
+
if (frame.data[0] & 0x0F) == can_id_low:
|
|
388
|
+
mst = frame.can_id & 0x7FF
|
|
389
|
+
if 1 <= mst <= 0xFE:
|
|
390
|
+
log.info("Detected MST_ID=0x%02X via status refresh", mst)
|
|
391
|
+
return mst
|
|
392
|
+
except Exception:
|
|
393
|
+
pass
|
|
394
|
+
|
|
395
|
+
log.warning(
|
|
396
|
+
"Could not auto-detect MST_ID for can_id=0x%02X; "
|
|
397
|
+
"falling back to default 0x%02X. "
|
|
398
|
+
"Use --mst-id to override if this is wrong.",
|
|
399
|
+
can_id, self._DEFAULT_MST_ID,
|
|
400
|
+
)
|
|
401
|
+
return self._DEFAULT_MST_ID
|
|
402
|
+
|
|
403
|
+
# ── teardown ────────────────────────────────────────────────────────
|
|
404
|
+
|
|
405
|
+
def close(self) -> None:
|
|
406
|
+
"""Close the underlying transport."""
|
|
407
|
+
self._transport.close()
|
|
408
|
+
|
|
409
|
+
def __enter__(self) -> "MotorController":
|
|
410
|
+
return self
|
|
411
|
+
|
|
412
|
+
def __exit__(self, *args) -> None:
|
|
413
|
+
self.close()
|
litegrip/can/motor.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Per-motor state container — tracks decoded status from CAN frames.
|
|
2
|
+
|
|
3
|
+
Each motor instance holds the latest position, velocity, torque, error code,
|
|
4
|
+
and temperature values. Updated by the MotorController when status frames
|
|
5
|
+
arrive on the motor's mst_id.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from enum import IntEnum
|
|
13
|
+
|
|
14
|
+
from .protocol import ControlMode, MotorLimits, get_motor_limits
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MotorType(IntEnum):
|
|
18
|
+
"""Damiao motor model indices (matching DM_Motor_Type)."""
|
|
19
|
+
DM3507 = 0
|
|
20
|
+
DM4310 = 1
|
|
21
|
+
DM4310_48V = 2
|
|
22
|
+
DM4340 = 3
|
|
23
|
+
DM4340_48V = 4
|
|
24
|
+
DM6006 = 5
|
|
25
|
+
DM6248P = 6
|
|
26
|
+
DM8006 = 7
|
|
27
|
+
DM8009 = 8
|
|
28
|
+
DM10010L = 9
|
|
29
|
+
DM10010 = 10
|
|
30
|
+
DMH3510 = 11
|
|
31
|
+
DMH6215 = 12
|
|
32
|
+
DMS3519 = 13
|
|
33
|
+
DMG6220 = 14
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MotorParams:
|
|
38
|
+
"""Configuration parameters for a single motor."""
|
|
39
|
+
motor_type: MotorType = MotorType.DM4310
|
|
40
|
+
can_id: int = 0x08
|
|
41
|
+
mst_id: int = 0x18 # Master ID (status frame CAN ID)
|
|
42
|
+
control_mode: ControlMode = ControlMode.MIT_MODE
|
|
43
|
+
limits: MotorLimits = field(default=None)
|
|
44
|
+
|
|
45
|
+
def __post_init__(self):
|
|
46
|
+
if self.limits is None:
|
|
47
|
+
self.limits = get_motor_limits(int(self.motor_type))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MotorState:
|
|
51
|
+
"""Runtime state of a single DM motor, updated from status frames.
|
|
52
|
+
|
|
53
|
+
Thread-unsafe — caller must serialize updates.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
__slots__ = (
|
|
57
|
+
"_params",
|
|
58
|
+
"_position",
|
|
59
|
+
"_velocity",
|
|
60
|
+
"_torque",
|
|
61
|
+
"_error",
|
|
62
|
+
"_t_mos",
|
|
63
|
+
"_t_coil",
|
|
64
|
+
"rx_count",
|
|
65
|
+
"_last_update",
|
|
66
|
+
"_last_frame_time",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def __init__(self, params: MotorParams):
|
|
70
|
+
self._params = params
|
|
71
|
+
self._position: float = 0.0
|
|
72
|
+
self._velocity: float = 0.0
|
|
73
|
+
self._torque: float = 0.0
|
|
74
|
+
self._error: int = 0
|
|
75
|
+
self._t_mos: int = 0
|
|
76
|
+
self._t_coil: int = 0
|
|
77
|
+
self.rx_count: int = 0
|
|
78
|
+
self._last_update: float = 0.0
|
|
79
|
+
self._last_frame_time: float = 0.0
|
|
80
|
+
|
|
81
|
+
# ── properties ──────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def can_id(self) -> int:
|
|
85
|
+
return self._params.can_id
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def mst_id(self) -> int:
|
|
89
|
+
return self._params.mst_id
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def control_mode(self) -> ControlMode:
|
|
93
|
+
return self._params.control_mode
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def motor_type(self) -> MotorType:
|
|
97
|
+
return self._params.motor_type
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def limits(self) -> MotorLimits:
|
|
101
|
+
return self._params.limits
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def position(self) -> float:
|
|
105
|
+
return self._position
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def velocity(self) -> float:
|
|
109
|
+
return self._velocity
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def torque(self) -> float:
|
|
113
|
+
return self._torque
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def error(self) -> int:
|
|
117
|
+
return self._error
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def t_mos(self) -> int:
|
|
121
|
+
return self._t_mos
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def t_coil(self) -> int:
|
|
125
|
+
return self._t_coil
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def is_enabled(self) -> bool:
|
|
129
|
+
return self._error == 1
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def is_fault(self) -> bool:
|
|
133
|
+
return self._error not in (0, 1)
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def last_update(self) -> float:
|
|
137
|
+
"""Monotonic time of the newest status frame; ``0.0`` if none ever came.
|
|
138
|
+
|
|
139
|
+
Monotonic because that is the clock the receive path stamps frames with
|
|
140
|
+
(``CanFrame.timestamp``) — subtract from ``time.monotonic()`` for an age.
|
|
141
|
+
"""
|
|
142
|
+
return self._last_update
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def last_frame_time(self) -> float:
|
|
146
|
+
"""Wall-clock time of the newest status frame; ``0.0`` if none ever came.
|
|
147
|
+
|
|
148
|
+
The human-readable counterpart of :attr:`last_update`. The two clocks
|
|
149
|
+
are not interchangeable: this one cannot be subtracted from.
|
|
150
|
+
"""
|
|
151
|
+
return self._last_frame_time
|
|
152
|
+
|
|
153
|
+
# ── control mode management ─────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
def set_mode(self, mode: ControlMode) -> None:
|
|
156
|
+
"""Change the control mode (does not send CAN command)."""
|
|
157
|
+
self._params.control_mode = mode
|
|
158
|
+
|
|
159
|
+
def mode_offset(self) -> int:
|
|
160
|
+
"""Get the CAN ID offset for the current control mode."""
|
|
161
|
+
return int(self._params.control_mode)
|
|
162
|
+
|
|
163
|
+
# ── status update ───────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def update_from_status(self, position: float, velocity: float,
|
|
166
|
+
torque: float, error: int,
|
|
167
|
+
t_mos: int, t_coil: int,
|
|
168
|
+
timestamp: float = 0.0) -> None:
|
|
169
|
+
"""Called by MotorController when a status frame for this motor arrives."""
|
|
170
|
+
self._position = position
|
|
171
|
+
self._velocity = velocity
|
|
172
|
+
self._torque = torque
|
|
173
|
+
self._error = error
|
|
174
|
+
self._t_mos = t_mos
|
|
175
|
+
self._t_coil = t_coil
|
|
176
|
+
self._last_update = timestamp
|
|
177
|
+
# Same instant, other clock: ``timestamp`` is the monotonic stamp the
|
|
178
|
+
# transport put on the frame, and a snapshot that reports "when" to a
|
|
179
|
+
# human needs the wall clock. Sampling it here keeps the two within a
|
|
180
|
+
# few microseconds of the frame that produced them.
|
|
181
|
+
self._last_frame_time = time.time()
|
|
182
|
+
self.rx_count += 1
|
|
183
|
+
|
|
184
|
+
def __repr__(self) -> str:
|
|
185
|
+
return (f"MotorState(can_id=0x{self.can_id:02X}, mst=0x{self.mst_id:02X}, "
|
|
186
|
+
f"pos={self._position:.4f}, vel={self._velocity:.3f}, "
|
|
187
|
+
f"tau={self._torque:.3f}, err={self._error}, "
|
|
188
|
+
f"rx={self.rx_count})")
|