fastgripper-dm 0.1.0__tar.gz
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.
- fastgripper_dm-0.1.0/.gitignore +12 -0
- fastgripper_dm-0.1.0/PKG-INFO +39 -0
- fastgripper_dm-0.1.0/README.md +26 -0
- fastgripper_dm-0.1.0/pyproject.toml +24 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/__init__.py +12 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/calstore.py +99 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/cli.py +252 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/controller.py +96 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/__init__.py +0 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/can_port.py +90 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/canbus.py +171 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/config_tool.py +125 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/dm4310.py +209 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/facade.py +227 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/port.py +60 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/profile.py +54 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/sim.py +96 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/tools/__init__.py +0 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/tools/_cli.py +36 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/tools/autocal.py +449 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/tools/bus_ping.py +98 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/tools/cal_doctor.py +140 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/tools/calibrate.py +231 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/tools/preflight.py +129 -0
- fastgripper_dm-0.1.0/src/fastgripper_dm/tracker.py +58 -0
- fastgripper_dm-0.1.0/tests/test_can_port.py +77 -0
- fastgripper_dm-0.1.0/tests/test_cli_verbs.py +17 -0
- fastgripper_dm-0.1.0/tests/test_controller.py +82 -0
- fastgripper_dm-0.1.0/tests/test_facade.py +78 -0
- fastgripper_dm-0.1.0/tests/test_harvest.py +180 -0
- fastgripper_dm-0.1.0/tests/test_port.py +21 -0
- fastgripper_dm-0.1.0/tests/test_profile.py +31 -0
- fastgripper_dm-0.1.0/tests/test_sim.py +61 -0
- fastgripper_dm-0.1.0/tests/test_tracker.py +47 -0
- fastgripper_dm-0.1.0/uv.lock +314 -0
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fastgripper-dm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastGripper worm-gear gripper driver for Damiao DM-J4310 actuators on a dedicated CAN channel
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: gs-usb>=0.3.0
|
|
8
|
+
Requires-Dist: python-can<4.6,>=4.0
|
|
9
|
+
Requires-Dist: pyusb>=1.2
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# fastgripper-dm
|
|
15
|
+
|
|
16
|
+
Driver, calibration and CLI for the FastGripper worm-gear parallel gripper
|
|
17
|
+
when its actuator is a Damiao DM-J4310 on a **dedicated CAN channel**
|
|
18
|
+
(standalone mode). Linux + SocketCAN is the shipped path; macOS + gs_usb is
|
|
19
|
+
supported for bench use.
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
pip install fastgripper-dm
|
|
23
|
+
|
|
24
|
+
# one-time: give the motor its identity (motor ALONE on the bus) and its watchdog
|
|
25
|
+
fastgripper-dm id --interface socketcan --channel can0 --new_id 0x07
|
|
26
|
+
fastgripper-dm setup --interface socketcan --channel can0 --motor_id 0x07 --master_id 0x17
|
|
27
|
+
|
|
28
|
+
# every session
|
|
29
|
+
fastgripper-dm preflight # no motion: bus, motor, fault, watchdog, calibration
|
|
30
|
+
fastgripper-dm calibrate # keyboard jog, mark open/closed (once per unit)
|
|
31
|
+
fastgripper-dm autocal home # re-anchor against the closed stop (~20 s)
|
|
32
|
+
fastgripper-dm drive 40 # 40 % and exit
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Docs: `docs/fastgripper-dm/` at the repo root (quickstart-linux, quickstart-mac, troubleshooting).
|
|
36
|
+
|
|
37
|
+
Safety: the `setup` step writes a comm-loss watchdog into the motor so a
|
|
38
|
+
crashed host cannot leave the worm pushing into a stop. `preflight` refuses to
|
|
39
|
+
pass with the watchdog disabled.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# fastgripper-dm
|
|
2
|
+
|
|
3
|
+
Driver, calibration and CLI for the FastGripper worm-gear parallel gripper
|
|
4
|
+
when its actuator is a Damiao DM-J4310 on a **dedicated CAN channel**
|
|
5
|
+
(standalone mode). Linux + SocketCAN is the shipped path; macOS + gs_usb is
|
|
6
|
+
supported for bench use.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
pip install fastgripper-dm
|
|
10
|
+
|
|
11
|
+
# one-time: give the motor its identity (motor ALONE on the bus) and its watchdog
|
|
12
|
+
fastgripper-dm id --interface socketcan --channel can0 --new_id 0x07
|
|
13
|
+
fastgripper-dm setup --interface socketcan --channel can0 --motor_id 0x07 --master_id 0x17
|
|
14
|
+
|
|
15
|
+
# every session
|
|
16
|
+
fastgripper-dm preflight # no motion: bus, motor, fault, watchdog, calibration
|
|
17
|
+
fastgripper-dm calibrate # keyboard jog, mark open/closed (once per unit)
|
|
18
|
+
fastgripper-dm autocal home # re-anchor against the closed stop (~20 s)
|
|
19
|
+
fastgripper-dm drive 40 # 40 % and exit
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Docs: `docs/fastgripper-dm/` at the repo root (quickstart-linux, quickstart-mac, troubleshooting).
|
|
23
|
+
|
|
24
|
+
Safety: the `setup` step writes a comm-loss watchdog into the motor so a
|
|
25
|
+
crashed host cannot leave the worm pushing into a stop. `preflight` refuses to
|
|
26
|
+
pass with the watchdog disabled.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastgripper-dm"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "FastGripper worm-gear gripper driver for Damiao DM-J4310 actuators on a dedicated CAN channel"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "Apache-2.0" }
|
|
8
|
+
dependencies = ["python-can>=4.0,<4.6", "pyusb>=1.2", "gs_usb>=0.3.0"]
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
dev = ["pytest>=8"]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
fastgripper-dm = "fastgripper_dm.cli:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/fastgripper_dm"]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""fastgripper-dm: FastGripper worm-gear gripper on a Damiao DM-J4310 over CAN.
|
|
2
|
+
|
|
3
|
+
v0.0.x is the "harvest" release: the hardware-validated bench tools under one
|
|
4
|
+
package and one CLI. The controller/port refactor (see the design spec) lands
|
|
5
|
+
in v0.1.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .facade import FastGripper, HomingError
|
|
9
|
+
from .profile import GripperProfile, PRESETS, TMAX_CAP
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__all__ = ["FastGripper", "HomingError", "GripperProfile", "PRESETS", "TMAX_CAP", "__version__"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Multi-gripper calibration store.
|
|
2
|
+
|
|
3
|
+
Format v2 — one gripper_cal.json, named entries, each self-describing:
|
|
4
|
+
|
|
5
|
+
{
|
|
6
|
+
"format": 2,
|
|
7
|
+
"grippers": {
|
|
8
|
+
"right": {
|
|
9
|
+
"motor_id": 32, "master_id": 48,
|
|
10
|
+
"open": ..., "closed": ..., "span": ...,
|
|
11
|
+
"last_position": ..., "last_wrapped": ...,
|
|
12
|
+
"stop_open": ..., "stop_closed": ..., "stop_span": ...,
|
|
13
|
+
"calibrated_at": "..."
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
Legacy flat single-gripper files (open/closed at top level) load as an entry
|
|
19
|
+
named "default" and are upgraded to v2 on the next save. Tools select an
|
|
20
|
+
entry with --gripper <name>; when the file has exactly one entry the name is
|
|
21
|
+
optional.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def config_home() -> str:
|
|
29
|
+
"""$FASTGRIPPER_DM_HOME, else ~/.config/fastgripper-dm. Never the cwd: a
|
|
30
|
+
stale gripper_cal.json in the working directory silently shadowing the
|
|
31
|
+
real calibration would drive goto targets toward the wrong stops."""
|
|
32
|
+
return os.environ.get("FASTGRIPPER_DM_HOME") or os.path.join(
|
|
33
|
+
os.path.expanduser("~"), ".config", "fastgripper-dm")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def default_cal_path() -> str:
|
|
37
|
+
return os.path.join(config_home(), "gripper_cal.json")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def default_config_path() -> str:
|
|
41
|
+
return os.path.join(config_home(), "config.json")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_store(path: str) -> dict:
|
|
45
|
+
"""Load a cal store, migrating legacy flat files in memory."""
|
|
46
|
+
try:
|
|
47
|
+
raw = json.load(open(path))
|
|
48
|
+
except OSError:
|
|
49
|
+
return {"format": 2, "grippers": {}}
|
|
50
|
+
if "grippers" in raw:
|
|
51
|
+
return raw
|
|
52
|
+
# legacy flat single-gripper file
|
|
53
|
+
return {"format": 2, "grippers": {"default": raw}}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def save_store(path: str, store: dict) -> None:
|
|
57
|
+
"""Atomic write: a crash mid-save must not destroy the only calibration."""
|
|
58
|
+
store["format"] = 2
|
|
59
|
+
parent = os.path.dirname(os.path.abspath(path))
|
|
60
|
+
os.makedirs(parent, exist_ok=True)
|
|
61
|
+
tmp = path + ".tmp"
|
|
62
|
+
with open(tmp, "w") as f:
|
|
63
|
+
json.dump(store, f, indent=2)
|
|
64
|
+
f.flush()
|
|
65
|
+
os.fsync(f.fileno())
|
|
66
|
+
os.replace(tmp, path)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_entry(store: dict, name: str | None = None) -> tuple[str, dict]:
|
|
70
|
+
"""Resolve (name, entry). Name optional iff exactly one entry exists."""
|
|
71
|
+
g = store["grippers"]
|
|
72
|
+
if name is not None:
|
|
73
|
+
if name not in g:
|
|
74
|
+
raise SystemExit(f"no gripper named '{name}' in the cal file — "
|
|
75
|
+
f"have: {', '.join(sorted(g)) or '(none)'}")
|
|
76
|
+
return name, g[name]
|
|
77
|
+
if len(g) == 1:
|
|
78
|
+
return next(iter(g.items()))
|
|
79
|
+
if not g:
|
|
80
|
+
raise SystemExit("cal file has no gripper entries — calibrate first "
|
|
81
|
+
"(calibrate.py or autocal.py full)")
|
|
82
|
+
raise SystemExit(f"cal file has multiple grippers ({', '.join(sorted(g))}) — "
|
|
83
|
+
f"pick one with --gripper <name>")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def resolve_ids(args, entry: dict) -> tuple[int, int]:
|
|
87
|
+
"""Motor IDs: the entry's own IDs win unless the CLI overrides them.
|
|
88
|
+
(add_bus_args defaults are 0x01/0x00 — treated as 'not specified'.)"""
|
|
89
|
+
cli_set = not (args.motor_id == 0x01 and args.master_id == 0x00)
|
|
90
|
+
if cli_set or "motor_id" not in entry:
|
|
91
|
+
return args.motor_id, args.master_id
|
|
92
|
+
return int(entry["motor_id"]), int(entry["master_id"])
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def entry_profile(entry: dict):
|
|
96
|
+
"""The entry's profile block merged over package defaults."""
|
|
97
|
+
from .profile import GripperProfile
|
|
98
|
+
|
|
99
|
+
return GripperProfile.from_dict(entry.get("profile", {}) or {})
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""fastgripper-dm — command-line front door.
|
|
2
|
+
|
|
3
|
+
fastgripper-dm setup save interface/channel (+ motor ids) and write the motor watchdog
|
|
4
|
+
fastgripper-dm preflight no-motion go/no-go: bus echo, motor answers, fault, watchdog, cal entry
|
|
5
|
+
fastgripper-dm doctor bus ping with fault decode (which motors are alive?)
|
|
6
|
+
fastgripper-dm id re-ID a factory motor (must be alone on the bus) / --verify
|
|
7
|
+
fastgripper-dm watchdog read or set the comm-loss watchdog (ms)
|
|
8
|
+
fastgripper-dm calibrate keyboard jog, mark open/closed (real terminal)
|
|
9
|
+
fastgripper-dm autocal full|home|touch hardstop calibration
|
|
10
|
+
fastgripper-dm cal-doctor turn-alias diagnosis of the saved calibration
|
|
11
|
+
fastgripper-dm open open fully (torque-capped)
|
|
12
|
+
fastgripper-dm close close fully (torque-capped)
|
|
13
|
+
fastgripper-dm goto go to a percentage open (0 = closed, 100 = open)
|
|
14
|
+
fastgripper-dm drive alias for `goto`
|
|
15
|
+
fastgripper-dm home stall-home against the closed stop
|
|
16
|
+
fastgripper-dm status print entry marks and current position (no motion)
|
|
17
|
+
|
|
18
|
+
Saved settings live in $FASTGRIPPER_DM_HOME or ~/.config/fastgripper-dm/
|
|
19
|
+
(config.json + gripper_cal.json). The tools take --interface/--channel/
|
|
20
|
+
--motor_id/--master_id explicitly; `setup` records them so you can omit them.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
from .calstore import default_config_path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _load_config() -> dict:
|
|
34
|
+
p = default_config_path()
|
|
35
|
+
if os.path.exists(p):
|
|
36
|
+
return json.load(open(p))
|
|
37
|
+
return {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _save_config(cfg: dict) -> None:
|
|
41
|
+
p = default_config_path()
|
|
42
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
43
|
+
tmp = p + ".tmp"
|
|
44
|
+
with open(tmp, "w") as f:
|
|
45
|
+
json.dump(cfg, f, indent=2)
|
|
46
|
+
os.replace(tmp, p)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _bus_argv(cfg: dict, extra: list[str]) -> list[str]:
|
|
50
|
+
"""Prefix saved bus/motor settings unless the caller passed them explicitly."""
|
|
51
|
+
out: list[str] = []
|
|
52
|
+
for key, flag in (("interface", "--interface"), ("channel", "--channel"),
|
|
53
|
+
("motor_id", "--motor_id"), ("master_id", "--master_id")):
|
|
54
|
+
if key in cfg and flag not in extra:
|
|
55
|
+
out += [flag, str(cfg[key])]
|
|
56
|
+
return out + extra
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _run_tool(tool_main, argv: list[str], prog: str) -> None:
|
|
60
|
+
from .tools._cli import run
|
|
61
|
+
|
|
62
|
+
sys.argv = [prog] + argv
|
|
63
|
+
run(tool_main)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_setup(args, extra):
|
|
67
|
+
from .damiao.canbus import open_bus
|
|
68
|
+
from .damiao.config_tool import RegisterClient, read_watchdog_ms, set_watchdog_ms
|
|
69
|
+
|
|
70
|
+
cfg = _load_config()
|
|
71
|
+
for key in ("interface", "channel", "motor_id", "master_id"):
|
|
72
|
+
v = getattr(args, key)
|
|
73
|
+
if v is not None:
|
|
74
|
+
cfg[key] = v
|
|
75
|
+
if "interface" not in cfg:
|
|
76
|
+
raise SystemExit("setup needs --interface (socketcan|gs_usb|slcan) and --channel")
|
|
77
|
+
cfg.setdefault("motor_id", 0x01)
|
|
78
|
+
cfg.setdefault("master_id", 0x00)
|
|
79
|
+
cfg["watchdog_ms"] = args.watchdog_ms
|
|
80
|
+
_save_config(cfg)
|
|
81
|
+
print(f"saved {default_config_path()}: {cfg}")
|
|
82
|
+
with open_bus(cfg["interface"], cfg.get("channel")) as bus:
|
|
83
|
+
c = RegisterClient(bus)
|
|
84
|
+
mid = int(cfg["motor_id"])
|
|
85
|
+
if not c.probe(mid):
|
|
86
|
+
raise SystemExit(f"motor 0x{mid:02X} does not answer -- check power/wiring, or run `fastgripper-dm id`")
|
|
87
|
+
before = read_watchdog_ms(c, mid)
|
|
88
|
+
got = before if before == args.watchdog_ms else set_watchdog_ms(c, mid, args.watchdog_ms)
|
|
89
|
+
print(f"motor 0x{mid:02X} watchdog: {before} -> {got} ms (verified)")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_watchdog(args, extra):
|
|
93
|
+
from .damiao.canbus import open_bus
|
|
94
|
+
from .damiao.config_tool import RegisterClient, read_watchdog_ms, set_watchdog_ms
|
|
95
|
+
|
|
96
|
+
cfg = _load_config()
|
|
97
|
+
mid = int(args.motor_id if args.motor_id is not None else cfg.get("motor_id", 0x01))
|
|
98
|
+
with open_bus(args.interface or cfg.get("interface", "auto"), args.channel or cfg.get("channel")) as bus:
|
|
99
|
+
c = RegisterClient(bus)
|
|
100
|
+
if args.set is None:
|
|
101
|
+
print(f"motor 0x{mid:02X} watchdog = {read_watchdog_ms(c, mid)} ms")
|
|
102
|
+
else:
|
|
103
|
+
print(f"motor 0x{mid:02X} watchdog = {set_watchdog_ms(c, mid, args.set)} ms (verified)")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_id(args, extra):
|
|
107
|
+
from .damiao.canbus import open_bus
|
|
108
|
+
from .damiao.config_tool import RegisterClient, set_motor_id
|
|
109
|
+
|
|
110
|
+
cfg = _load_config()
|
|
111
|
+
with open_bus(args.interface or cfg.get("interface", "auto"), args.channel or cfg.get("channel")) as bus:
|
|
112
|
+
c = RegisterClient(bus)
|
|
113
|
+
if args.verify:
|
|
114
|
+
for reg in ("id", "master_id", "timeout", "sw_ver"):
|
|
115
|
+
print(f"motor 0x{args.new_id:02X} {reg} = {c.read(args.new_id, reg)}")
|
|
116
|
+
return
|
|
117
|
+
got_id, got_master = set_motor_id(c, args.old_id, args.new_id, args.master_id)
|
|
118
|
+
print(f"SUCCESS: motor is now 0x{got_id:02X} / feedback 0x{got_master:02X}")
|
|
119
|
+
cfg["motor_id"], cfg["master_id"] = got_id, got_master
|
|
120
|
+
_save_config(cfg)
|
|
121
|
+
print(f"saved to {default_config_path()}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_preflight(args, extra):
|
|
125
|
+
from .tools.preflight import run_preflight
|
|
126
|
+
|
|
127
|
+
cfg = _load_config()
|
|
128
|
+
ok = run_preflight(cfg, interface=args.interface, channel=args.channel)
|
|
129
|
+
raise SystemExit(0 if ok else 1)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_motion(args, extra):
|
|
133
|
+
from .facade import FastGripper
|
|
134
|
+
from .tools._cli import run
|
|
135
|
+
|
|
136
|
+
frac = {"open": 1.0, "close": 0.0}.get(args.cmd)
|
|
137
|
+
if frac is None:
|
|
138
|
+
frac = args.pct / 100.0
|
|
139
|
+
|
|
140
|
+
def go():
|
|
141
|
+
with FastGripper.standalone(gripper=args.gripper, cal_path=args.cal) as g:
|
|
142
|
+
g.goto(frac)
|
|
143
|
+
g.wait(timeout=15.0)
|
|
144
|
+
print(f"at {g.position:+.2f} rad" + (" (stalled/holding)" if g.stalled else ""))
|
|
145
|
+
run(go)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_home(args, extra):
|
|
149
|
+
from .facade import FastGripper
|
|
150
|
+
from .tools._cli import run
|
|
151
|
+
|
|
152
|
+
def go():
|
|
153
|
+
with FastGripper.standalone(gripper=args.gripper, cal_path=args.cal, home="stall") as g:
|
|
154
|
+
print(f"homed; at {g.position:+.2f} rad")
|
|
155
|
+
run(go)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_status(args, extra):
|
|
159
|
+
from .facade import FastGripper
|
|
160
|
+
from .tools._cli import run
|
|
161
|
+
|
|
162
|
+
def go():
|
|
163
|
+
g = FastGripper.standalone(gripper=args.gripper, cal_path=args.cal, home="off")
|
|
164
|
+
g.connect()
|
|
165
|
+
e = g._entry
|
|
166
|
+
pos = g.position
|
|
167
|
+
pct = 100.0 * (pos - e["closed"]) / (e["open"] - e["closed"]) if pos is not None and e["open"] != e["closed"] else None
|
|
168
|
+
print(f"entry '{g._gripper_name}': open {e['open']:+.2f} closed {e['closed']:+.2f} rad")
|
|
169
|
+
print(f"position (this window): {pos:+.2f} rad" + (f" ~ {pct:.0f}% open" if pct is not None else ""))
|
|
170
|
+
print("NOTE: 'off' mode has no absolute frame; use preflight/park for trusted state")
|
|
171
|
+
g.port.disable()
|
|
172
|
+
g.port.close()
|
|
173
|
+
run(go)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main() -> None:
|
|
177
|
+
parser = argparse.ArgumentParser(prog="fastgripper-dm", description=__doc__,
|
|
178
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
179
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
180
|
+
hexint = lambda s: int(s, 0) # noqa: E731
|
|
181
|
+
|
|
182
|
+
p = sub.add_parser("setup", help="save bus/motor settings and write the motor watchdog")
|
|
183
|
+
p.add_argument("--interface", choices=["gs_usb", "slcan", "socketcan"])
|
|
184
|
+
p.add_argument("--channel")
|
|
185
|
+
p.add_argument("--motor_id", type=hexint)
|
|
186
|
+
p.add_argument("--master_id", type=hexint)
|
|
187
|
+
p.add_argument("--watchdog_ms", type=int, default=8000, help="RID 9 value; unit under investigation (8000 = the arm motors' proven setting)")
|
|
188
|
+
|
|
189
|
+
p = sub.add_parser("preflight", help="no-motion go/no-go")
|
|
190
|
+
p.add_argument("--interface"); p.add_argument("--channel")
|
|
191
|
+
|
|
192
|
+
p = sub.add_parser("watchdog", help="read/set the comm-loss watchdog (ms)")
|
|
193
|
+
p.add_argument("--set", type=int, default=None)
|
|
194
|
+
p.add_argument("--interface"); p.add_argument("--channel"); p.add_argument("--motor_id", type=hexint)
|
|
195
|
+
|
|
196
|
+
p = sub.add_parser("id", help="re-ID a factory motor (motor ALONE on the bus)")
|
|
197
|
+
p.add_argument("--old_id", type=hexint, default=0x01)
|
|
198
|
+
p.add_argument("--new_id", type=hexint, default=0x07)
|
|
199
|
+
p.add_argument("--master_id", type=hexint, default=None, help="default new_id + 0x10")
|
|
200
|
+
p.add_argument("--verify", action="store_true")
|
|
201
|
+
p.add_argument("--interface"); p.add_argument("--channel")
|
|
202
|
+
|
|
203
|
+
for name in ("doctor", "calibrate", "autocal", "cal-doctor"):
|
|
204
|
+
sub.add_parser(name, help=f"run `{name}` (pass its own flags after the name; -h for help)")
|
|
205
|
+
|
|
206
|
+
for name in ("open", "close"):
|
|
207
|
+
p = sub.add_parser(name, help=f"{name} fully (torque-capped)")
|
|
208
|
+
p.add_argument("--gripper", default=None)
|
|
209
|
+
p.add_argument("--cal", default=None, help="cal store path override")
|
|
210
|
+
for name in ("goto", "drive"):
|
|
211
|
+
p = sub.add_parser(name, help="go to a percentage open (0 = closed, 100 = open)")
|
|
212
|
+
p.add_argument("pct", type=float)
|
|
213
|
+
p.add_argument("--gripper", default=None)
|
|
214
|
+
p.add_argument("--cal", default=None, help="cal store path override")
|
|
215
|
+
p = sub.add_parser("home", help="stall-home against the closed stop, then hold just off it")
|
|
216
|
+
p.add_argument("--gripper", default=None)
|
|
217
|
+
p.add_argument("--cal", default=None, help="cal store path override")
|
|
218
|
+
p = sub.add_parser("status", help="print entry marks and current position (no motion; briefly energizes the motor with zero gains)")
|
|
219
|
+
p.add_argument("--gripper", default=None)
|
|
220
|
+
p.add_argument("--cal", default=None, help="cal store path override")
|
|
221
|
+
|
|
222
|
+
args, extra = parser.parse_known_args()
|
|
223
|
+
if args.cmd == "setup":
|
|
224
|
+
return cmd_setup(args, extra)
|
|
225
|
+
if args.cmd == "preflight":
|
|
226
|
+
return cmd_preflight(args, extra)
|
|
227
|
+
if args.cmd == "watchdog":
|
|
228
|
+
return cmd_watchdog(args, extra)
|
|
229
|
+
if args.cmd == "id":
|
|
230
|
+
return cmd_id(args, extra)
|
|
231
|
+
if args.cmd in ("open", "close", "goto", "drive"):
|
|
232
|
+
return cmd_motion(args, extra)
|
|
233
|
+
if args.cmd == "home":
|
|
234
|
+
return cmd_home(args, extra)
|
|
235
|
+
if args.cmd == "status":
|
|
236
|
+
return cmd_status(args, extra)
|
|
237
|
+
|
|
238
|
+
cfg = _load_config()
|
|
239
|
+
argv = _bus_argv(cfg, extra)
|
|
240
|
+
if args.cmd == "doctor":
|
|
241
|
+
from .tools.bus_ping import main as tool
|
|
242
|
+
elif args.cmd == "calibrate":
|
|
243
|
+
from .tools.calibrate import main as tool
|
|
244
|
+
elif args.cmd == "autocal":
|
|
245
|
+
from .tools.autocal import main as tool
|
|
246
|
+
else:
|
|
247
|
+
from .tools.cal_doctor import main as tool
|
|
248
|
+
_run_tool(tool, argv, f"fastgripper-dm {args.cmd}")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
main()
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The gripper control algorithm, once, with no I/O.
|
|
2
|
+
|
|
3
|
+
Ported from so101_teleop.py v7 (the hardware-validated version with the
|
|
4
|
+
torque cap): software P velocity loop + per-tick torque clamp + stall hold.
|
|
5
|
+
The owner of the bus loops `fb = port.command(controller.tick(fb, dt))`
|
|
6
|
+
(standalone) or merges the returned command into its own array (adapters)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .port import Feedback, MitCommand
|
|
11
|
+
from .profile import GripperProfile
|
|
12
|
+
from .tracker import MultiTurnTracker
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GripperController:
|
|
16
|
+
def __init__(self, entry: dict, profile: GripperProfile):
|
|
17
|
+
profile.validate()
|
|
18
|
+
if "open" not in entry or "closed" not in entry:
|
|
19
|
+
raise ValueError("cal entry needs open/closed marks -- calibrate first")
|
|
20
|
+
self.entry = entry
|
|
21
|
+
self.profile = profile
|
|
22
|
+
self.kd = profile.tmax_nm / profile.vmax
|
|
23
|
+
self._lo = min(entry["open"], entry["closed"])
|
|
24
|
+
self._hi = max(entry["open"], entry["closed"])
|
|
25
|
+
self.tracker = MultiTurnTracker()
|
|
26
|
+
self.goal: float | None = None
|
|
27
|
+
self.stalled = False
|
|
28
|
+
self._stall_t = 0.0
|
|
29
|
+
|
|
30
|
+
# --- goals ---
|
|
31
|
+
def goto_rad(self, pos: float) -> None:
|
|
32
|
+
self.goal = min(self._hi, max(self._lo, pos))
|
|
33
|
+
self.stalled = False
|
|
34
|
+
self._stall_t = 0.0
|
|
35
|
+
|
|
36
|
+
def goto_frac(self, frac: float) -> None:
|
|
37
|
+
frac = min(1.0, max(0.0, frac))
|
|
38
|
+
self.goto_rad(self.entry["closed"] + frac * (self.entry["open"] - self.entry["closed"]))
|
|
39
|
+
|
|
40
|
+
def open(self) -> None:
|
|
41
|
+
self.goto_frac(1.0)
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
self.goto_frac(0.0)
|
|
45
|
+
|
|
46
|
+
def hold(self) -> None:
|
|
47
|
+
if self.position is not None:
|
|
48
|
+
self.goal = self.position
|
|
49
|
+
|
|
50
|
+
# --- state ---
|
|
51
|
+
@property
|
|
52
|
+
def position(self) -> float | None:
|
|
53
|
+
return self.tracker.position if self.tracker.seen else None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def at_goal(self) -> bool:
|
|
57
|
+
return (self.goal is not None and self.position is not None
|
|
58
|
+
and abs(self.goal - self.position) < 0.05)
|
|
59
|
+
|
|
60
|
+
def adopt_park(self, last_position: float) -> None:
|
|
61
|
+
self.tracker = MultiTurnTracker(start_unwrapped=last_position)
|
|
62
|
+
|
|
63
|
+
def anchor(self, known_position: float) -> None:
|
|
64
|
+
self.tracker.anchor(known_position)
|
|
65
|
+
|
|
66
|
+
def park_fields(self) -> dict:
|
|
67
|
+
return {"last_position": self.tracker.position, "last_wrapped": self.tracker.wrapped}
|
|
68
|
+
|
|
69
|
+
# --- the tick ---
|
|
70
|
+
def tick(self, fb: Feedback, dt: float) -> MitCommand:
|
|
71
|
+
if fb.faulted:
|
|
72
|
+
return MitCommand()
|
|
73
|
+
pos = self.tracker.update(fb.position)
|
|
74
|
+
if self.goal is None or self.stalled:
|
|
75
|
+
return MitCommand(kd=self.kd) # damp to zero velocity (hold)
|
|
76
|
+
p = self.profile
|
|
77
|
+
v = p.sw_kp * (self.goal - pos)
|
|
78
|
+
v = min(p.vmax, max(-p.vmax, v))
|
|
79
|
+
dv = p.tmax_nm / self.kd # = vmax by construction
|
|
80
|
+
# Clamps against fb.velocity, which is one integration step stale relative
|
|
81
|
+
# to the velocity the motor (or sim) uses to compute the torque it reports
|
|
82
|
+
# next tick -- the <= tmax bound is enforced by the motor's own kd law
|
|
83
|
+
# (tau = kd*(v_cmd - v_actual), a self-correcting relationship), not by
|
|
84
|
+
# strict per-tick algebra here, and is verified empirically in tests.
|
|
85
|
+
# Revisit this comment if vmax, sw_kp, or the loop dt change materially.
|
|
86
|
+
v = min(fb.velocity + dv, max(fb.velocity - dv, v))
|
|
87
|
+
# stall detection on MEASURED torque (current sense), like the bench code
|
|
88
|
+
if abs(fb.torque) > p.stall_torque_frac * p.tmax_nm and not self.at_goal:
|
|
89
|
+
self._stall_t += dt
|
|
90
|
+
if self._stall_t > p.stall_time_s:
|
|
91
|
+
self.stalled = True
|
|
92
|
+
self.goal = pos
|
|
93
|
+
return MitCommand(kd=self.kd)
|
|
94
|
+
else:
|
|
95
|
+
self._stall_t = 0.0
|
|
96
|
+
return MitCommand(vel=v, kd=self.kd)
|
|
File without changes
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Synchronous MotorPort over a dedicated CAN channel (python-can)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
from ..port import Feedback, MitCommand, PortError
|
|
8
|
+
from .dm4310 import DM4310
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PortFault(PortError):
|
|
12
|
+
def __init__(self, code: int, text: str):
|
|
13
|
+
super().__init__(text)
|
|
14
|
+
self.code = code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DamiaoCanPort:
|
|
18
|
+
pos_window = 12.5
|
|
19
|
+
|
|
20
|
+
def __init__(self, bus, motor_id: int, master_id: int, retry_budget_s: float = 0.2):
|
|
21
|
+
self.motor = DM4310(bus, can_id=motor_id, master_id=master_id)
|
|
22
|
+
self.retry_budget_s = retry_budget_s
|
|
23
|
+
self._closed = False
|
|
24
|
+
|
|
25
|
+
def enable(self) -> None:
|
|
26
|
+
self.motor.enable()
|
|
27
|
+
|
|
28
|
+
def disable(self) -> None:
|
|
29
|
+
self.motor.disable()
|
|
30
|
+
|
|
31
|
+
def clear_error(self) -> None:
|
|
32
|
+
self.motor.clear_error()
|
|
33
|
+
|
|
34
|
+
def _txrx(self, cmd: MitCommand) -> Feedback:
|
|
35
|
+
deadline = time.monotonic() + self.retry_budget_s
|
|
36
|
+
while True:
|
|
37
|
+
self.motor.mit_control(cmd.pos, cmd.vel, cmd.kp, cmd.kd, cmd.tau)
|
|
38
|
+
raw = self.motor.read_feedback(timeout=min(0.05, self.retry_budget_s))
|
|
39
|
+
if raw is not None:
|
|
40
|
+
return Feedback(position=raw.position, velocity=raw.velocity,
|
|
41
|
+
torque=raw.torque, error_code=raw.error, t=time.monotonic())
|
|
42
|
+
if time.monotonic() > deadline:
|
|
43
|
+
raise PortError(f"motor 0x{self.motor.can_id:02X}: no feedback within "
|
|
44
|
+
f"{self.retry_budget_s:.2f}s")
|
|
45
|
+
|
|
46
|
+
def _recover(self) -> Feedback | None:
|
|
47
|
+
for _ in range(10):
|
|
48
|
+
self.motor.clear_error()
|
|
49
|
+
time.sleep(0.01)
|
|
50
|
+
self.motor.enable()
|
|
51
|
+
try:
|
|
52
|
+
fb = self._txrx(MitCommand())
|
|
53
|
+
except PortError:
|
|
54
|
+
time.sleep(0.3)
|
|
55
|
+
continue
|
|
56
|
+
if not fb.faulted:
|
|
57
|
+
return fb
|
|
58
|
+
time.sleep(0.3)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def command(self, cmd: MitCommand) -> Feedback:
|
|
62
|
+
fb = self._txrx(cmd)
|
|
63
|
+
if fb.faulted:
|
|
64
|
+
recovered = self._recover()
|
|
65
|
+
if recovered is None:
|
|
66
|
+
raise PortFault(fb.error_code,
|
|
67
|
+
f"motor fault '{fb.fault_text}' did not clear after 10 attempts")
|
|
68
|
+
return recovered
|
|
69
|
+
return fb
|
|
70
|
+
|
|
71
|
+
def read(self) -> Feedback:
|
|
72
|
+
return self.command(MitCommand()) # zero gains: no motion
|
|
73
|
+
|
|
74
|
+
def close(self) -> None:
|
|
75
|
+
if self._closed:
|
|
76
|
+
return
|
|
77
|
+
self._closed = True
|
|
78
|
+
for _ in range(3):
|
|
79
|
+
try:
|
|
80
|
+
self.motor.mit_control(0.0, 0.0, 0.0, 0.0, 0.0)
|
|
81
|
+
except Exception:
|
|
82
|
+
break
|
|
83
|
+
time.sleep(0.02)
|
|
84
|
+
for _ in range(5):
|
|
85
|
+
try:
|
|
86
|
+
self.motor.disable()
|
|
87
|
+
self.motor.read_feedback(timeout=0.1)
|
|
88
|
+
except Exception:
|
|
89
|
+
break
|
|
90
|
+
time.sleep(0.05)
|