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.
@@ -0,0 +1,380 @@
1
+ """Damiao motor CAN protocol encoder/decoder — pure functions, no state.
2
+
3
+ Implements the complete DM motor protocol:
4
+ - MIT frame pack/unpack (q/dq/kp/kd/tau → 8 bytes)
5
+ - Command frames (enable/disable/clear fault/set zero)
6
+ - Parameter read/write/save frames (broadcast to 0x7FF)
7
+ - Status frame parsing
8
+ - Parameter response frame parsing
9
+
10
+ Reference: DM4310/DM4340/DM6248P CAN protocol specification.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import struct
17
+ from dataclasses import dataclass
18
+ from enum import IntEnum
19
+ from typing import Optional
20
+
21
+ # ── CAN ID constants ─────────────────────────────────────────────────────
22
+ BROADCAST_ID = 0x7FF
23
+
24
+
25
+ class ControlMode(IntEnum):
26
+ """CAN ID offsets for control frames."""
27
+ MIT_MODE = 0x000
28
+ POS_VEL_MODE = 0x100
29
+ VEL_MODE = 0x200
30
+ POS_FORCE_MODE = 0x300
31
+
32
+
33
+ class ControlModeCode(IntEnum):
34
+ """CTRL_MODE register values."""
35
+ MIT = 1
36
+ POS_VEL = 2
37
+ VEL = 3
38
+ POS_FORCE = 4
39
+
40
+
41
+ class DM_REG(IntEnum):
42
+ """Damiao motor register IDs."""
43
+ UV_Value = 0
44
+ KT_Value = 1
45
+ OT_Value = 2
46
+ OC_Value = 3
47
+ ACC = 4
48
+ DEC = 5
49
+ MAX_SPD = 6
50
+ MST_ID = 7
51
+ ESC_ID = 8
52
+ TIMEOUT = 9
53
+ CTRL_MODE = 10
54
+ Damp = 11
55
+ Inertia = 12
56
+ hw_ver = 13
57
+ sw_ver = 14
58
+ SN = 15
59
+ NPP = 16
60
+ Rs = 17
61
+ LS = 18
62
+ Flux = 19
63
+ Gr = 20
64
+ PMAX = 21
65
+ VMAX = 22
66
+ TMAX = 23
67
+ I_BW = 24
68
+ KP_ASR = 25
69
+ KI_ASR = 26
70
+ KP_APR = 27
71
+ KI_APR = 28
72
+ OV_Value = 29
73
+ GREF = 30
74
+ Deta = 31
75
+ V_BW = 32
76
+ IQ_c1 = 33
77
+ VL_c1 = 34
78
+ can_br = 35
79
+ sub_ver = 36
80
+
81
+
82
+ # ── Integer register ranges (big-endian decode on read response) ─────────
83
+ _INT_REG_RANGES = (
84
+ (7, 10),
85
+ (13, 16),
86
+ (35, 36),
87
+ )
88
+
89
+
90
+ def _is_int_register(rid: int) -> bool:
91
+ """Return True if the register ID stores an integer value."""
92
+ for lo, hi in _INT_REG_RANGES:
93
+ if lo <= rid <= hi:
94
+ return True
95
+ return False
96
+
97
+
98
+ # ── Quantization helpers ─────────────────────────────────────────────────
99
+
100
+ def float_to_uint(value: float, value_min: float, value_max: float,
101
+ bits: int) -> int:
102
+ """Quantize a float to unsigned integer of given bit width.
103
+
104
+ Out-of-range values are clamped — that is the documented saturation of
105
+ this codec. Non-finite ones are **rejected**: a NaN has no position on
106
+ the scale at all, and the clamp would silently hand it the top of the
107
+ range (``value_max``), which is a real command the caller never asked for.
108
+ On a motion frame that is ``q = +12.5 rad`` or ``tau = +tau_max``.
109
+
110
+ Raises:
111
+ ValueError: ``value`` is NaN or infinite.
112
+ """
113
+ if not math.isfinite(value):
114
+ raise ValueError(
115
+ f"cannot encode a non-finite value: value={value!r} "
116
+ f"(range [{value_min}, {value_max}], {bits} bits)")
117
+ value = max(value_min, min(value_max, value))
118
+ span = value_max - value_min
119
+ offset = value - value_min
120
+ return int(offset * ((1 << bits) - 1) / span)
121
+
122
+
123
+ def uint_to_float(value: int, value_min: float, value_max: float,
124
+ bits: int) -> float:
125
+ """Dequantize an unsigned integer back to float."""
126
+ span = value_max - value_min
127
+ return float(value) * span / float((1 << bits) - 1) + value_min
128
+
129
+
130
+ # ── MIT control frame ────────────────────────────────────────────────────
131
+
132
+ def pack_mit_frame(
133
+ q: float,
134
+ dq: float,
135
+ kp: float,
136
+ kd: float,
137
+ tau: float,
138
+ q_max: float = 12.5,
139
+ dq_max: float = 30.0,
140
+ tau_max: float = 10.0,
141
+ ) -> bytes:
142
+ """Pack position/torque targets into an 8-byte MIT control frame.
143
+
144
+ Args:
145
+ q: Target position (rad), mapped to [-q_max, +q_max] in 16 bits.
146
+ dq: Target velocity (rad/s), mapped to [-dq_max, +dq_max] in 12 bits.
147
+ kp: Position stiffness (0–500), mapped to [0, 500] in 12 bits.
148
+ kd: Velocity damping (0–5), mapped to [0, 5] in 12 bits.
149
+ tau: Feed-forward torque (Nm), mapped to [-tau_max, +tau_max] in 12 bits.
150
+
151
+ Returns:
152
+ 8-byte CAN payload.
153
+
154
+ Raises:
155
+ ValueError: any field is NaN or infinite. Nothing non-finite ever
156
+ becomes bytes: see :func:`float_to_uint`.
157
+ """
158
+ q_uint = float_to_uint(q, -q_max, q_max, 16)
159
+ dq_uint = float_to_uint(dq, -dq_max, dq_max, 12)
160
+ kp_uint = float_to_uint(kp, 0.0, 500.0, 12)
161
+ kd_uint = float_to_uint(kd, 0.0, 5.0, 12)
162
+ tau_uint = float_to_uint(tau, -tau_max, tau_max, 12)
163
+
164
+ # Bit packing per protocol spec
165
+ data = bytearray(8)
166
+ data[0] = (q_uint >> 8) & 0xFF
167
+ data[1] = q_uint & 0xFF
168
+ data[2] = (dq_uint >> 4) & 0xFF
169
+ data[3] = ((dq_uint & 0x0F) << 4) | ((kp_uint >> 8) & 0x0F)
170
+ data[4] = kp_uint & 0xFF
171
+ data[5] = (kd_uint >> 4) & 0xFF
172
+ data[6] = ((kd_uint & 0x0F) << 4) | ((tau_uint >> 8) & 0x0F)
173
+ data[7] = tau_uint & 0xFF
174
+
175
+ return bytes(data)
176
+
177
+
178
+ # ── Status frame (motor → PC) ────────────────────────────────────────────
179
+
180
+ @dataclass
181
+ class ParsedStatus:
182
+ """Parsed motor status frame."""
183
+ err: int # 4-bit error code (0=disabled, 1=enabled, 0x9=UV, etc.)
184
+ can_id: int # 4-bit CAN ID (low nibble of data[0])
185
+ q: float # Position (rad)
186
+ dq: float # Velocity (rad/s)
187
+ tau: float # Torque (Nm)
188
+ t_mos: int # MOS temperature (°C)
189
+ t_coil: int # Coil temperature (°C)
190
+
191
+
192
+ def unpack_status_frame(
193
+ data: bytes,
194
+ q_max: float = 12.5,
195
+ dq_max: float = 30.0,
196
+ tau_max: float = 10.0,
197
+ ) -> ParsedStatus:
198
+ """Parse an 8-byte motor status frame.
199
+
200
+ Byte layout::
201
+
202
+ [0] err(4) | can_id(4)
203
+ [1] q[15:8] [2] q[7:0]
204
+ [3] dq[11:4] [4] dq[3:0] | tau[11:8]
205
+ [5] tau[7:0]
206
+ [6] t_mos [7] t_coil
207
+ """
208
+ if len(data) < 8:
209
+ raise ValueError(f"Status frame needs 8 bytes, got {len(data)}")
210
+
211
+ err = (data[0] >> 4) & 0x0F
212
+ cid = data[0] & 0x0F
213
+
214
+ q_uint = ((data[1] << 8) | data[2]) & 0xFFFF
215
+ dq_uint = ((data[3] << 4) | (data[4] >> 4)) & 0xFFF
216
+ tau_uint = (((data[4] & 0x0F) << 8) | data[5]) & 0xFFF
217
+
218
+ q = uint_to_float(q_uint, -q_max, q_max, 16)
219
+ dq = uint_to_float(dq_uint, -dq_max, dq_max, 12)
220
+ tau = uint_to_float(tau_uint, -tau_max, tau_max, 12)
221
+
222
+ t_mos = data[6]
223
+ t_coil = data[7]
224
+
225
+ return ParsedStatus(err=err, can_id=cid, q=q, dq=dq, tau=tau,
226
+ t_mos=t_mos, t_coil=t_coil)
227
+
228
+
229
+ # ── Command frames (PC → motor) ──────────────────────────────────────────
230
+
231
+ # Command byte values (placed in byte 7)
232
+ CMD_ENABLE = 0xFC
233
+ CMD_DISABLE = 0xFD
234
+ CMD_CLEAR_FAULT = 0xFB
235
+ CMD_SET_ZERO = 0xFE
236
+
237
+
238
+ def pack_command_frame(cmd: int) -> bytes:
239
+ """Pack a command frame (enable/disable/clear fault/set zero).
240
+
241
+ All 8 bytes are 0xFF except byte 7 which holds the command.
242
+ """
243
+ data = bytearray([0xFF] * 7)
244
+ data.append(cmd)
245
+ return bytes(data)
246
+
247
+
248
+ # ── Status refresh frame ────────────────────────────────────────────────
249
+
250
+ def pack_refresh_frame(can_id: int) -> bytes:
251
+ """Pack a status refresh request (sent to broadcast ID 0x7FF).
252
+
253
+ Returns:
254
+ 4-byte frame: [can_id_lo, can_id_hi, 0xCC, 0x00]
255
+ """
256
+ return bytes([can_id & 0xFF, (can_id >> 8) & 0xFF, 0xCC, 0x00])
257
+
258
+
259
+ # ── Parameter read/write/save frames ────────────────────────────────────
260
+
261
+ def pack_read_param_frame(can_id: int, rid: int) -> bytes:
262
+ """Pack a parameter read request (sent to broadcast ID 0x7FF).
263
+
264
+ Returns:
265
+ 8-byte frame: [can_id_lo, can_id_hi, 0x33, RID, 0, 0, 0, 0]
266
+ """
267
+ return bytes([can_id & 0xFF, (can_id >> 8) & 0xFF, 0x33, rid & 0xFF,
268
+ 0, 0, 0, 0])
269
+
270
+
271
+ def pack_write_param_frame(can_id: int, rid: int, value) -> bytes:
272
+ """Pack a parameter write request (sent to broadcast ID 0x7FF).
273
+
274
+ Args:
275
+ can_id: Motor CAN ID.
276
+ rid: Register ID.
277
+ value: int or float — int registers packed as little-endian uint32,
278
+ float registers packed as IEEE 754 little-endian float32.
279
+
280
+ Returns:
281
+ 8-byte frame: [can_id_lo, can_id_hi, 0x55, RID, data[0..3]]
282
+ """
283
+ if _is_int_register(rid):
284
+ data_bytes = int(value).to_bytes(4, "little", signed=False)
285
+ else:
286
+ data_bytes = struct.pack("<f", float(value))
287
+
288
+ return bytes([can_id & 0xFF, (can_id >> 8) & 0xFF, 0x55, rid & 0xFF,
289
+ data_bytes[0], data_bytes[1], data_bytes[2], data_bytes[3]])
290
+
291
+
292
+ def pack_save_param_frame(can_id: int) -> bytes:
293
+ """Pack a parameter save-to-flash request (sent to broadcast ID 0x7FF).
294
+
295
+ Note: Motor must be disabled before saving.
296
+
297
+ Returns:
298
+ 8-byte frame: [can_id_lo, can_id_hi, 0xAA, 0x01, 0, 0, 0, 0]
299
+ """
300
+ return bytes([can_id & 0xFF, (can_id >> 8) & 0xFF, 0xAA, 0x01,
301
+ 0, 0, 0, 0])
302
+
303
+
304
+ # ── Parameter response frame (motor → PC) ───────────────────────────────
305
+
306
+ @dataclass
307
+ class ParsedParamResponse:
308
+ """Parsed parameter read/write response."""
309
+ can_id: int # Motor CAN ID (from data[0] & 0x0F)
310
+ opcode: int # 0x33 = read response, 0x55 = write response, 0xAA = save response
311
+ rid: int # Register ID
312
+ value: float # Decoded value (float for float regs, int cast to float for int regs)
313
+
314
+
315
+ def unpack_param_response(data: bytes) -> Optional[ParsedParamResponse]:
316
+ """Parse a parameter response frame.
317
+
318
+ Byte layout::
319
+
320
+ [0] motor_id (low nibble = can_id)
321
+ [1] can_id_hi
322
+ [2] opcode (0x33=read, 0x55=write, 0xAA=save)
323
+ [3] RID
324
+ [4..7] data (4 bytes)
325
+
326
+ Integer registers: little-endian uint32 decode (matching damiao firmware
327
+ convention on the wire).
328
+ Float registers: little-endian IEEE 754 float32.
329
+ """
330
+ if len(data) < 8:
331
+ return None
332
+
333
+ can_id = data[0] & 0x0F
334
+ opcode = data[2]
335
+ rid = data[3]
336
+
337
+ if opcode not in (0x33, 0x55, 0xAA):
338
+ return None
339
+
340
+ if _is_int_register(rid):
341
+ value = float((data[7] << 24) | (data[6] << 16) |
342
+ (data[5] << 8) | data[4])
343
+ else:
344
+ try:
345
+ value = struct.unpack("<f", bytes(data[4:8]))[0]
346
+ except struct.error:
347
+ value = 0.0
348
+
349
+ return ParsedParamResponse(can_id=can_id, opcode=opcode, rid=rid,
350
+ value=value)
351
+
352
+
353
+ # ── Motor type parameters ────────────────────────────────────────────────
354
+
355
+ @dataclass
356
+ class MotorLimits:
357
+ """MIT frame quantization limits for a motor type."""
358
+ q_max: float # rad
359
+ dq_max: float # rad/s
360
+ tau_max: float # Nm
361
+
362
+
363
+ # Per-motor-type limits (matching DM4310/DM4340/DM6248P datasheet)
364
+ MOTOR_LIMITS: dict[int, MotorLimits] = {
365
+ 1: MotorLimits(q_max=12.5, dq_max=30.0, tau_max=10.0), # DM4310
366
+ 3: MotorLimits(q_max=12.5, dq_max=10.0, tau_max=28.0), # DM4340
367
+ 6: MotorLimits(q_max=12.566, dq_max=20.0, tau_max=120.0), # DM6248P
368
+ }
369
+
370
+
371
+ def get_motor_limits(motor_type: int) -> MotorLimits:
372
+ """Get quantization limits for a given motor type index.
373
+
374
+ Args:
375
+ motor_type: DM_Motor_Type enum value (1=DM4310, 3=DM4340, 6=DM6248P, etc.)
376
+
377
+ Returns:
378
+ MotorLimits for that type. Falls back to DM4310 limits for unknown types.
379
+ """
380
+ return MOTOR_LIMITS.get(motor_type, MOTOR_LIMITS[1])