Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,58 @@ its own fails loudly (`False`, then `CommandError` from the motions) instead of
silently adopting `can0`'s direction. Set `LITEGRIP_CALIB` to pin one explicit
path for every channel instead.

## Leader/follower teleoperation

Two grippers can be linked so one follows the other. The **master** (leader) motor goes slack —
you push its jaws by hand — and it publishes how far open it is at the loop rate. The **slave**
(follower) receives that and drives its own jaws to match. What travels over the wire is a
normalized opening in `[0, 1]`, not an angle, so the two ends do not need the same calibration,
mount, or zero point.

```python
from litegrip import LiteGrip

# Leader: publish this gripper's opening to the follower at 192.168.1.20.
with LiteGrip("can0") as master:
master.load_calibration()
master.enable()
master.teleop_start("master", host="192.168.1.20")

# Follower: bind, align to the first frame, then follow.
with LiteGrip("can0") as slave:
slave.load_calibration()
slave.enable()
slave.teleop_start("slave", host="0.0.0.0")
while True:
print(slave.teleop_status()) # frames, openness, loop_hz, stale, ...
```

`examples/teleop.py` runs one end from the command line:

```bash
# Machine A — the leader you push by hand:
python3 examples/teleop.py --mode master --channel can0 --host 192.168.1.20
# Machine B — the follower:
python3 examples/teleop.py --mode slave --channel can0 --host 0.0.0.0
```

Both ends must share `master_id` (default `master`) and be connected and enabled first. Teleop is
exclusive: the background loop owns the CAN I/O, so do not drive the gripper from the caller until
`teleop_stop()`. `teleop_start` returns the initial `teleop_status()` snapshot; `teleop_status()`
reports `active`, `mode`, `topic`, `frames`, `last_frame_age_ms`, `stale`, `openness`, `loop_hz`.

- **The transport is plain UDP**, with no authentication or encryption. Use it only on a trusted
network. Pass `transport=` a `TeleopTransport` to supply your own; an injected one is never closed
by the SDK.
- **A follower that loses the leader holds its position, it does not go slack.** After
`watchdog_s` (default `0.2`) without a fresh frame it keeps commanding its last target under the
follow gains, so `stale` goes true but the jaws stay put — and can hold whatever is between them.
- **The follower clamps the incoming opening to `[0, 1]`**, i.e. to its own calibrated travel, so a
bad frame cannot command it past a limit.
- **Stopping leaves the gripper holding**, not slack: the master leaves zero-gravity mode on
`teleop_stop()`, so its jaws hold under the configured gains.
- Follow gains default to `kp=100.0`, `kd=2.0`; override with `kp=` / `kd=`.

## The six actions

These are the supported entry points for moving the gripper. Each one verifies its own
Expand Down
129 changes: 129 additions & 0 deletions examples/teleop.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Run one end of a leader/follower gripper teleoperation link.

This is a runnable companion to the teleoperation section of the README. It is
meant to be started once per machine — one process per gripper:

# Machine A (the leader you push by hand):
python3 examples/teleop.py --mode master --channel can0 --host 192.168.1.20

# Machine B (the follower that copies it):
python3 examples/teleop.py --mode slave --channel can0 --host 0.0.0.0

Both ends must share ``--master-id``. The default transport is plain UDP on
``--port``; it carries no authentication or encryption, so keep it on a trusted
network. Press Ctrl+C on either end to stop; the gripper holds its position.

This script talks to real hardware. It does not detect an object in the jaws,
and the follower holds its position on a leader dropout rather than going
slack, so it can clamp whatever is between the fingers. Keep a hand on the
power switch.
"""

from __future__ import annotations

import argparse
import sys
import time

from litegrip import LiteGrip, LiteGripError


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Run one end of a LiteGrip leader/follower teleop link.")
parser.add_argument(
"--mode", required=True, choices=("master", "slave"),
help="master = the leader you push by hand; slave = the follower")
parser.add_argument(
"--channel", default="can0", help="CAN interface (default: can0)")
parser.add_argument(
"--can-id", type=lambda s: int(s, 0), default=0x08,
help="motor CAN ID (default: 0x08)")
parser.add_argument(
"--host", required=True,
help="master: the follower's address; slave: the local bind address")
parser.add_argument(
"--port", type=int, default=7448, help="UDP port (default: 7448)")
parser.add_argument(
"--master-id", default="master",
help="topic id both ends must agree on (default: master)")
parser.add_argument(
"--mount", choices=("normal", "reverse"), default=None,
help="load a mount template instead of this channel's calibration")
parser.add_argument(
"--kp", type=float, default=None,
help="follower stiffness (default: 100.0)")
parser.add_argument(
"--kd", type=float, default=None,
help="follower damping (default: 2.0)")
parser.add_argument(
"--no-align", action="store_true",
help="follower: skip the one-shot align to the first frame")
parser.add_argument(
"--watchdog", type=float, default=0.2,
help="follower: hold position after this many seconds without a "
"fresh frame (default: 0.2)")
parser.add_argument(
"--rate", type=float, default=50.0, help="loop rate in Hz (default: 50)")
parser.add_argument(
"--dry-run", action="store_true",
help="print the resolved plan and exit without touching hardware")
return parser


def _print_status(status: dict) -> None:
age = status.get("last_frame_age_ms")
age_txt = "-" if age is None else f"{age:6.1f}"
openness = status.get("openness")
open_txt = "-" if openness is None else f"{openness:5.3f}"
print(f"frames={status.get('frames', 0):>7} "
f"age_ms={age_txt} stale={str(status.get('stale', False)):>5} "
f"openness={open_txt} loop_hz={status.get('loop_hz', 0.0):4.1f}",
flush=True)


def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)

gripper = LiteGrip(channel=args.channel, can_id=args.can_id)
if args.mount is not None:
gripper.load_calibration(template=args.mount)
else:
gripper.load_calibration()
print(f"mount={gripper.mount} closed={gripper.config.pos_closed_rad:+.4f} "
f"open={gripper.config.pos_open_rad:+.4f} rad_to_mm={gripper.config.rad_to_mm}")

if args.dry_run:
print(f"dry run: would start {args.mode} on {args.channel} at "
f"{args.host}:{args.port} (topic litegrip/teleop/{args.master_id})")
return 0

gripper.connect()
gripper.enable()
status = gripper.teleop_start(
args.mode, host=args.host, port=args.port,
kp=args.kp, kd=args.kd, align=not args.no_align,
watchdog_s=args.watchdog, rate_hz=args.rate,
master_id=args.master_id)
print(f"teleop {args.mode} running; Ctrl+C to stop")
_print_status(status)

try:
while True:
time.sleep(1.0)
_print_status(gripper.teleop_status())
except KeyboardInterrupt:
print("\nstopping")
finally:
gripper.teleop_stop()
gripper.disconnect()
return 0


if __name__ == "__main__":
try:
sys.exit(main())
except LiteGripError as error:
print(f"error: {error}", file=sys.stderr)
sys.exit(1)
47 changes: 47 additions & 0 deletions readme_zn.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,53 @@ with LiteGrip("can1", mount="reverse") as gripper:
`False`,随后运动接口抛 `CommandError`),而不是悄悄采纳 `can0` 的方向。想让所有通道共用一个
显式路径,设 `LITEGRIP_CALIB`。

## 主从遥操

两台夹爪可以联动,一台跟着另一台动。**主夹爪**(leader)的电机卸力 —— 你用手掰它的爪子,
它按循环频率把「张开程度」发出去;**从夹爪**(follower)收到后驱动自己的爪子跟到位。线上传的
是归一化到 `[0, 1]` 的张开度,不是角度,所以两端不需要相同的标定、装法或零点。

```python
from litegrip import LiteGrip

# 主端:把本夹爪的张开度发到 192.168.1.20 的从端。
with LiteGrip("can0") as master:
master.load_calibration()
master.enable()
master.teleop_start("master", host="192.168.1.20")

# 从端:绑定端口,先对齐首帧,然后跟随。
with LiteGrip("can0") as slave:
slave.load_calibration()
slave.enable()
slave.teleop_start("slave", host="0.0.0.0")
while True:
print(slave.teleop_status()) # frames, openness, loop_hz, stale, ...
```

`examples/teleop.py` 可以在命令行跑其中一端:

```bash
# A 机 —— 你用手掰的主夹爪:
python3 examples/teleop.py --mode master --channel can0 --host 192.168.1.20
# B 机 —— 从夹爪:
python3 examples/teleop.py --mode slave --channel can0 --host 0.0.0.0
```

两端必须共用 `master_id`(默认 `master`),且都已连接、已使能。遥操是互斥的:后台循环独占 CAN
读写,在 `teleop_stop()` 之前不要再从调用方驱动夹爪。`teleop_start` 返回初始的
`teleop_status()`;`teleop_status()` 报告 `active`、`mode`、`topic`、`frames`、
`last_frame_age_ms`、`stale`、`openness`、`loop_hz`。

- **传输是明文 UDP**,无鉴权、无加密,只用在可信网络里。要给自定义传输,传 `transport=` 一个
`TeleopTransport`;注入的传输不会被 SDK 关闭。
- **从端与主端失联时是「持位」,不是「卸力」。** 超过 `watchdog_s`(默认 `0.2`)没有新帧后,
它仍按跟随增益顶着上一个目标继续发帧 —— 于是 `stale` 变真,但爪子停在原地,可能夹住中间的
东西。
- **从端会把收到的张开度夹到 `[0, 1]`**,也就是夹在自己的标定行程内,坏帧无法把它指到限位之外。
- **停止后是持位**,不是卸力:主端在 `teleop_stop()` 时退出零重力模式,爪子按配置增益持位。
- 跟随增益默认 `kp=100.0`、`kd=2.0`,用 `kp=` / `kd=` 覆盖。

## 六个动作接口

要让夹爪动起来就用这六个。每一个都会自己校验结果再报成功,所以调用方不必再重写斜坡和
Expand Down
29 changes: 29 additions & 0 deletions src/litegrip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ def _detect_version(dist_name: str = "litegrip") -> str:
NotInitializedError,
)

# ── Teleoperation (leader / follower) ───────────────────────────────────
from .teleop import (
GripperTeleop,
TeleopTransport,
TeleopSubscription,
UdpTeleopTransport,
InProcTeleopTransport,
TeleopError,
TeleopBusyError,
TeleopNotActiveError,
FRAME_SIZE,
encode_frame,
decode_frame,
teleop_topic,
)

# ── CAN subpackage (expert) ─────────────────────────────────────────────
from . import can

Expand Down Expand Up @@ -150,6 +166,19 @@ def _detect_version(dist_name: str = "litegrip") -> str:
"CANTimeoutError",
"HardwareError",
"NotInitializedError",
# Teleoperation
"GripperTeleop",
"TeleopTransport",
"TeleopSubscription",
"UdpTeleopTransport",
"InProcTeleopTransport",
"TeleopError",
"TeleopBusyError",
"TeleopNotActiveError",
"FRAME_SIZE",
"encode_frame",
"decode_frame",
"teleop_topic",
# Subpackages
"can",
]
Loading
Loading