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.
Files changed (35) hide show
  1. fastgripper_dm-0.1.0/.gitignore +12 -0
  2. fastgripper_dm-0.1.0/PKG-INFO +39 -0
  3. fastgripper_dm-0.1.0/README.md +26 -0
  4. fastgripper_dm-0.1.0/pyproject.toml +24 -0
  5. fastgripper_dm-0.1.0/src/fastgripper_dm/__init__.py +12 -0
  6. fastgripper_dm-0.1.0/src/fastgripper_dm/calstore.py +99 -0
  7. fastgripper_dm-0.1.0/src/fastgripper_dm/cli.py +252 -0
  8. fastgripper_dm-0.1.0/src/fastgripper_dm/controller.py +96 -0
  9. fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/__init__.py +0 -0
  10. fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/can_port.py +90 -0
  11. fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/canbus.py +171 -0
  12. fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/config_tool.py +125 -0
  13. fastgripper_dm-0.1.0/src/fastgripper_dm/damiao/dm4310.py +209 -0
  14. fastgripper_dm-0.1.0/src/fastgripper_dm/facade.py +227 -0
  15. fastgripper_dm-0.1.0/src/fastgripper_dm/port.py +60 -0
  16. fastgripper_dm-0.1.0/src/fastgripper_dm/profile.py +54 -0
  17. fastgripper_dm-0.1.0/src/fastgripper_dm/sim.py +96 -0
  18. fastgripper_dm-0.1.0/src/fastgripper_dm/tools/__init__.py +0 -0
  19. fastgripper_dm-0.1.0/src/fastgripper_dm/tools/_cli.py +36 -0
  20. fastgripper_dm-0.1.0/src/fastgripper_dm/tools/autocal.py +449 -0
  21. fastgripper_dm-0.1.0/src/fastgripper_dm/tools/bus_ping.py +98 -0
  22. fastgripper_dm-0.1.0/src/fastgripper_dm/tools/cal_doctor.py +140 -0
  23. fastgripper_dm-0.1.0/src/fastgripper_dm/tools/calibrate.py +231 -0
  24. fastgripper_dm-0.1.0/src/fastgripper_dm/tools/preflight.py +129 -0
  25. fastgripper_dm-0.1.0/src/fastgripper_dm/tracker.py +58 -0
  26. fastgripper_dm-0.1.0/tests/test_can_port.py +77 -0
  27. fastgripper_dm-0.1.0/tests/test_cli_verbs.py +17 -0
  28. fastgripper_dm-0.1.0/tests/test_controller.py +82 -0
  29. fastgripper_dm-0.1.0/tests/test_facade.py +78 -0
  30. fastgripper_dm-0.1.0/tests/test_harvest.py +180 -0
  31. fastgripper_dm-0.1.0/tests/test_port.py +21 -0
  32. fastgripper_dm-0.1.0/tests/test_profile.py +31 -0
  33. fastgripper_dm-0.1.0/tests/test_sim.py +61 -0
  34. fastgripper_dm-0.1.0/tests/test_tracker.py +47 -0
  35. fastgripper_dm-0.1.0/uv.lock +314 -0
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ venv/
3
+ __pycache__/
4
+ *.pyc
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ logs/
9
+ datasets/
10
+ checkpoints/
11
+ bench/local.toml
12
+ .DS_Store
@@ -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)
@@ -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)