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,9 @@
1
+ {
2
+ "calibrated": true,
3
+ "zero_position_rad": 0.029564,
4
+ "max_position_rad": -1.141184,
5
+ "travel_range_rad": 1.170748,
6
+ "rad_to_mm": 73.45731105242119,
7
+ "work_stroke_mm": 80.0,
8
+ "motor_type": "DM4310"
9
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "calibrated": true,
3
+ "zero_position_rad": -1.141184,
4
+ "max_position_rad": 0.029564,
5
+ "travel_range_rad": 1.170748,
6
+ "rad_to_mm": 73.45731105242119,
7
+ "work_stroke_mm": 80.0,
8
+ "motor_type": "DM4310"
9
+ }
@@ -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})")