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,636 @@
1
+ Metadata-Version: 2.4
2
+ Name: litegrip-python
3
+ Version: 1.1.2
4
+ Summary: Python SDK for the LiteGrip lightweight robotic gripper series
5
+ Author: NEXFORM ROBOTICS
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Repository, https://github.com/nexform-tech/litegrip-python
8
+ Project-URL: Issues, https://github.com/nexform-tech/litegrip-python/issues
9
+ Keywords: litegrip,litearm,gripper,robotics,socketcan,can,damiao,dm4310
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.8
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.8
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: zenoh
26
+ Requires-Dist: eclipse-zenoh>=1.0; extra == "zenoh"
27
+ Dynamic: license-file
28
+
29
+ # litegrip-python
30
+
31
+ [English](https://github.com/nexform-tech/litegrip-python/blob/main/README.md) | [简体中文](https://github.com/nexform-tech/litegrip-python/blob/main/README.zh-CN.md)
32
+
33
+ Python SDK for the **LiteGrip lightweight robotic gripper series**, and the primary public
34
+ interface to it. This is the reference: what each interface is, and how to use it.
35
+
36
+ You do not need to know CAN or the DM4310 protocol: every move, calibration and force control is
37
+ one method call, and each call verifies its own outcome before reporting success. Every signature
38
+ below is the real one, with its default.
39
+
40
+ ## What you need
41
+
42
+ | | |
43
+ | --- | --- |
44
+ | Motor | Damiao DM4310, CAN ID `0x08`, MIT mode, classic CAN at 1 Mbit/s |
45
+ | Host | Linux with SocketCAN — this is the only supported platform |
46
+ | Python | 3.8 or newer |
47
+ | Dependencies | none: standard library only |
48
+ | Optional | `litegrip-python[zenoh]` — the point-to-point teleoperation link |
49
+
50
+ ## 1. Install
51
+
52
+ Install it from a checkout:
53
+
54
+ ```bash
55
+ git clone https://github.com/nexform-tech/litegrip-python.git
56
+ cd litegrip-python
57
+ python3 -m pip install .
58
+ ```
59
+
60
+ Or skip the install and point `PYTHONPATH` at the source tree:
61
+
62
+ ```bash
63
+ PYTHONPATH=/path/to/litegrip-python/src python3 your_script.py
64
+ ```
65
+
66
+ ## 2. Bring up CAN
67
+
68
+ The gripper is a CAN device. Bring the interface up at 1 Mbit/s before any SDK call, and do it
69
+ again after every reboot:
70
+
71
+ ```bash
72
+ sudo ip link set can0 type can bitrate 1000000
73
+ sudo ip link set can0 up
74
+ ```
75
+
76
+ Both commands need root. If `can0` does not exist at all, the CAN adapter is not loaded yet. In a
77
+ product, run these two commands at boot from your init system, so the application never has to
78
+ start the interface itself.
79
+
80
+ ## 3. Quick start
81
+
82
+ ```python
83
+ from litegrip import LiteGrip
84
+
85
+ with LiteGrip("can0") as gripper: # connect() on entry, disconnect() on exit
86
+ gripper.load_calibration() # the shipped factory calibration — no arguments
87
+ gripper.enable() # retries until the status frame says err == 1
88
+
89
+ gripper.open() # 50 mm/s toward the open-side stop
90
+ gripper.close() # 50 mm/s toward the closed-side stop
91
+ result = gripper.grasp(force_n=20.0, hold_s=3.0)
92
+ print(result.ok, result.stalled, result.cycles)
93
+ ```
94
+
95
+ Two things to know before going further:
96
+
97
+ - **The factory calibration is the default.** A no-argument `load_calibration()` reads the
98
+ calibration file shipped inside the SDK, which describes the shipped units (normal mount). You
99
+ name a different file only when you have one — see [Calibration](#calibration).
100
+ - **Nothing moves before a calibration is loaded.** Every motion method raises `CommandError` on
101
+ an uncalibrated config, because the placeholder limits describe no real travel.
102
+
103
+ `with` calls `disconnect()` on exit, which disables the motor by default. Pass
104
+ `LiteGrip(..., disable_on_disconnect=False)` to leave it energised after the block.
105
+
106
+ ## Interfaces
107
+
108
+ ### LiteGrip
109
+
110
+ ```python
111
+ LiteGrip(channel="can0", can_id=0x08, mst_id=None, canfd_mode=None,
112
+ motor_type=MotorType.DM4310, config=None, motion_config=None,
113
+ disable_on_disconnect=True, mount=None)
114
+ ```
115
+
116
+ - `channel` — CAN interface name. Every LiteGrip shares CAN ID `0x08`, so the channel is the only
117
+ identity key when two sit on one machine.
118
+ - `mount` — `"normal"` or `"reverse"`, loading that template's limits. The shipped units are
119
+ normal mount, which is what the factory calibration describes, so leave it unset unless a unit
120
+ is reverse-mounted (its jaws open when you `close()`).
121
+ - `mst_id` — master ID; `None` auto-detects it.
122
+ - `config` / `motion_config` — replace the two dataclasses up front instead of editing them after
123
+ construction.
124
+ - `disable_on_disconnect` — `disconnect()` disables the motor (crash safety). Set `False` only
125
+ when something else keeps the gripper under control.
126
+
127
+ | Attribute | What it is |
128
+ | --- | --- |
129
+ | `channel`, `can_id`, `mst_id` | The identity this instance drives |
130
+ | `config` | `GripperConfig` — limits, scale, gains |
131
+ | `motion_config` | `MotionConfig` — every tunable; settable as a whole or field by field |
132
+ | `actions` | `GripperActions` — the engine the high-level methods forward to |
133
+ | `mount` | `"normal"`, `"reverse"`, or `None` until calibrated (derived from the limits) |
134
+ | `is_connected`, `is_enabled` | Session state |
135
+ | `last_send_error` | Why the last `send_mit_frame` did not send, or `None`. The exception the send reported — an `OSError` from the transport, or the `NotInitializedError` naming the step that was skipped. |
136
+ | `session` | The CAN session, for advanced use |
137
+
138
+ ### Connection and state
139
+
140
+ | Method | What it does |
141
+ | --- | --- |
142
+ | `connect()` → `bool` | Opens the CAN session. The `with` block does this for you. |
143
+ | `disconnect()` → `None` | Closes it, disabling the motor unless `disable_on_disconnect=False`. |
144
+ | `poll(timeout_s=0.0)` → `bool` | Reads one frame; `True` if one arrived. |
145
+ | `get_state(wait=True)` → `GripperState` | Position, velocity, torque, temperature, error bits, plus how fresh they are. `wait=True` waits up to 50 ms for the frame that updates them — it does not guarantee one. |
146
+ | `refresh_status()` → `bool` | Asks the motor for a status frame (0xCC) without moving the jaws. Use it before `get_state()` when the gripper has been idle. |
147
+ | `get_position()` / `get_position_rad()` → `float` | Measured opening, in mm / rad. |
148
+ | `get_force()` → `float` | `get_torque()` × 10, in N. |
149
+ | `get_torque()` → `float` | Measured torque, Nm. |
150
+ | `get_temperature()` → `tuple[int, int]` | `(MOS °C, coil °C)`. |
151
+ | `get_error()` → `int` | The status code the motor reported: `1` enabled, `0` disabled, `0x9`–`0xC` for faults. `describe_error(code)` names one. |
152
+ | `get_info()` → `GripperInfo` | Static device metadata. |
153
+ | `is_moving()` / `is_grasped()` → `bool` | Motion and grip predicates. |
154
+ | `wait_for_ready(timeout=5.0)` → `bool` | Blocks until the motor accepts commands. |
155
+
156
+ The readings `get_state()` returns are a cache, and only a status frame updates it. A DM motor
157
+ answers the control frames it receives, so a gripper that is being driven reports on its own; one
158
+ holding a pose, or disabled, sends nothing, and then `wait=True` waits out its 50 ms and hands back
159
+ the previous frame. The snapshot says which happened:
160
+
161
+ ```python
162
+ state = gripper.get_state()
163
+ if not state.fresh:
164
+ gripper.refresh_status() # 0xCC: ask for a frame; the jaws do not move
165
+ state = gripper.get_state()
166
+
167
+ if state.is_stale(): # older than 0.5 s, or never had a frame
168
+ raise RuntimeError(f"no status frame for {state.data_age_s} s")
169
+ print(state.position_mm, state.force_n, state.timestamp)
170
+ ```
171
+
172
+ - `fresh` — a frame answered *this* read. False means the numbers are the previous frame's, however
173
+ old that is.
174
+ - `data_age_s` — seconds since the frame behind the snapshot arrived, or `None` when none ever has.
175
+ - `timestamp` — when that frame arrived, not when you asked. `0.0` means there is no frame at all and
176
+ every reading is a constructor default. **Do not** time a loop with it; it is for a human reading a
177
+ log, and the age arithmetic is what `data_age_s` is for.
178
+ - `is_stale(stale_after_s=STALE_AFTER_S)` — age past the threshold (`STALE_AFTER_S` is 0.5 s). It is
179
+ about the snapshot, not the read: a snapshot 20 ms old can still be `fresh=False`.
180
+
181
+ `fresh` and `is_stale()` answer different questions, and neither substitutes for the other — the wait
182
+ window is 50 ms while the staleness threshold is 0.5 s, so a read no frame answered can still look
183
+ young. Ask both when the difference matters.
184
+
185
+ ### Enable and faults
186
+
187
+ | Method | What it does |
188
+ | --- | --- |
189
+ | `enable(retries=None)` → `EnableResult` | Enables, then re-reads the status frame until it reports `err == 1`. `retries=None` uses `MotionConfig.enable_retries` (3). |
190
+ | `disable()` → `bool` | Zero torque; the jaws are back-drivable by hand. |
191
+ | `clear_fault()` → `bool` | Clears a latched motor fault. |
192
+ | `stop()` → `None` | Sends one zero-torque frame. |
193
+
194
+ ### Motion
195
+
196
+ | Method | What it does |
197
+ | --- | --- |
198
+ | `open(speed_mm_s=None, *, progress=None)` → `MoveResult` | Ramps toward the calibrated open-side stop. With `GripperConfig.work_stroke_mm` set it stops at that opening instead — the shipped calibration sets `80.0` against the unit's 85 mm of travel, so a plain `open()` ends 5 mm short of the open end. |
199
+ | `close(speed_mm_s=None, *, progress=None)` → `MoveResult` | The same, toward the closed side. `work_stroke_mm` is an open-side number and does not change where a close ends. |
200
+ | `home()` → `MoveResult` | Ramps to the calibrated closed position. |
201
+ | `grasp(force_n=None, hold_s=0.0, *, progress=None)` → `GraspResult` | Closes onto the object — or onto the closed-side calibrated stop if there is nothing there — then ramps to and holds `force_n` N. The closing leg carries `force_n` as its torque budget, so meeting the object presses no harder than the setpoint. The hold takes over only once the jaw is stopped: it is `stalled=True` (something is between the jaws) or `reached=True` (the jaws closed empty). `hold_s=0` holds until a fault, the runaway watch, or Ctrl+C. `force_n=None` uses `MotionConfig.force_n` (20 N). |
202
+ | `goto(position_mm, kp=None, kd=None, duration=0.5)` → `MoveResult` | Ramp to a position in millimetres. |
203
+ | `goto_rad(position_rad, kp=None, kd=None, dq_target=0.0, tau_feedforward=0.0, duration=0.5)` → `MoveResult` | The same in radians. `dq_target` and `tau_feedforward` must be 0: the ramp derives the velocity feed-forward from its own schedule, so both are refused with `CommandError` rather than ignored. |
204
+ | `move_to(target_rad, kp=None, kd=None, tau_feedforward=0.0, duration=1.0)` → `MoveResult` | The same as `goto_rad`, with a longer default `duration`. |
205
+ | `move_at_speed(target_mm, speed_mm_s=30.0, kp=None, kd=None)` → `MoveResult` | Ramp to a millimetre target at `speed_mm_s`. |
206
+ | `move_at_speed_rad(target_rad, speed_rad_s=0.5, kp=None, kd=None)` → `MoveResult` | The same in radians. |
207
+
208
+ `kp` and `kd` fall back to `GripperConfig.kp` (`100.0`) and `.kd` (`2.0`) on every call that takes
209
+ them. Every positioning call — the `goto` family and the `move_at_speed` family alike — runs one
210
+ ramp of one frame per `MotionConfig.frame_interval`, and **`duration` is the length of that ramp**,
211
+ not a deadline: a 40 mm target with `duration=0.5` travels at 80 mm/s and takes 0.5 s, then holds
212
+ the target for `settle_s` (`0.3`) while arrival is decided. The two families differ only in how the
213
+ speed is given (a duration, a speed in mm/s, or a speed in rad/s).
214
+
215
+ The ramp is bounded, and that bound is the point of it. Each frame's command leads the measured
216
+ position by at most `MotionConfig.max_lead_mm` (4 mm), which caps the frame's torque near
217
+ `kp × 4 mm` — about 6.6 N·m against the shipped `rad_to_mm` of `61.01`, under the DM4310's ±10 N·m
218
+ frame limit. A hard step to the target is not bounded: at the shipped `kp` of `100 Nm/rad` any
219
+ error past about `0.1 rad` saturates that limit, and a saturated frame overshoots the target
220
+ instead of stopping on it. `open` and `close` press onto a calibrated stop on purpose; they cap the
221
+ emitted command at the calibrated extreme, so the jaw stops on the limit and is never driven past
222
+ it. A NaN or ±inf position is refused with `CommandError`, never clamped.
223
+
224
+ ### Gripping and force
225
+
226
+ `grasp(force_n, hold_s)` is the only way to press with a force. It closes first and holds second,
227
+ and the hold frames carry `kp = kd = 0`, so the drive's output *is* the feed-forward torque
228
+ `force_n × 0.1 Nm`. That is what makes the force independent of the workpiece: as a soft object
229
+ yields, the jaws follow it and the grip stays at the setpoint.
230
+
231
+ **A force is only meaningful against something.** There is no call that applies a force at the
232
+ current position, and that is deliberate. Such a call cannot tell whether anything is between the
233
+ jaws, and the hold frames give the drive no position or velocity error to work against — with
234
+ nothing there, the torque is the only acting force and the jaws accelerate. `grasp()` avoids it
235
+ by construction: the closing leg runs on the position ramp, so it arrives at the object (or at
236
+ the closed-side stop) and stops there before the hold takes over.
237
+
238
+ - **The force is approximate.** `1 N = 0.1 Nm` is marked approximate in the SDK, and the real
239
+ clamping force also depends on your finger geometry. Treat `force_n` as a repeatable setting,
240
+ not a calibrated measurement.
241
+ - **The jaws stay compliant.** An external push back-drives them while the motor keeps pushing
242
+ with `force_n`, and an empty `grasp()` closes onto the mechanical stop at that force. That is
243
+ the price of a force that does not drift when the workpiece moves.
244
+ - **Below about 3.7 N the approach speed becomes the limit**, because one frame of travel at
245
+ `grasp_speed_mm_s` already spends the whole torque budget. Cap the travel time with
246
+ `grasp_speed_mm_s` rather than asking for a force the mechanism's own friction absorbs.
247
+
248
+ **The runaway watch.** The hold frames carry no position error, so no position limit can act on
249
+ them; the only lever is removing the torque. Once the measured position is `hold_runaway_rad`
250
+ (`0.05 rad`, about 3 mm) past the closed-side calibrated stop, `grasp()` streams zero-torque
251
+ release frames and returns with `GraspResult.travel_guard` set. Only the closing side is checked,
252
+ because that is the side the feed-forward pushes.
253
+ This is a runaway detector, not an accuracy bound: raise `hold_runaway_rad` only if a jaw that is
254
+ genuinely holding something is being released. The watch is off until the config is calibrated.
255
+
256
+ **An empty `grasp()` is the case that reaches it.** The closing leg presses onto the closed-side
257
+ stop and the hold keeps pushing there, so a stop that is not where the calibration says — or one
258
+ that has been knocked out — lets the hold walk the jaws past it. The trip leaves them sitting
259
+ outside the calibrated travel, and the release frames leave the drive *enabled* but carrying no
260
+ torque: it holds nothing, but it is not off either. Get the jaws back inside the travel before
261
+ commanding anything else — `disable()` the motor, push them clear of the stop by hand, then
262
+ `enable()` and carry on. Do not just call `grasp()` again: the closing leg re-approaches the same
263
+ stop and the hold presses it again.
264
+
265
+ Need more force than the default? `MotionConfig.force_n` is `20.0` N (`2.0 Nm`), inside the
266
+ DM4310's `3 Nm` rating. Its peak is `7 Nm` and the protocol ceiling is `10 Nm`.
267
+
268
+ ### Calibration
269
+
270
+ The factory calibration shipped inside the SDK is the standard value, and a no-argument load reads
271
+ it and nothing else:
272
+
273
+ ```python
274
+ from litegrip import LiteGrip
275
+
276
+ with LiteGrip("can0") as gripper:
277
+ gripper.load_calibration() # the shipped file, describing a normal-mount unit
278
+ gripper.enable()
279
+ ```
280
+
281
+ | Call | What it reads or writes |
282
+ | --- | --- |
283
+ | `load_calibration()` → `bool` | The shipped factory calibration, plus `LITEGRIP_CALIB` when that is set (the env var wins). |
284
+ | `load_calibration(path="/abs/file.json")` → `bool` | That file. Falls back to the factory file only if the path cannot deliver a usable calibration, and warns on a channel mismatch. |
285
+ | `load_calibration(template="normal")` / `load_template(name)` → `bool` | The limits of that template, whatever channel it is on. Strict: an unknown name raises `CommandError`. |
286
+ | `list_templates()` → `List[str]` | `['normal', 'reverse']` — every name the SDK accepts, for a UI to offer. A module-level function, imported by name. |
287
+ | `default_calib_path(channel="can0")` → `str` | Where a calibration is saved: `~/.litegrip/<channel>_calibration.json`, or `$LITEGRIP_CALIB` when set. |
288
+ | `save_calibration(path=None)` → `str` | Writes the current config to that path. |
289
+ | `zero()` → `CalibrationData` | Probes both mechanical stops, derives the scale, saves the result. |
290
+ | `calibrate_guided(kp=20.0, kd=2.0, step_rad=0.05, ...)` → `CalibrationData` | Two-step probe that stops at each limit and waits for you to confirm. |
291
+ | `calibrate_manual(duration=30.0, settle_time=2.0, sample_interval=0.01)` → `CalibrationData` | Hand-teach: goes slack, you push the jaws through the full stroke, it samples the extremes. |
292
+ | `write_zero()` → `WriteZeroResult` | Declares the *current* encoder angle as the motor's zero, in place. Writes the motor's flash and **cannot be undone**. |
293
+
294
+ What follows from that:
295
+
296
+ - **A calibration measured on your machine is never picked up by accident.** `zero()` writes
297
+ `~/.litegrip/<channel>_calibration.json`, and that file is read only when you name it:
298
+ `load_calibration(path=default_calib_path("can0"))`, or `LITEGRIP_CALIB=/path/to/file.json` for
299
+ every channel at once. **Do not expect it to be picked up automatically.**
300
+ - **Only `<channel>_calibration.json` is recognized in that directory.** A calibration file under
301
+ any other name — `can0_08_calibration.json`, say — is not a candidate at all: not tried, not
302
+ merged, not a later fallback stage. Whenever a no-argument load passes over a local file, the
303
+ warning names it and repeats the two ways to use it.
304
+ - `zero()` drives the jaws onto both real stops, so keep hands and objects out of the travel
305
+ range, and support the jaws if the gripper is mounted in a way they would fall. Set
306
+ `GripperConfig.max_stroke_mm` (`85.0`, the shipped hardware) when your jaws are not 85 mm; it is
307
+ the one per-unit measurement no calibration file carries, and it matters only when you re-probe.
308
+ - The factory file declares `channel: can0`, so a `can1` unit has no implicit source and fails
309
+ loudly — `load_calibration()` returns `False` — rather than silently adopting `can0`'s
310
+ direction. Give it a template, or a `zero()` of its own named back as above.
311
+
312
+ ### Reading the results
313
+
314
+ The result types are dataclasses that also define `__bool__`, so `if gripper.close():` works.
315
+ What it *means*, and what to check, is per action:
316
+
317
+ | Type | Fields | `bool()` is |
318
+ | --- | --- | --- |
319
+ | `MoveResult` | `ok`, `reached`, `stalled`, `state`, `target_rad`, `limit_rad`, `final_cmd_rad`, `steps`, `protected`, `released` | `ok` |
320
+ | `GraspResult` | `ok`, `reached`, `stalled`, `state`, `target_rad`, `force_n`, `cycles`, `travel_guard` | `ok` |
321
+ | `EnableResult` | `ok`, `state`, `tries` | `ok` |
322
+
323
+ **Read `ok`.** Three combinations look alarming and are correct:
324
+
325
+ - **`close()` on an empty gripper** returns `ok=True, stalled=True, reached=False`. Success for
326
+ `open`/`close` is *stalling with the command capped at the calibrated limit and the jaw parked
327
+ within `stop_tol` of it*. `reached` is essentially always `False` here, because the target sits
328
+ deliberately past the limit.
329
+ - **`grasp()` on an object** returns `stalled=True, reached=False` with `ok=True`. Stopping short
330
+ of the empty-jaw target is the point of gripping.
331
+ - **`grasp()` on an empty gripper** can return `reached=True` while the jaw sits about
332
+ `0.010 rad` short of `target_rad` — the close-side stick-slip dead band, which is why
333
+ `reach_tol` defaults to `0.02`. `target_rad` is the closed-side calibrated stop itself.
334
+
335
+ A fourth combination is a failure, and new in this version:
336
+
337
+ - **`grasp()` with `ok=False, reached=False, stalled=False`** means the closing leg ran out of
338
+ frames without reaching the stop and without stalling — the jaw is still in free travel. The
339
+ hold is *not* entered: its frames carry no position and no velocity term, so handing a moving
340
+ jaw over to them leaves the feed-forward torque as the only thing acting on it, and the jaw
341
+ accelerates until the runaway watch releases it. The call releases in place instead and reports
342
+ `cycles=0`. Nothing was gripped. If you see this, the closing leg is being starved — a stale
343
+ state read, or friction the mechanism's own budget cannot overcome.
344
+
345
+ Stalling far from the limit gives `ok=False`: something blocked the travel. The diagnostic fields
346
+ — `final_cmd_rad`, `steps`, `state` — are what you log when it is false.
347
+
348
+ The positioning calls read differently, because their target is a point inside the travel rather
349
+ than a stop: `goto`, `goto_rad`, `move_to`, `home` and `move_at_speed*` are `ok` only when the
350
+ jaws arrived and did not stall (`reached and not stalled`). Blocked halfway, they come back
351
+ `ok=False, stalled=True` — and with `released=True`, meaning the drive's force was taken off
352
+ (`kp = kd = tau = 0`) after the stall instead of being left pressing the part. `protected=True` is
353
+ the other way a move can end early: the in-travel torque guard (~7 N, see
354
+ `MotionConfig.stop_torque_nm`) tripped. That guard takes the force off on its own, so `protected`
355
+ leaves `released` at `False`, and it always forces `ok=False`.
356
+
357
+ ### Progress reporting
358
+
359
+ The motion actions never print; they report through a `progress` callback, invoked with a
360
+ `MoveProgress` snapshot at the sampling rate.
361
+
362
+ ```python
363
+ def show(p):
364
+ print(f"[{p.phase}] {p.i}/{p.total_steps} cmd={p.cmd_rad:+.4f} "
365
+ f"pos={p.pos_rad:+.4f} tau={p.torque_nm:+.3f}")
366
+
367
+ gripper.open(progress=show)
368
+ ```
369
+
370
+ For `grasp`, `p.phase` is `"hold"` while force is being held, and `p.total_steps` is `0`. `zero()`
371
+ is the exception to the rule: it prints its own probe progress to stdout.
372
+
373
+ ### Tuning
374
+
375
+ Every tunable lives on one dataclass, `MotionConfig`. The defaults are the values validated on
376
+ real hardware. Override them per instance through `gripper.motion_config`, or per call.
377
+
378
+ ```python
379
+ from litegrip import LiteGrip, MotionConfig
380
+
381
+ with LiteGrip("can0") as gripper:
382
+ gripper.motion_config = MotionConfig(speed_mm_s=25.0, force_n=10.0)
383
+ gripper.close()
384
+
385
+ gripper.motion_config.force_n = 5.0 # also writable field by field
386
+ gripper.grasp()
387
+ ```
388
+
389
+ | Field | Default | Meaning |
390
+ | --- | --- | --- |
391
+ | `speed_mm_s` | `50.0` | `open` / `close` jaw speed |
392
+ | `grasp_speed_mm_s` | `50.0` | closing speed of the `grasp` approach |
393
+ | `force_n` | `20.0` | default `grasp` force |
394
+ | `force_ramp_n_s` | `20.0` | rate the held force climbs to its setpoint, N/s |
395
+ | `hold_interval` | `0.2` | force-hold slice length, seconds |
396
+ | `hold_runaway_rad` | `0.05` | past the closed stop by this much, the force hold drops the torque and stops |
397
+ | `margin` | `0.05` | fraction of travel kept in reserve from the stop by the `limit_target` helper; no shipped call reaches it |
398
+ | `max_lead_mm` | `4.0` | cap on how far the command may lead the measured position, on moves that carry no force setpoint |
399
+ | `stop_lead_mm` | `0.7` | the same cap on the approach to a stop, so the pressing torque stays bounded |
400
+ | `press_safety` | `0.9` | fraction of the setpoint a force-carrying approach may spend |
401
+ | `reach_tol` | `0.02` | position tolerance for `reached`, radians |
402
+ | `stop_tol` | `0.03` | how close to the calibrated stop the jaw must park to count as pressed home |
403
+ | `settle_s` | `0.3` | time spent holding the target after the ramp |
404
+
405
+ Every other field — the stall window, the probe settings, the frame timing — is documented on the
406
+ dataclass itself, with the reasoning for its default.
407
+
408
+ `sleep_fn` and `monotonic_fn` are the supported seams for simulation: the engine calls only those
409
+ two for time, so `sleep_fn=lambda _: None` collapses a whole ramp to no wall-clock time.
410
+
411
+ ### Teleoperation
412
+
413
+ Two grippers can be linked so one follows the other. The **master** (leader) motor goes slack —
414
+ you push its jaws by hand — and publishes how far open it is. The **slave** (follower) drives its
415
+ own jaws to match. What travels over the wire is a normalized opening in `[0, 1]`, not an angle,
416
+ so the two ends do not need the same calibration, mount, or zero point.
417
+
418
+ ```python
419
+ from litegrip import LiteGrip
420
+
421
+ # Leader: listen and publish this gripper's opening.
422
+ with LiteGrip("can0") as master:
423
+ master.load_calibration()
424
+ master.enable()
425
+ master.teleop_start("master") # zenoh, gripA, port 17448
426
+
427
+ # Follower: connect to the leader, ramp to the first frame, then follow.
428
+ with LiteGrip("can0") as slave:
429
+ slave.load_calibration()
430
+ slave.enable()
431
+ slave.teleop_start("slave", host="192.168.1.20")
432
+ while True:
433
+ print(slave.teleop_status()) # frames, openness, loop_hz, stale, ...
434
+ ```
435
+
436
+ | Method | What it does |
437
+ | --- | --- |
438
+ | `teleop_start(mode, *, transport=None, link="zenoh", host=None, port=17448, grip_id="gripA", kp=None, kd=None, align=True, watchdog_s=0.2, dq_max=10.0, torque_limit_nm=0.0, require_ready=True, ...)` → `dict` | Starts the background loop for `mode="master"` or `"slave"`. Both ends must share `grip_id` and be connected and enabled first. |
439
+ | `teleop_stop(timeout=2.0)` → `dict` | Stops the loop and leaves the jaws holding. |
440
+ | `teleop_status()` → `dict` | `active`, `mode`, `frames`, `last_frame_age_ms`, `stale`, `openness`, `position_mm`, `force_n`, `loop_hz`, `rejected`, `send_failed`, `fault`, `torque_nm`, `over_torque`, `torque_trips`, `ready`, and more. |
441
+
442
+ The link is a point-to-point **zenoh** session, an optional dependency:
443
+
444
+ ```bash
445
+ pip install 'litegrip-python[zenoh]'
446
+ ```
447
+
448
+ Both ends use `mode="peer"` with all discovery off, so the only way they find each other is an
449
+ explicit endpoint. `link="udp"` selects a plain-UDP fallback for a trusted LAN; it has no
450
+ authentication or encryption, so keep either one on a trusted network.
451
+
452
+ `examples/teleop.py` runs one end from the command line — one process per machine:
453
+
454
+ ```bash
455
+ python3 examples/teleop.py --mode master --channel can0
456
+ python3 examples/teleop.py --mode slave --channel can0 --host 192.168.1.20
457
+ ```
458
+
459
+ To bench a **single** gripper, `--fake-leader` replaces the leader with a synthetic one on an
460
+ in-process bus. Put a rigid object between the jaws first — with nothing to press against, the
461
+ follower closes freely and the torque guard has nothing to demonstrate.
462
+
463
+ ```bash
464
+ python3 examples/teleop.py --mode slave --channel can0 --fake-leader --torque-limit 1.0
465
+ ```
466
+
467
+ - **The leader holds its jaws under gain until the follower reports ready**, so the operator
468
+ cannot push the target out from under a follower that is still travelling. `ready_timeout_s`
469
+ (default `10.0`) bounds the wait; `require_ready=False` restores the immediate hand-back.
470
+ - **An optional torque guard releases a follower that is pressing too hard.** Set
471
+ `torque_limit_nm` and it goes to zero stiffness and damping *in place* after 60 ms over the
472
+ limit, then re-arms once the leader has reopened. The default is `0` — **the guard is off
473
+ unless you ask for it**. Pick the value per machine: it depends on how fragile the part between
474
+ the jaws is.
475
+ - **A follower that loses the leader holds its position, it does not go slack.** After
476
+ `watchdog_s` (default `0.2`) without a fresh frame, `stale` goes true and the jaws stay put.
477
+ - **The follower feeds the leader's velocity forward** by differencing successive frames, so it
478
+ does not trail a moving leader. `dq_max=0` disables that.
479
+ - **Non-finite frames are dropped, never clamped**, on both ends.
480
+ - Teleop owns the CAN I/O while it runs: do not drive the gripper from the caller until
481
+ `teleop_stop()`.
482
+
483
+ ### Recording and replay
484
+
485
+ A motion you teach by hand can be captured once and repeated later. Recording puts the motor into
486
+ zero-gravity so you can push the jaws through the motion; replay streams the captured openings
487
+ back as command frames. What is stored is the normalized opening in `[0, 1]`, exactly as teleop
488
+ sends it, so a trajectory taught on one gripper replays on another with a different mount or
489
+ calibration.
490
+
491
+ ```python
492
+ from litegrip import LiteGrip
493
+
494
+ with LiteGrip("can0") as gripper:
495
+ gripper.load_calibration()
496
+ gripper.enable()
497
+
498
+ taught = gripper.record(5.0) # 5 s of hand-teaching; the jaws are slack
499
+ taught.save("pick") # ~/.litegrip/trajectories/pick.lgt
500
+ gripper.play(taught) # repeat it
501
+ ```
502
+
503
+ | Method | Behaviour |
504
+ | --- | --- |
505
+ | `record(duration_s, rate_hz=100.0, zero_gravity=True)` → `Trajectory` | Blocking hand-teach. |
506
+ | `record_start(rate_hz=100.0, zero_gravity=True, max_samples=None)` → `dict` | Background recording. |
507
+ | `record_stop(allow_empty=False)` → `Trajectory` | Stops and returns the capture. |
508
+ | `play(trajectory, speed=1.0, kp=None, kd=None, loop=False, align=True)` → `dict` | Blocking replay. `loop=True` raises `ValueError` — a blocking replay would never return. |
509
+ | `play_start(trajectory, speed=1.0, kp=None, kd=None, loop=False, align=True)` → `dict` | Background replay. |
510
+ | `play_stop(timeout=2.0)` → `dict` | Stops a replay and leaves the gripper holding. |
511
+ | `trajectory_status()` → `dict` | One snapshot for both directions: `active`, `kind`, `samples`, `error`, plus `rate_hz` / `loop_hz` while recording and `frames` / `speed` / `openness` / `completed` while playing. |
512
+
513
+ ```bash
514
+ python3 examples/trajectory.py --record 5 --save pick # hand-teach, then save
515
+ python3 examples/trajectory.py --list # no hardware needed
516
+ python3 examples/trajectory.py --play pick --repeat 3
517
+ ```
518
+
519
+ - **Replay commands position, not force.** The recorded torque is stored for diagnostics and never
520
+ fed forward, so a squeeze recorded against an object repeats as a position path. Follow the
521
+ replay with `grasp(force_n=...)` if the grip force matters.
522
+ - **`record()` is exclusive and the jaws are slack for its whole duration.** Keep a hand on the
523
+ gripper: nothing is holding the jaws, and whatever is between them will drop.
524
+ - **A capture that did not fill raises** rather than returning a short recording as if it were
525
+ whole.
526
+ - **Record without zero-gravity when something else drives.** `record_start(zero_gravity=False)`
527
+ only *reads* state, so the caller may run a `grasp()` or a move sequence from another thread and
528
+ capture it.
529
+ - `LITEGRIP_TRAJ_DIR` moves the trajectory directory. Recording, replay and teleop are mutually
530
+ exclusive and raise `TrajectoryBusyError` / `TeleopBusyError`; `disconnect()` stops whichever is
531
+ running.
532
+
533
+ ### Errors
534
+
535
+ | Exception | Raised when |
536
+ | --- | --- |
537
+ | `LiteGripError` | Base class. `str()` appends the motor's code as `[0xXXXX]` when there is one. |
538
+ | `ConnectError` | The CAN interface is unavailable, or the motor never replies. |
539
+ | `CommError` | A CAN read or write failed mid-session. |
540
+ | `CANTimeoutError` | No reply within the timeout. |
541
+ | `CommandError` | The command was rejected: bad argument, uncalibrated config, unknown template, or a NaN / ±inf value. |
542
+ | `HardwareError` | The motor reported a fault code — for instance `enable()` never saw `err == 1`. |
543
+ | `NotInitializedError` | Not connected, or not enabled. |
544
+ | `TeleopError` / `TrajectoryError` | Teleop and recording / replay; each has busy, not-active and format subclasses. |
545
+
546
+ Errors are typed and never swallowed. `describe_error(code)` turns the motor's own error code into
547
+ text. The SDK logs through the standard `logging` module — the calibration it loaded, the
548
+ direction it derived, every guard that fired; attach a handler to the `litegrip` logger to see
549
+ them, and note that warnings reach stderr even with no handler configured.
550
+
551
+ A frame that fails to go out follows the table rather than one fixed message. The send reports
552
+ `False` and keeps the reason in `gripper.last_send_error`: a transport failure raises `CommError`
553
+ carrying that reason, and a gripper that is not connected or not enabled raises
554
+ `NotInitializedError` naming the missing step. When nothing recorded a reason — a coarse
555
+ `send_mit_frame` stub, say — the message says so instead of guessing.
556
+
557
+ ### Advanced
558
+
559
+ | Interface | What it is for |
560
+ | --- | --- |
561
+ | `gripper.actions` | The `GripperActions` engine behind the high-level methods — the layer to build another driver on. |
562
+ | `send_mit_frame(q, kp, kd, dq=0.0, tau=0.0)` | One raw MIT frame, when you want to compose the control law yourself. |
563
+ | `read_param(rid, timeout_s=0.5)` | Reads a motor register. |
564
+ | `enter_zero_gravity(duration=0.0)` / `exit_zero_gravity()` | Slack the motor by hand, then take it back. |
565
+ | `clamp_to_travel(config, q)` | Clamps an angle into the calibrated travel — the rule `open` and `close` emit under. |
566
+ | `limit_target`, `press_target`, `work_limit_target` | The target math behind `open`, `close` and the work-stroke limit. |
567
+ | `force_approach_terms(kp, kd, speed_rad_s, frame_interval, budget_nm, lead_ceiling_rad)` | The speed, damping and lead a force-carrying approach may spend. |
568
+ | `closing_travel_excursion_rad(config, q)` | How far past the closed-side limit a position is. |
569
+ | `CALIB_TEMPLATES`, `UnitConversion`, `GripperParams`, `GripperGeometry`, `ErrorCode` | Template paths, unit conversions, motor limits, geometry and alarm codes. |
570
+ | `litegrip.can` | Raw SocketCAN transport and the DM protocol codec, for multi-motor setups. |
571
+
572
+ `clamp_to_travel`, `limit_target`, `press_target`, `work_limit_target`, `force_approach_terms` and
573
+ `closing_travel_excursion_rad` are module-level functions — import them from `litegrip`. They take
574
+ the config as their first argument rather than being called on the gripper.
575
+
576
+ ## Troubleshooting
577
+
578
+ | Symptom | What it means |
579
+ | --- | --- |
580
+ | `CommandError` naming a calibration | No calibration loaded for this channel. Call `load_calibration()`. |
581
+ | `ConnectError`, or no status frame ever | `can0` is down, or the bitrate is not 1 Mbit/s. See step 2. |
582
+ | `HardwareError` from `enable()` | The motor never reported `err == 1`. Check power, then `clear_fault()`. |
583
+ | The jaws move the wrong way | The unit is reverse-mounted, not normal. Construct with `mount="reverse"`. |
584
+ | `close()` returns `ok=False` | The jaw parked further than `stop_tol` from the limit. The gap comes from `zero()` probing harder than `close()` presses, so raising `stop_lead_mm` barely moves it — check that the calibration describes the real travel, and raise `stop_tol` if the unit is genuinely parked on the stop. |
585
+ | `travel_guard` is set | The runaway watch tripped: the jaw walked past the closed-side stop while a force was held — an empty `grasp()` does this when the stop is not where the calibration says. Check the calibration describes the real travel, and that nothing has removed the stop. The jaws are now outside the calibrated travel: `disable()`, push them back clear of the stop by hand, then `enable()`, before commanding anything else. |
586
+ | A `can1` unit's `load_calibration()` returns `False` | A no-argument load reads the factory file, which declares `channel: can0`, so this unit has no implicit source. Give it a template, or name its own file with `path=` or `LITEGRIP_CALIB`. |
587
+ | The held force sags below the setpoint | You changed the hold gains, or something is back-driving the jaws faster than the feed-forward. `hold_kp` / `hold_kd` are deprecated and ignored. |
588
+ | `import litegrip` loads older code than the checkout you are reading | An installed copy does not update itself across `git pull`. Check `litegrip.__file__`, or `pip install -e .` in the checkout. |
589
+
590
+ ## Safety and limits
591
+
592
+ - **Load a calibration before any motion.** Until then every motion method raises `CommandError`.
593
+ This is the whole gate: on the placeholder defaults a `goto(40.0)` maps to `+0.61 rad`, past the
594
+ real closed stop, and would press there.
595
+ - **Keep hands and objects out of the travel range** during `open`, `close`, `grasp` and `zero()`.
596
+ `open` and `close` stop on the calibrated limit; `zero()` probes the real mechanical stops under
597
+ power.
598
+ - **A calibration that is wrong limits the travel; it cannot let the jaw run past it.** `open` and
599
+ `close` cap the emitted command at the calibrated extreme, so once the jaw is there the residual
600
+ error is zero and the steady pressing torque is zero.
601
+ - **A held force keeps pushing.** Nothing about a hold is position-bounded: the frames carry no
602
+ gains, so the jaws are compliant and the motor pushes with `force_n` wherever they end up. The
603
+ runaway watch is the only thing that stops it, and it is a backstop, not an operating limit.
604
+ - **`write_zero()` is irreversible.** It writes an encoder offset into the motor's flash.
605
+ - **Sustained pressing heats the coil.** `open` and `close` hold against the limit for the settle
606
+ phase every time, so check the coil temperature if they run back to back.
607
+ - **A non-finite value is refused, never clamped.** `goto`, `goto_rad`, `move_at_speed*` and
608
+ `grasp` raise `CommandError` for a NaN or ±inf argument, and the CAN boundary refuses any such
609
+ field of a frame before it can become bytes.
610
+ - **`disconnect()` disables the motor by default**, so a process that dies does not leave the motor
611
+ holding torque. Set `disable_on_disconnect=False` only when something else keeps the gripper
612
+ under control.
613
+
614
+ ## Examples
615
+
616
+ Every script below is runnable from the repository root and prints what it measured.
617
+
618
+ | Script | What it answers |
619
+ | --- | --- |
620
+ | `examples/stroke_test.py` | Does this unit work? Both ends of the travel, exit 0 on success. |
621
+ | `examples/grasp_test.py` | Does `grasp()` still do what it says — on clear jaws and on an object? |
622
+ | `examples/hold_force_test.py` | Does the held grip hold its force, or drift to a smaller one? |
623
+ | `examples/zero_closed.py` | Low-level CAN bench tool, one subcommand per operation: `status`, `release`, `goto`, `zero`, `open`, `close`, `enable`, `disable`. |
624
+ | `examples/teleop.py` | One end of a leader/follower link. |
625
+ | `examples/trajectory.py` | Hand-teach a motion, save it, play it back. |
626
+
627
+ The test suite is hardware-free: it drives the engine through a kinematic fake CAN in
628
+ `tests/fake_can.py`, so it runs anywhere without a gripper or a CAN interface.
629
+
630
+ ```bash
631
+ python3 -m unittest discover -s tests -t tests -v
632
+ ```
633
+
634
+ ## License
635
+
636
+ Copyright © 2026 NEXFORM ROBOTICS. Licensed under the [Apache License 2.0](https://github.com/nexform-tech/litegrip-python/blob/main/LICENSE).
@@ -0,0 +1,25 @@
1
+ litegrip/__init__.py,sha256=td31c0tK5Db1y6cvhfG3l_tIfzhwaUX3RYaS2eDxxsM,9346
2
+ litegrip/actions.py,sha256=aKc2rCloJKp9n0gLEz6PNQs6w9gz6c--6K-DR5i62YM,80278
3
+ litegrip/calibration_normal.json,sha256=rL9XGWD-aATZWUqE3MSXuQ2wR_Oz1L0EF78kJgQpnd0,209
4
+ litegrip/calibration_reverse.json,sha256=HVweCi6LVBy7QQGxHa5NeaJfoOAfnzPMmVtXXIiCE74,209
5
+ litegrip/constants.py,sha256=A1-gt-bEk4xsX00LJtE-0uQQu-Brz8LRIJOAVQfBpfg,6660
6
+ litegrip/exceptions.py,sha256=2fQtANhxat0lVbhhU_SD-6nTcA4r-NLCpfmd06TGe3U,1144
7
+ litegrip/factory_calibration.json,sha256=j4kbECLTXRPyfrApraDm2PlNRa1w7mnuhO6sQVQXSxU,345
8
+ litegrip/gripper.py,sha256=UNoD0xG0-U0KFbBOpy57leUfnm8xqWJ8wI9PEGNV-mc,120533
9
+ litegrip/models.py,sha256=UzBtvje9r6Rq6t_eqJz3fuI9XxGHTeY9xVn5lCCJ3ZM,7659
10
+ litegrip/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ litegrip/teleop.py,sha256=epCwCGnANFF9ySl5kCCNxp5LdOGvsNYBe2z7cSzKY4c,71737
12
+ litegrip/trajectory.py,sha256=n1m97jADWvyk6FdUQGA69g8D2mFwD0bg0o5Zh9Mrpgg,44081
13
+ litegrip/zenoh_link.py,sha256=t9NzlDhG0vJmUuw6B0B7WkXSNgxDH5EhiKIJAmuHDaY,16264
14
+ litegrip/can/__init__.py,sha256=MmK9W41RVx8tmwBMUbezlBbeMUC34pgsw6LveKQ8FKg,1019
15
+ litegrip/can/controller.py,sha256=E8kPcKn0vHOVyxGhrZiiECEKFoY5tUEBvr6-_rw3hMM,16343
16
+ litegrip/can/motor.py,sha256=rdcYK3rA3-yu4vWHA3sVM_DZY-rEeSGXGM5mLtff7AI,5753
17
+ litegrip/can/protocol.py,sha256=dt69WYOtVGSlmNv_Vxszt4YLkazUvl4dWs4S75wgZqg,11954
18
+ litegrip/can/transport.py,sha256=4-0XjPpguEaFVMVHsD5iLBTjgKN8Cb-X1MYTZJkvhPE,14188
19
+ litegrip/protocols/__init__.py,sha256=aregvFK6W76DGGrKCS1tccWsqWWCFBROXZPQ9CcPI1k,304
20
+ litegrip/protocols/can_bus.py,sha256=UhGhVMrgQ--hC-hZNvPscrT2sjdMX3rgspOdCeKXAnY,26383
21
+ litegrip_python-1.1.2.dist-info/licenses/LICENSE,sha256=yVuuHRzgI17MzTVgt3LsHvuX80innw--CmNPDCzO_iw,11358
22
+ litegrip_python-1.1.2.dist-info/METADATA,sha256=Gxp0sNKME4uVDASP5xKTy7hcuY84zqIKRoJVAJ4NPsY,39000
23
+ litegrip_python-1.1.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
24
+ litegrip_python-1.1.2.dist-info/top_level.txt,sha256=P4onDW9LkBEwiiQw4u1ui2fZSjQ4_XL5RZeCUh7RGoE,9
25
+ litegrip_python-1.1.2.dist-info/RECORD,,