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,365 @@
1
+ """Raw SocketCAN transport — no dependencies beyond Python stdlib + Linux SocketCAN.
2
+
3
+ Supports classic CAN (MTU 16) and CAN FD (MTU 72) with automatic mode
4
+ detection. Provides send/receive with optional timeout.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import fcntl
10
+ import logging
11
+ import select
12
+ import socket
13
+ import struct
14
+ import time
15
+ from dataclasses import dataclass
16
+ from enum import IntEnum
17
+ from typing import Optional
18
+
19
+ log = logging.getLogger("litegrip.can.transport")
20
+
21
+ # ── SocketCAN constants ──────────────────────────────────────────────────
22
+ CAN_RAW = getattr(socket, "CAN_RAW", 1)
23
+ CAN_RAW_FD_FRAMES = getattr(socket, "CAN_RAW_FD_FRAMES", 5)
24
+ CAN_RAW_FILTER = getattr(socket, "CAN_RAW_FILTER", 1)
25
+ SOL_CAN_RAW = getattr(socket, "SOL_CAN_RAW", 101)
26
+ CAN_SFF_MASK = 0x7FF
27
+ CAN_EFF_MASK = 0x1FFFFFFF
28
+
29
+ # struct can_filter { canid_t can_id; canid_t can_mask; } — two native uint32.
30
+ _CAN_FILTER_FMT = "=II"
31
+
32
+ # Classic CAN frame: can_id(4B) + dlc(1B) + padding(3B) + data(8B) = 16B
33
+ CAN_FRAME_FMT = "=IB3x8s"
34
+ CAN_MTU = 16
35
+
36
+ # CAN FD frame: can_id(4B) + dlc(1B) + flags(1B) + len8_flag(1B) + reserved(1B) + data(64B) = 72B
37
+ CANFD_FRAME_FMT = "=IBBBB64s"
38
+ CANFD_FRAME_SIZE = struct.calcsize(CANFD_FRAME_FMT)
39
+ CANFD_BRS = 0x01
40
+ CANFD_MTU = 72
41
+
42
+ _SIOCGIFMTU = 0x8921
43
+
44
+ # sizeof(struct ifreq) — 40 bytes on 64-bit Linux, 32 on 32-bit. SIOCGIFMTU
45
+ # writes back the whole struct, not just ifr_name plus ifr_mtu.
46
+ _IFREQ_SIZE = 40
47
+
48
+
49
+ def _iface_mtu(sock: socket.socket, iface: str) -> Optional[int]:
50
+ """Read interface MTU (16=classic CAN, 72=CAN FD). Returns None on failure."""
51
+ try:
52
+ ifr = struct.pack("16sH", iface.encode("utf-8")[:15], 0)
53
+ # Hand over a whole struct ifreq: the kernel writes all of it back, and
54
+ # since 3.14 fcntl.ioctl() raises SystemError (not OSError) when
55
+ # anything lands past the end of the argument. The reply is exactly as
56
+ # long as the argument, so slice the mtu back out of the front.
57
+ ifr = ifr.ljust(_IFREQ_SIZE, b"\x00")
58
+ res = fcntl.ioctl(sock.fileno(), _SIOCGIFMTU, ifr)
59
+ return struct.unpack("16sH", res[:18])[1]
60
+ except OSError:
61
+ return None
62
+
63
+
64
+ class CanMode(IntEnum):
65
+ """CAN interface mode."""
66
+ CAN = 0
67
+ CANFD = 1
68
+
69
+
70
+ @dataclass
71
+ class CanFrame:
72
+ """A single CAN frame."""
73
+ can_id: int
74
+ data: bytes
75
+ is_extended: bool = False
76
+ is_fd: bool = False
77
+ timestamp: float = 0.0
78
+
79
+ @property
80
+ def dlc(self) -> int:
81
+ return len(self.data)
82
+
83
+
84
+ class CanTransport:
85
+ """SocketCAN transport — opens a CAN socket and provides send/recv.
86
+
87
+ Usage::
88
+
89
+ transport = CanTransport("can0")
90
+ transport.open()
91
+ transport.send(0x108, bytes([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFC]))
92
+ frame = transport.recv(timeout_s=0.1)
93
+ transport.close()
94
+ """
95
+
96
+ def __init__(self, channel: str, mode: CanMode = CanMode.CAN):
97
+ self._channel = channel
98
+ self._mode = mode
99
+ self._sock: Optional[socket.socket] = None
100
+ self._is_open = False
101
+ # Set of CAN IDs (SFF) we want the kernel to deliver. Empty = accept
102
+ # all (no hardware filter). Populated via set_id_filter() once the
103
+ # motor's mst_id is known, so that on a shared bus (e.g. a full arm
104
+ # chattering on other IDs) foreign frames don't flood the RX buffer
105
+ # and starve/drop the motor's status frames.
106
+ self._accept_ids: set = set()
107
+
108
+ # ── properties ──────────────────────────────────────────────────────
109
+
110
+ @property
111
+ def channel(self) -> str:
112
+ return self._channel
113
+
114
+ @property
115
+ def mode(self) -> CanMode:
116
+ return self._mode
117
+
118
+ @property
119
+ def is_open(self) -> bool:
120
+ return self._is_open
121
+
122
+ # ── open / close ────────────────────────────────────────────────────
123
+
124
+ def open(self) -> None:
125
+ """Open and bind the CAN socket.
126
+
127
+ Raises:
128
+ OSError: if the interface does not exist or is down.
129
+ """
130
+ if self._is_open:
131
+ return
132
+
133
+ sock = socket.socket(socket.PF_CAN, socket.SOCK_RAW, CAN_RAW)
134
+
135
+ # Check interface MTU. Some USB-CAN adapters (gs_usb) and Linux
136
+ # kernel versions require CAN FD socket mode on FD-capable
137
+ # interfaces — classic CAN sockets may silently drop frames.
138
+ # Match the old damiao_socketcan behaviour: upgrade CAN→CANFD
139
+ # when the interface is FD, downgrade CANFD→CAN when not.
140
+ mtu = _iface_mtu(sock, self._channel)
141
+ if mtu is not None:
142
+ if mtu >= CANFD_MTU and self._mode == CanMode.CAN:
143
+ log.info(
144
+ "Interface %s is CAN FD (MTU=%d); switching to CANFD mode.",
145
+ self._channel, mtu,
146
+ )
147
+ self._mode = CanMode.CANFD
148
+ elif mtu < CANFD_MTU and self._mode == CanMode.CANFD:
149
+ log.warning(
150
+ "Interface %s MTU is %d (classic CAN) but CanMode.CANFD "
151
+ "requested; switching to classic CAN.",
152
+ self._channel, mtu,
153
+ )
154
+ self._mode = CanMode.CAN
155
+
156
+ if self._mode == CanMode.CANFD:
157
+ try:
158
+ sock.setsockopt(SOL_CAN_RAW, CAN_RAW_FD_FRAMES, 1)
159
+ except OSError:
160
+ log.warning("Failed to enable CAN FD on %s; falling back to classic CAN.", self._channel)
161
+ self._mode = CanMode.CAN
162
+
163
+ try:
164
+ socket.if_nametoindex(self._channel) # verify interface exists
165
+ except OSError as e:
166
+ sock.close()
167
+ raise OSError(f"CAN interface '{self._channel}' not found: {e}")
168
+
169
+ sock.bind((self._channel,))
170
+ sock.setblocking(False)
171
+
172
+ self._sock = sock
173
+ self._is_open = True
174
+ # Re-apply any ID filter requested before the socket existed.
175
+ if self._accept_ids:
176
+ self._apply_id_filter()
177
+ log.info("CAN transport opened on %s (mode=%s)", self._channel, self._mode.name)
178
+
179
+ # ── receive filtering ─────────────────────────────────────────────────
180
+
181
+ def set_id_filter(self, can_ids) -> None:
182
+ """Restrict which CAN IDs the kernel delivers to this socket.
183
+
184
+ On a shared bus (e.g. a full arm), other devices chatter on their own
185
+ IDs at high rate. Without a hardware filter every one of those frames
186
+ lands in this socket's RX buffer; a caller that reads one frame per
187
+ control cycle cannot keep up, the buffer saturates, and the motor's
188
+ own status frames are delayed or dropped — making read-back appear
189
+ "frozen" while the motor is actually moving.
190
+
191
+ Args:
192
+ can_ids: Iterable of SFF CAN IDs to accept. Empty/None clears the
193
+ filter (accept all frames again).
194
+ """
195
+ self._accept_ids = {int(i) & CAN_SFF_MASK for i in (can_ids or [])}
196
+ if self._is_open:
197
+ self._apply_id_filter()
198
+
199
+ def add_id_filter(self, can_id: int) -> None:
200
+ """Add a single CAN ID to the accept set (see :meth:`set_id_filter`)."""
201
+ self.set_id_filter(self._accept_ids | {int(can_id) & CAN_SFF_MASK})
202
+
203
+ def _apply_id_filter(self) -> None:
204
+ """Push the current accept-id set to the socket via CAN_RAW_FILTER.
205
+
206
+ An empty set installs a zero-length filter list, which SocketCAN
207
+ interprets as "receive nothing" — so instead we remove the filter by
208
+ installing a single match-all entry (id=0, mask=0).
209
+ """
210
+ if self._sock is None:
211
+ return
212
+ if self._accept_ids:
213
+ fdata = b"".join(
214
+ struct.pack(_CAN_FILTER_FMT, cid, CAN_SFF_MASK)
215
+ for cid in sorted(self._accept_ids)
216
+ )
217
+ else:
218
+ fdata = struct.pack(_CAN_FILTER_FMT, 0, 0) # match-all
219
+ try:
220
+ self._sock.setsockopt(SOL_CAN_RAW, CAN_RAW_FILTER, fdata)
221
+ log.info("CAN RX filter set on %s: %s", self._channel,
222
+ [hex(i) for i in sorted(self._accept_ids)] or "all")
223
+ except OSError as e:
224
+ log.warning("Failed to set CAN RX filter on %s: %s", self._channel, e)
225
+
226
+ def close(self) -> None:
227
+ """Close the CAN socket."""
228
+ if not self._is_open or self._sock is None:
229
+ return
230
+ try:
231
+ self._sock.close()
232
+ except OSError:
233
+ pass
234
+ self._sock = None
235
+ self._is_open = False
236
+
237
+ # ── send ────────────────────────────────────────────────────────────
238
+
239
+ def send(self, can_id: int, data: bytes, timeout: float = 0.1) -> None:
240
+ """Send a CAN frame.
241
+
242
+ Args:
243
+ can_id: 11-bit or 29-bit CAN ID.
244
+ data: Payload (0–8 bytes classic, 0–64 bytes FD).
245
+ timeout: Max wait time if send buffer is full (seconds).
246
+
247
+ Raises:
248
+ TimeoutError: the send buffer stayed full for ``timeout`` seconds and
249
+ the frame could not be sent. ``TimeoutError`` is an ``OSError``.
250
+ The frame is *not* dropped silently: a caller that cannot send
251
+ must find out, or it reports motion that never happened.
252
+ """
253
+ if not self._is_open or self._sock is None:
254
+ raise RuntimeError("Transport not open")
255
+
256
+ if self._mode == CanMode.CANFD:
257
+ flags = 0
258
+ frame = struct.pack(CANFD_FRAME_FMT,
259
+ can_id & CAN_EFF_MASK,
260
+ len(data), flags, 0, 0,
261
+ data.ljust(64, b'\x00'))
262
+ else:
263
+ if len(data) > 8:
264
+ raise ValueError(f"Classic CAN frames max 8 bytes, got {len(data)}")
265
+ frame = struct.pack(CAN_FRAME_FMT,
266
+ can_id & CAN_SFF_MASK,
267
+ len(data),
268
+ data.ljust(8, b'\x00'))
269
+
270
+ # Retry on BlockingIOError (send buffer full under high load).
271
+ deadline = time.monotonic() + timeout
272
+ while True:
273
+ try:
274
+ self._sock.send(frame)
275
+ return
276
+ except BlockingIOError:
277
+ remaining = deadline - time.monotonic()
278
+ if remaining <= 0:
279
+ # 丢帧改报错:缓冲满意味着这一帧**没发出去**,而调用方拿到
280
+ # 一个 None 就会当成发出去了(实测里正是这条链让夹爪
281
+ # 「位移 0.000 mm 却返回 True」)。docstring 一直承诺的是
282
+ # OSError,这里只是把实现改成与它一致。
283
+ log.warning(
284
+ "CAN send timeout on %s (buffer full >%.1fs), "
285
+ "frame to 0x%03X not sent",
286
+ self._channel, timeout, can_id & CAN_SFF_MASK,
287
+ )
288
+ raise TimeoutError(
289
+ f"CAN 发送超时:{self._channel} 的发送缓冲在 {timeout:.1f} s "
290
+ f"内一直满,帧 0x{can_id & CAN_SFF_MASK:03X} 没发出去。")
291
+ select.select([], [self._sock], [], remaining)
292
+
293
+ def send_multi(self, can_id: int, data: bytes, count: int,
294
+ interval_s: float = 0.002) -> None:
295
+ """Send the same frame multiple times (for reliability of config commands)."""
296
+ for _ in range(count):
297
+ self.send(can_id, data)
298
+ if count > 1 and interval_s > 0:
299
+ time.sleep(interval_s)
300
+
301
+ # ── recv ────────────────────────────────────────────────────────────
302
+
303
+ def recv(self, timeout_s: float = 0.0) -> Optional[CanFrame]:
304
+ """Receive a single CAN frame.
305
+
306
+ Args:
307
+ timeout_s: Max wait time in seconds. 0 = non-blocking poll.
308
+
309
+ Returns:
310
+ CanFrame if a frame was received, None if timeout.
311
+ """
312
+ if not self._is_open or self._sock is None:
313
+ return None
314
+
315
+ try:
316
+ ready, _, _ = select.select([self._sock], [], [], timeout_s)
317
+ if not ready:
318
+ return None
319
+ except (OSError, ValueError):
320
+ return None
321
+
322
+ try:
323
+ raw = self._sock.recv(CANFD_FRAME_SIZE)
324
+ except OSError:
325
+ return None
326
+
327
+ ts = time.monotonic()
328
+
329
+ if self._mode == CanMode.CANFD:
330
+ can_id, dlc, flags, _len8, _res, data = struct.unpack(
331
+ CANFD_FRAME_FMT, raw)
332
+ is_fd = True
333
+ is_extended = bool(can_id & CAN_EFF_MASK)
334
+ data = data[:dlc]
335
+ else:
336
+ can_id, dlc, data = struct.unpack(CAN_FRAME_FMT, raw)
337
+ is_fd = False
338
+ is_extended = bool(can_id & CAN_EFF_MASK)
339
+ data = data[:dlc]
340
+
341
+ return CanFrame(
342
+ can_id=can_id & CAN_SFF_MASK,
343
+ data=data,
344
+ is_extended=is_extended,
345
+ is_fd=is_fd,
346
+ timestamp=ts,
347
+ )
348
+
349
+ def drain(self) -> None:
350
+ """Read and discard all pending frames from the receive buffer."""
351
+ while self.recv(timeout_s=0.0) is not None:
352
+ pass
353
+
354
+ # ── context manager ─────────────────────────────────────────────────
355
+
356
+ def __enter__(self) -> "CanTransport":
357
+ self.open()
358
+ return self
359
+
360
+ def __exit__(self, *args) -> None:
361
+ self.close()
362
+
363
+ def __repr__(self) -> str:
364
+ state = "open" if self._is_open else "closed"
365
+ return f"CanTransport({self._channel!r}, {self._mode.name}, {state})"
litegrip/constants.py ADDED
@@ -0,0 +1,146 @@
1
+ """LiteGrip SDK — constants and protocol definitions.
2
+
3
+ Self-contained. Uses litegrip.can for DM motor types; does NOT import
4
+ damiao_socketcan or any arm library.
5
+ """
6
+
7
+ from typing import Final
8
+
9
+ from .can.motor import MotorType
10
+ from .can.protocol import ControlMode as _ControlMode
11
+
12
+
13
+ # ═════════════════════════════════════════════════════════════════════════
14
+ # Re-export DM protocol types for convenience
15
+ # ═════════════════════════════════════════════════════════════════════════
16
+
17
+ DM_Motor_Type = MotorType
18
+ Control_Mode = _ControlMode
19
+
20
+
21
+ # ═════════════════════════════════════════════════════════════════════════
22
+ # LiteGrip gripper parameters
23
+ # ═════════════════════════════════════════════════════════════════════════
24
+
25
+ class GripperParams:
26
+ """LiteGrip default parameters."""
27
+
28
+ CAN_ID: Final = 0x08
29
+ MST_ID: Final = 0x18
30
+ MOTOR_TYPE: Final = MotorType.DM4310
31
+ CONTROL_MODE: Final = Control_Mode.MIT_MODE
32
+
33
+ # Position limits (rad) — nominal placeholders only. The real limits come
34
+ # from the calibration; do not treat these as a direction convention, since
35
+ # a reverse-mounted motor has them the other way round.
36
+ POS_CLOSED_RAD: Final = 1.14
37
+ POS_OPEN_RAD: Final = 0.0
38
+
39
+ # MIT quantization limits (DM4310)
40
+ Q_MAX: Final = 12.5 # rad
41
+ DQ_MAX: Final = 30.0 # rad/s
42
+ TAU_MAX: Final = 10.0 # Nm
43
+
44
+ # Default control gains
45
+ DEFAULT_KP: Final = 100.0
46
+ DEFAULT_KD: Final = 2.0
47
+
48
+ # Fault recovery
49
+ FAULT_CLEAR_RETRIES: Final = 5
50
+ FAULT_CLEAR_DELAY_S: Final = 0.02
51
+
52
+
53
+ # ═════════════════════════════════════════════════════════════════════════
54
+ # Gripper geometry and unit conversion
55
+ # ═════════════════════════════════════════════════════════════════════════
56
+
57
+ class GripperGeometry:
58
+ """The LiteGrip two-finger gripper's nominal geometry.
59
+
60
+ These are the numbers every uncalibrated default in this package comes
61
+ from. They are the measured geometry of the reference unit, not a
62
+ tolerance to be picked per gripper: an ``85 mm`` jaw travel is what the
63
+ calipers read, and the ``1 mm`` inset is how far the probe presses into
64
+ the open stop while looking for it.
65
+
66
+ **Keep the two apart; do not fold the inset into the travel.** The
67
+ calibration probe measures the span *between the two stops*, which is
68
+ ``SPAN_MM`` (86 mm) — the jaws at rest on the closed stop, and pressed
69
+ ~1 mm past where the jaws have run out of travel on the open one. The
70
+ millimetres-per-radian scale is that span over the measured travel in
71
+ radians (``rad_to_mm``); deriving it from the 85 mm jaw travel instead
72
+ would leave every mm-based move 1.2% short — 1 mm lost per full stroke.
73
+ The factory calibration's own numbers show the pair: 1.170748 rad of
74
+ travel is 86 mm at ``73.45731105242119`` mm/rad, and 85 mm at
75
+ ``72.604``.
76
+
77
+ ``work_stroke_mm`` is measured from the closed zero like
78
+ :attr:`JAW_TRAVEL_MM`, so a full-stroke ``work_stroke_mm`` is 85 mm, not
79
+ ``SPAN_MM``.
80
+ """
81
+
82
+ #: Jaw travel measured with calipers, from the closed stop (mm).
83
+ JAW_TRAVEL_MM: Final = 85.0
84
+
85
+ #: How far the calibration probe presses into the open stop (mm).
86
+ STOP_INSET_MM: Final = 1.0
87
+
88
+ #: What the recorded extremes span (mm) — the numerator of the scale.
89
+ SPAN_MM: Final = JAW_TRAVEL_MM + STOP_INSET_MM
90
+
91
+
92
+ class UnitConversion:
93
+ """Unit conversion coefficients.
94
+
95
+ Nominal values for this gripper — see :class:`GripperGeometry`. They are
96
+ the span over the uncalibrated placeholder travel
97
+ (:attr:`~litegrip.models.GripperConfig.pos_closed_rad`, ``1.14 rad``), and
98
+ every motion is refused until a real calibration replaces them. Run
99
+ ``calibrate()`` to get accurate per-unit values.
100
+ """
101
+ RAD_TO_MM: Final = GripperGeometry.SPAN_MM / 1.14 # ≈ 75.44 mm/rad
102
+ MM_TO_RAD: Final = 1.14 / GripperGeometry.SPAN_MM # ≈ 0.01326 rad/mm
103
+ NM_TO_N: Final = 10.0 # approximate N per Nm
104
+ N_TO_NM: Final = 0.1 # approximate Nm per N
105
+
106
+
107
+ # ═════════════════════════════════════════════════════════════════════════
108
+ # Error codes
109
+ # ═════════════════════════════════════════════════════════════════════════
110
+
111
+ class ErrorCode:
112
+ """Damiao motor error codes (extracted from status frame data[0] >> 4)."""
113
+ DISABLED: Final = 0
114
+ ENABLED: Final = 1
115
+ UV_FAULT: Final = 0x9
116
+ OC_FAULT: Final = 0xA
117
+ MOS_OT: Final = 0xB
118
+ COIL_OT: Final = 0xC
119
+
120
+
121
+ ERROR_DESCRIPTIONS = {
122
+ 0x0: "已失能",
123
+ 0x1: "已使能",
124
+ 0x9: "欠压故障 (UV)",
125
+ 0xA: "过流故障 (OC)",
126
+ 0xB: "MOS 过温故障",
127
+ 0xC: "线圈过温故障",
128
+ }
129
+
130
+
131
+ def describe_error(code: int) -> str:
132
+ """Return a human-readable description for a motor error code."""
133
+ return ERROR_DESCRIPTIONS.get(code, f"未知错误 (0x{code:X})")
134
+
135
+
136
+ # ═════════════════════════════════════════════════════════════════════════
137
+ # Default configuration
138
+ # ═════════════════════════════════════════════════════════════════════════
139
+
140
+ class DefaultParams:
141
+ """System defaults."""
142
+ CAN_CHANNEL: Final = "can0"
143
+ CAN_BITRATE: Final = 1_000_000 # 1 Mbps classic CAN
144
+ CANFD_MODE: Final = False
145
+ TIMEOUT_S: Final = 0.1
146
+ INIT_TIMEOUT_S: Final = 2.0
litegrip/exceptions.py ADDED
@@ -0,0 +1,47 @@
1
+ """LiteGrip SDK - 异常定义"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class LiteGripError(Exception):
7
+ """LiteGrip 基础异常"""
8
+
9
+ def __init__(self, message: str, error_code: int | None = None):
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.error_code = error_code
13
+
14
+ def __str__(self) -> str:
15
+ if self.error_code is not None:
16
+ return f"{self.message} [0x{self.error_code:04X}]"
17
+ return self.message
18
+
19
+
20
+ class CommError(LiteGripError):
21
+ """通信错误(CAN 总线读写失败)"""
22
+ pass
23
+
24
+
25
+ class ConnectError(LiteGripError):
26
+ """连接错误(CAN 接口不可用、电机无应答等)"""
27
+ pass
28
+
29
+
30
+ class CommandError(LiteGripError):
31
+ """指令错误(电机拒绝执行或参数越界)"""
32
+ pass
33
+
34
+
35
+ class CANTimeoutError(LiteGripError):
36
+ """超时错误(CAN 总线无应答)"""
37
+ pass
38
+
39
+
40
+ class HardwareError(LiteGripError):
41
+ """硬件故障(欠压/过流/过温等达妙电机故障码)"""
42
+ pass
43
+
44
+
45
+ class NotInitializedError(LiteGripError):
46
+ """未初始化错误(未连接/未使能时调用了需要使能的操作)"""
47
+ pass
@@ -0,0 +1,16 @@
1
+ {
2
+ "channel": "can0",
3
+ "can_id": 8,
4
+ "canfd_mode": false,
5
+ "calibrated": true,
6
+ "zero_position_rad": 0.029564,
7
+ "max_position_rad": -1.141184,
8
+ "travel_range_rad": 1.170748,
9
+ "rad_to_mm": 73.45731105242119,
10
+ "work_stroke_mm": 80.0,
11
+ "motor_type": "DM4310",
12
+ "kp": 100.0,
13
+ "kd": 2.0,
14
+ "grasp_torque_threshold": 0.5,
15
+ "mst_id": 24
16
+ }