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,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