urkit 0.1.0__tar.gz → 0.1.1__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 (39) hide show
  1. {urkit-0.1.0 → urkit-0.1.1}/PKG-INFO +108 -13
  2. {urkit-0.1.0 → urkit-0.1.1}/README.md +106 -12
  3. {urkit-0.1.0 → urkit-0.1.1}/pyproject.toml +5 -1
  4. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/__init__.py +3 -1
  5. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/__main__.py +8 -2
  6. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/cli/teach.py +64 -46
  7. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/connection.py +10 -3
  8. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/gripper/base.py +19 -9
  9. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/gripper/digital.py +0 -10
  10. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/gripper/robotiq.py +21 -14
  11. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/motion.py +2 -2
  12. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/robot.py +2 -7
  13. {urkit-0.1.0 → urkit-0.1.1}/src/urkit.egg-info/PKG-INFO +108 -13
  14. {urkit-0.1.0 → urkit-0.1.1}/tests/test_gripper.py +43 -5
  15. {urkit-0.1.0 → urkit-0.1.1}/tests/test_gripper_factory.py +1 -5
  16. {urkit-0.1.0 → urkit-0.1.1}/setup.cfg +0 -0
  17. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/cli/__init__.py +0 -0
  18. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/cli/colors.py +0 -0
  19. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/cli/connection_monitor.py +0 -0
  20. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/cli/points.py +0 -0
  21. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/config.py +0 -0
  22. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/exceptions.py +0 -0
  23. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/geometry.py +0 -0
  24. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/gripper/__init__.py +0 -0
  25. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/gripper/presets.py +0 -0
  26. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/gripper/robotiq_preamble.py +0 -0
  27. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/io.py +0 -0
  28. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/points.py +0 -0
  29. {urkit-0.1.0 → urkit-0.1.1}/src/urkit/telemetry.py +0 -0
  30. {urkit-0.1.0 → urkit-0.1.1}/src/urkit.egg-info/SOURCES.txt +0 -0
  31. {urkit-0.1.0 → urkit-0.1.1}/src/urkit.egg-info/dependency_links.txt +0 -0
  32. {urkit-0.1.0 → urkit-0.1.1}/src/urkit.egg-info/entry_points.txt +0 -0
  33. {urkit-0.1.0 → urkit-0.1.1}/src/urkit.egg-info/requires.txt +0 -0
  34. {urkit-0.1.0 → urkit-0.1.1}/src/urkit.egg-info/top_level.txt +0 -0
  35. {urkit-0.1.0 → urkit-0.1.1}/tests/test_exceptions.py +0 -0
  36. {urkit-0.1.0 → urkit-0.1.1}/tests/test_geometry.py +0 -0
  37. {urkit-0.1.0 → urkit-0.1.1}/tests/test_gripper_presets.py +0 -0
  38. {urkit-0.1.0 → urkit-0.1.1}/tests/test_points.py +0 -0
  39. {urkit-0.1.0 → urkit-0.1.1}/tests/test_robot_integration.py +0 -0
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Universal Robots e-Series control toolkit built on ur_rtde
5
5
  Author: URKit Contributors
6
6
  License: MIT
7
+ Project-URL: Repository, https://github.com/rodolfo-verde/urkit
7
8
  Keywords: universal-robots,ur3e,ur5e,ur7e,ur10e,ur12e,ur16e,ur8,ur15,ur18,ur20,ur30,rtde,robotics,industrial-robot
8
9
  Classifier: Development Status :: 3 - Alpha
9
10
  Classifier: Intended Audience :: Developers
@@ -25,6 +26,8 @@ Requires-Dist: rich>=13.0.0
25
26
 
26
27
  # URKit
27
28
 
29
+ [![PyPI](https://img.shields.io/pypi/v/urkit.svg)](https://pypi.org/project/urkit/)
30
+
28
31
  **URKit** is a Python toolkit for [Universal Robots](https://www.universal-robots.com/) e-Series robots that makes the common stuff simple and gets out of the way for everything else.
29
32
 
30
33
  Built on [`ur_rtde`](https://sdurobotics.gitlab.io/ur_rtde/), it packages the operations you reach for most: connecting, moving to named points, gripper control, telemetry, and I/O, while exposing the raw RTDE interfaces for anything deeper. It doesn't try to replace `ur_rtde`; it sits on top of it so you can use both in tandem.
@@ -103,17 +106,20 @@ urkit teach # reads IP from config.yaml
103
106
  | Flag | Description |
104
107
  |------|-------------|
105
108
  | `ip` | Robot IP address (positional, overrides config) |
106
- | `--gripper` | Gripper preset: `2f-85`, `2f-140`, `hand-e`, `digital` |
109
+ | `--gripper` | Gripper preset: `2f-85`, `2f-140`, `hand-e`, `digital`, `none` |
107
110
  | `--gripper-pin` | Digital gripper output pin (default: 0) |
108
111
  | `--gripper-force` | Robotiq force 0-100 |
109
112
  | `--gripper-speed` | Robotiq speed 0-100 |
110
113
  | `--gripper-close-on-high` | Digital polarity: `true` or `false` |
111
114
  | `--points` | Path to `points.db` file (overrides config) |
115
+ | `--config` | Path to config file (default: `config.yaml` in project root or CWD) |
112
116
  | `-v`, `--verbose` | Show verbose output (debug connection issues) |
113
117
 
114
118
  ```bash
115
119
  urkit teach 172.31.1.200 --gripper hand-e --points /path/to/points.db
116
120
  urkit teach --gripper digital --gripper-pin 3
121
+ urkit teach --gripper none # no gripper (overrides config)
122
+ urkit teach --config /path/to/my.yaml # load custom config
117
123
  urkit teach -v # verbose mode
118
124
  ```
119
125
 
@@ -210,10 +216,11 @@ All movement and orientation keys support **hold-to-repeat** — hold a key down
210
216
  <td align="center" style="width:50%">
211
217
  <table>
212
218
  <tr><th>Key</th><th>Action</th></tr>
213
- <tr><td><code>F</code></td><td>Freedrive (OFF → ALL → XYZ)</td></tr>
219
+ <tr><td><code>F</code></td><td>Freedrive (OFF → ALL → XYZ+Rz)</td></tr>
214
220
  <tr><td><code>M</code></td><td>Toggle frame (BASE / TOOL)</td></tr>
215
221
  <tr><td><code>N</code></td><td>Go To mode (Cartesian / Joint)</td></tr>
216
222
  <tr><td><code>T</code></td><td>Orient TCP down (180°)</td></tr>
223
+ <tr><td><code>Y</code></td><td>Save config to file</td></tr>
217
224
  <tr><td><code>ESC</code></td><td>Exit</td></tr>
218
225
  </table>
219
226
  </td>
@@ -249,22 +256,110 @@ robot = URRobot(
249
256
 
250
257
  ### From Config
251
258
 
252
- Store your config in a `config.yaml`:
259
+ ```python
260
+ robot = URRobot.from_config("config.yaml")
261
+ robot = URRobot.from_config("config.yaml", ip="10.0.0.50") # override IP
262
+ ```
263
+
264
+ See the [Configuration](#configuration) section for full details on config location, keys, and saving.
265
+
266
+ ---
267
+
268
+ ## Configuration
269
+
270
+ URKit uses a YAML config file (`config.yaml`) to persist settings between sessions. The CLI reads it automatically, and `URRobot.from_config()` loads it programmatically.
271
+
272
+ ### Config File Location
273
+
274
+ URKit searches for `config.yaml` in this order:
275
+ 1. Explicit path via `--config` flag or `load_config("path")`
276
+ 2. Project root (where `src/urkit` lives)
277
+ 3. Current working directory
278
+
279
+ If no config file exists, URKit uses built-in defaults and operates fine — the config is optional.
280
+
281
+ ### Config Keys
282
+
283
+ | Key | Description | Example |
284
+ |-----|-------------|---------|
285
+ | `robot_ip` | Robot IP address | `192.168.1.100` |
286
+ | `points_path` | Path to SQLite points database | `points.db` |
287
+ | `gripper` | Gripper preset name | `hand-e`, `2f-85`, `2f-140`, `digital` |
288
+ | `default_vel` | Default linear velocity (m/s) | `0.5` |
289
+ | `default_acc` | Default linear acceleration (m/s²) | `0.3` |
290
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
291
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
292
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
293
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
294
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
295
+
296
+ ### Gripper Config Section
297
+
298
+ For digital grippers, specify pin and polarity:
299
+
300
+ ```yaml
301
+ gripper: digital
302
+ gripper_config:
303
+ pin: 3
304
+ close_on_high: true
305
+ ```
306
+
307
+ For Robotiq grippers, override preset values:
253
308
 
254
309
  ```yaml
255
- robot_ip: 172.31.1.200
256
- points_path: points.db
257
310
  gripper: hand-e
258
- default_vel: 0.5
259
- default_acc: 0.3
260
- rtde_frequency: 125
311
+ gripper_config:
312
+ force: 50
313
+ speed: 80
261
314
  ```
262
315
 
263
- Then load it:
316
+ ### CLI Override Precedence
317
+
318
+ Settings are resolved in this order (highest priority first):
319
+
320
+ 1. **CLI flags** — `urkit teach 172.31.1.200 --gripper none`
321
+ 2. **Config file** — values from `config.yaml`
322
+ 3. **Built-in defaults** — `points.db` for points, no gripper, `0.5` m/s velocity
323
+
324
+ Use `--gripper none` to explicitly disable a gripper that's set in your config file.
325
+
326
+ ### Saving Config
327
+
328
+ The CLI **never** modifies your config file automatically. Inside the teach pendant, press **Y** to save your current session's settings (IP, gripper, points path) to the config file. This way you only save settings you've actually tested and verified work.
329
+
330
+ ```bash
331
+ # First connection — test everything, then press Y inside the pendant
332
+ urkit teach 172.31.1.200 --gripper hand-e
333
+
334
+ # After pressing Y, config.yaml is saved. Next time:
335
+ urkit teach # reads IP + gripper from config
336
+
337
+ # Custom config file
338
+ urkit teach --config station_a.yaml # load from custom path
339
+ # press Y inside → saves back to station_a.yaml
340
+ ```
341
+
342
+ This lets you maintain separate configs per workcell:
343
+
344
+ ```bash
345
+ urkit teach --config station_a.yaml # press Y to save
346
+ urkit teach --config station_b.yaml # press Y to save
347
+ ```
348
+
349
+ ### Programmatic Config
264
350
 
265
351
  ```python
266
- robot = URRobot.from_config("config.yaml")
267
- robot = URRobot.from_config("config.yaml", ip="10.0.0.50") # override IP
352
+ from urkit import load_config, resolve_config
353
+
354
+ # Load with auto-resolution
355
+ config = load_config() # searches for config.yaml
356
+ config = load_config("/path/to/my.yaml") # explicit path
357
+
358
+ # Check if config exists
359
+ path = resolve_config() # returns Path or None
360
+
361
+ # Create robot from config dict
362
+ robot = URRobot.from_config({"robot_ip": "172.31.1.200", "gripper": "2f-85"})
268
363
  ```
269
364
 
270
365
  ---
@@ -478,7 +573,7 @@ from urkit import FreedriveMode
478
573
 
479
574
  # Freedrive lets you manually push the robot: motion commands won't work while active
480
575
  robot.enable_freedrive() # all 6 axes free
481
- robot.enable_freedrive(FreedriveMode.XYZ) # linear axes only
576
+ robot.enable_freedrive(FreedriveMode.XYZ) # linear axes + Rz rotation
482
577
  robot.enable_freedrive(FreedriveMode.ROTATION) # rotation only
483
578
  robot.disable_freedrive() # always disable before sending motion commands
484
579
 
@@ -1,5 +1,7 @@
1
1
  # URKit
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/urkit.svg)](https://pypi.org/project/urkit/)
4
+
3
5
  **URKit** is a Python toolkit for [Universal Robots](https://www.universal-robots.com/) e-Series robots that makes the common stuff simple and gets out of the way for everything else.
4
6
 
5
7
  Built on [`ur_rtde`](https://sdurobotics.gitlab.io/ur_rtde/), it packages the operations you reach for most: connecting, moving to named points, gripper control, telemetry, and I/O, while exposing the raw RTDE interfaces for anything deeper. It doesn't try to replace `ur_rtde`; it sits on top of it so you can use both in tandem.
@@ -78,17 +80,20 @@ urkit teach # reads IP from config.yaml
78
80
  | Flag | Description |
79
81
  |------|-------------|
80
82
  | `ip` | Robot IP address (positional, overrides config) |
81
- | `--gripper` | Gripper preset: `2f-85`, `2f-140`, `hand-e`, `digital` |
83
+ | `--gripper` | Gripper preset: `2f-85`, `2f-140`, `hand-e`, `digital`, `none` |
82
84
  | `--gripper-pin` | Digital gripper output pin (default: 0) |
83
85
  | `--gripper-force` | Robotiq force 0-100 |
84
86
  | `--gripper-speed` | Robotiq speed 0-100 |
85
87
  | `--gripper-close-on-high` | Digital polarity: `true` or `false` |
86
88
  | `--points` | Path to `points.db` file (overrides config) |
89
+ | `--config` | Path to config file (default: `config.yaml` in project root or CWD) |
87
90
  | `-v`, `--verbose` | Show verbose output (debug connection issues) |
88
91
 
89
92
  ```bash
90
93
  urkit teach 172.31.1.200 --gripper hand-e --points /path/to/points.db
91
94
  urkit teach --gripper digital --gripper-pin 3
95
+ urkit teach --gripper none # no gripper (overrides config)
96
+ urkit teach --config /path/to/my.yaml # load custom config
92
97
  urkit teach -v # verbose mode
93
98
  ```
94
99
 
@@ -185,10 +190,11 @@ All movement and orientation keys support **hold-to-repeat** — hold a key down
185
190
  <td align="center" style="width:50%">
186
191
  <table>
187
192
  <tr><th>Key</th><th>Action</th></tr>
188
- <tr><td><code>F</code></td><td>Freedrive (OFF → ALL → XYZ)</td></tr>
193
+ <tr><td><code>F</code></td><td>Freedrive (OFF → ALL → XYZ+Rz)</td></tr>
189
194
  <tr><td><code>M</code></td><td>Toggle frame (BASE / TOOL)</td></tr>
190
195
  <tr><td><code>N</code></td><td>Go To mode (Cartesian / Joint)</td></tr>
191
196
  <tr><td><code>T</code></td><td>Orient TCP down (180°)</td></tr>
197
+ <tr><td><code>Y</code></td><td>Save config to file</td></tr>
192
198
  <tr><td><code>ESC</code></td><td>Exit</td></tr>
193
199
  </table>
194
200
  </td>
@@ -224,22 +230,110 @@ robot = URRobot(
224
230
 
225
231
  ### From Config
226
232
 
227
- Store your config in a `config.yaml`:
233
+ ```python
234
+ robot = URRobot.from_config("config.yaml")
235
+ robot = URRobot.from_config("config.yaml", ip="10.0.0.50") # override IP
236
+ ```
237
+
238
+ See the [Configuration](#configuration) section for full details on config location, keys, and saving.
239
+
240
+ ---
241
+
242
+ ## Configuration
243
+
244
+ URKit uses a YAML config file (`config.yaml`) to persist settings between sessions. The CLI reads it automatically, and `URRobot.from_config()` loads it programmatically.
245
+
246
+ ### Config File Location
247
+
248
+ URKit searches for `config.yaml` in this order:
249
+ 1. Explicit path via `--config` flag or `load_config("path")`
250
+ 2. Project root (where `src/urkit` lives)
251
+ 3. Current working directory
252
+
253
+ If no config file exists, URKit uses built-in defaults and operates fine — the config is optional.
254
+
255
+ ### Config Keys
256
+
257
+ | Key | Description | Example |
258
+ |-----|-------------|---------|
259
+ | `robot_ip` | Robot IP address | `192.168.1.100` |
260
+ | `points_path` | Path to SQLite points database | `points.db` |
261
+ | `gripper` | Gripper preset name | `hand-e`, `2f-85`, `2f-140`, `digital` |
262
+ | `default_vel` | Default linear velocity (m/s) | `0.5` |
263
+ | `default_acc` | Default linear acceleration (m/s²) | `0.3` |
264
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
265
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
266
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
267
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
268
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
269
+
270
+ ### Gripper Config Section
271
+
272
+ For digital grippers, specify pin and polarity:
273
+
274
+ ```yaml
275
+ gripper: digital
276
+ gripper_config:
277
+ pin: 3
278
+ close_on_high: true
279
+ ```
280
+
281
+ For Robotiq grippers, override preset values:
228
282
 
229
283
  ```yaml
230
- robot_ip: 172.31.1.200
231
- points_path: points.db
232
284
  gripper: hand-e
233
- default_vel: 0.5
234
- default_acc: 0.3
235
- rtde_frequency: 125
285
+ gripper_config:
286
+ force: 50
287
+ speed: 80
236
288
  ```
237
289
 
238
- Then load it:
290
+ ### CLI Override Precedence
291
+
292
+ Settings are resolved in this order (highest priority first):
293
+
294
+ 1. **CLI flags** — `urkit teach 172.31.1.200 --gripper none`
295
+ 2. **Config file** — values from `config.yaml`
296
+ 3. **Built-in defaults** — `points.db` for points, no gripper, `0.5` m/s velocity
297
+
298
+ Use `--gripper none` to explicitly disable a gripper that's set in your config file.
299
+
300
+ ### Saving Config
301
+
302
+ The CLI **never** modifies your config file automatically. Inside the teach pendant, press **Y** to save your current session's settings (IP, gripper, points path) to the config file. This way you only save settings you've actually tested and verified work.
303
+
304
+ ```bash
305
+ # First connection — test everything, then press Y inside the pendant
306
+ urkit teach 172.31.1.200 --gripper hand-e
307
+
308
+ # After pressing Y, config.yaml is saved. Next time:
309
+ urkit teach # reads IP + gripper from config
310
+
311
+ # Custom config file
312
+ urkit teach --config station_a.yaml # load from custom path
313
+ # press Y inside → saves back to station_a.yaml
314
+ ```
315
+
316
+ This lets you maintain separate configs per workcell:
317
+
318
+ ```bash
319
+ urkit teach --config station_a.yaml # press Y to save
320
+ urkit teach --config station_b.yaml # press Y to save
321
+ ```
322
+
323
+ ### Programmatic Config
239
324
 
240
325
  ```python
241
- robot = URRobot.from_config("config.yaml")
242
- robot = URRobot.from_config("config.yaml", ip="10.0.0.50") # override IP
326
+ from urkit import load_config, resolve_config
327
+
328
+ # Load with auto-resolution
329
+ config = load_config() # searches for config.yaml
330
+ config = load_config("/path/to/my.yaml") # explicit path
331
+
332
+ # Check if config exists
333
+ path = resolve_config() # returns Path or None
334
+
335
+ # Create robot from config dict
336
+ robot = URRobot.from_config({"robot_ip": "172.31.1.200", "gripper": "2f-85"})
243
337
  ```
244
338
 
245
339
  ---
@@ -453,7 +547,7 @@ from urkit import FreedriveMode
453
547
 
454
548
  # Freedrive lets you manually push the robot: motion commands won't work while active
455
549
  robot.enable_freedrive() # all 6 axes free
456
- robot.enable_freedrive(FreedriveMode.XYZ) # linear axes only
550
+ robot.enable_freedrive(FreedriveMode.XYZ) # linear axes + Rz rotation
457
551
  robot.enable_freedrive(FreedriveMode.ROTATION) # rotation only
458
552
  robot.disable_freedrive() # always disable before sending motion commands
459
553
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "urkit"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Universal Robots e-Series control toolkit built on ur_rtde"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -12,6 +12,7 @@ requires-python = ">=3.8"
12
12
  authors = [
13
13
  {name = "URKit Contributors"},
14
14
  ]
15
+
15
16
  keywords = [
16
17
  "universal-robots",
17
18
  "ur3e",
@@ -49,6 +50,9 @@ dependencies = [
49
50
  "rich>=13.0.0",
50
51
  ]
51
52
 
53
+ [project.urls]
54
+ Repository = "https://github.com/rodolfo-verde/urkit"
55
+
52
56
  [project.scripts]
53
57
  urkit = "urkit.__main__:main_entry"
54
58
 
@@ -24,6 +24,8 @@ Quick start::
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ from importlib.metadata import version as _metadata_version
28
+
27
29
  from urkit.config import load_config, resolve_config
28
30
  from urkit.exceptions import (
29
31
  URKitError,
@@ -57,7 +59,7 @@ from urkit.gripper.presets import (
57
59
  from urkit.motion import FreedriveMode
58
60
  from urkit.robot import URRobot
59
61
 
60
- __version__ = "0.0.1"
62
+ __version__ = _metadata_version("urkit")
61
63
 
62
64
  __all__ = [
63
65
  # Version
@@ -41,8 +41,8 @@ def main() -> None:
41
41
  "--gripper",
42
42
  type=str,
43
43
  default=None,
44
- choices=["2f-85", "2f-140", "hand-e", "digital"],
45
- help="Gripper preset (overrides config file)",
44
+ choices=["2f-85", "2f-140", "hand-e", "digital", "none"],
45
+ help="Gripper preset (overrides config file). Use 'none' to disable gripper",
46
46
  )
47
47
  teach_parser.add_argument(
48
48
  "--gripper-pin",
@@ -75,6 +75,12 @@ def main() -> None:
75
75
  default=None,
76
76
  help="Path to points.db file (overrides config file)",
77
77
  )
78
+ teach_parser.add_argument(
79
+ "--config",
80
+ type=str,
81
+ default=None,
82
+ help="Path to config file (default: config.yaml in project root or CWD)",
83
+ )
78
84
  teach_parser.add_argument(
79
85
  "-v", "--verbose",
80
86
  action="store_true",
@@ -101,10 +101,15 @@ def _compute_vel_acc(
101
101
  acc = min(vel * 3.0, _MAX_ACC)
102
102
  return vel, acc
103
103
 
104
- def _save_config(config: dict) -> None:
105
- """Write configuration to <script_dir>/config.yaml."""
104
+ def _save_config(config: dict, path: str | Path | None = None) -> None:
105
+ """Write configuration to config.yaml.
106
+
107
+ Args:
108
+ config: Configuration dict to save.
109
+ path: Explicit path. Falls back to resolved default config path.
110
+ """
106
111
  try:
107
- config_path = _resolve_default_config_path()
112
+ config_path = Path(path) if path is not None else _resolve_default_config_path()
108
113
  config_path.parent.mkdir(parents=True, exist_ok=True)
109
114
  with open(config_path, "w") as f:
110
115
  yaml.dump(config, f, default_flow_style=False)
@@ -186,13 +191,14 @@ def _draw_screen(
186
191
  tcp_force = [0, 0, 0, 0, 0, 0]
187
192
 
188
193
  gripper_state = "None"
189
- if robot.gripper and not state.get("freedrive"):
190
- try:
191
- gripper_state = "Connected" if robot.gripper.is_connected() else "Disconnected"
192
- except Exception:
193
- gripper_state = "?"
194
- elif robot.gripper and state.get("freedrive"):
195
- gripper_state = dim("(freedrive active)")
194
+ if robot.gripper:
195
+ pos_mm = robot.gripper.get_position_mm()
196
+ max_mm = robot.gripper.max_travel_mm()
197
+ if pos_mm is not None and max_mm is not None and max_mm > 0:
198
+ pct = int((max_mm - pos_mm) / max_mm * 100)
199
+ gripper_state = f"{green('Connected')} {pos_mm:.1f}mm ({pct}%)"
200
+ else:
201
+ gripper_state = green("Connected")
196
202
 
197
203
  lines: list[str] = []
198
204
 
@@ -212,9 +218,12 @@ def _draw_screen(
212
218
  lines.append(f" {blue('Go To:'.ljust(lw))} {green(goto_label)} {dim('[N: toggle Cartesian/Joint]')}")
213
219
  lines.append(f" {blue('Gripper:'.ljust(lw))} {gripper_state}")
214
220
  if state["freedrive"]:
215
- lines.append(f" {blue('Freedrive:'.ljust(lw))} {green('ON')} ({state['freedrive_mode'].name})")
221
+ mode_label = state["freedrive_mode"].name
222
+ if mode_label == "XYZ":
223
+ mode_label = "XYZ+Rz"
224
+ lines.append(f" {blue('Freedrive:'.ljust(lw))} {green('ON')} ({mode_label})")
216
225
  else:
217
- lines.append(f" {blue('Freedrive:'.ljust(lw))} {red('OFF')} {dim('[F: cycle ALL/XYZ]')}")
226
+ lines.append(f" {blue('Freedrive:'.ljust(lw))} {red('OFF')} {dim('[F: cycle ALL/XYZ+Rz]')}")
218
227
  slider_pct = int(state["speed_slider"] * 100)
219
228
  slider_color = green if state["speed_slider"] >= 0.5 else yellow if state["speed_slider"] >= 0.2 else red
220
229
  lines.append(f" {blue('Speed:'.ljust(lw))} {slider_color(f'{slider_pct}%')} {dim('[0: set]')}")
@@ -245,16 +254,18 @@ def _draw_screen(
245
254
  # Control reference
246
255
  lines.append(f" {yellow('MOVE:')} {yellow('W/S')}: ±X {yellow('A/D')}: ±Y {yellow('Q/E')}: ±Z")
247
256
  lines.append(f" {yellow('ORIENT:')} {yellow('U/O')}: ±Roll {yellow('I/K')}: ±Pitch {yellow('J/L')}: ±Yaw")
248
- lines.append(f" {yellow('STEP:')} {yellow('1')}: Set Linear (mm) {yellow('2')}: Set Angular (°) {yellow('.')}: Reset")
249
- lines.append(f" {yellow('GRIPPER:')} {yellow('X')}: Open {yellow('C')}: Close {yellow('V')}: Set Position")
250
- lines.append(f" {yellow('POINTS:')} {yellow('B')}: Save {yellow('G')}: Go To {yellow('H')}: Delete {yellow('R')}: Rename")
251
- lines.append(f" {yellow('OTHER:')} {yellow('F')}: Freedrive {yellow('M')}: Frame {yellow('N')}: Go To Mode {yellow('T')}: TCP Down {yellow('0')}: Speed")
252
- lines.append(f" {yellow('ESC')}: Exit")
257
+ lines.append(f" {yellow('STEP:')} {yellow('1')}: Linear (mm) {yellow('2')}: Angular (°) {yellow('.')}: Reset")
258
+ lines.append(f" {yellow('GRIPPER:')} {yellow('X')}: Open {yellow('C')}: Close {yellow('V')}: Position")
259
+ lines.append(f" {yellow('POINTS:')} {yellow('B')}: Save {yellow('G')}: Go To {yellow('H')}: Delete {yellow('R')}: Rename {yellow('P')}: Explorer")
260
+ lines.append(f" {yellow('OTHER:')} {yellow('F')}: Freedrive {yellow('M')}: Frame {yellow('N')}: GoTo Mode {yellow('T')}: TCP Down")
261
+ lines.append(f" {yellow(' ')} {yellow('0')}: Speed {yellow('Y')}: Save Config")
262
+ lines.append(f" {yellow('EXIT:')} {yellow('ESC')}")
253
263
  lines.append(dim("=" * width))
254
264
 
255
265
  # Clear and redraw
256
- print("\033[2J\033[1;1H", end="")
257
- print("\n".join(lines))
266
+ sys.stdout.write("\033[2J\033[1;1H")
267
+ sys.stdout.write("\n".join(lines) + "\n")
268
+ sys.stdout.flush()
258
269
 
259
270
 
260
271
  def _draw_help() -> None:
@@ -288,14 +299,16 @@ def _draw_help() -> None:
288
299
  lines.append(" G → Go to saved point (ask cartesian/joint)")
289
300
  lines.append(" H → Delete saved point")
290
301
  lines.append(" R → Rename saved point")
302
+ lines.append(" P → Open points explorer")
291
303
  lines.append("")
292
304
  lines.append(" OTHER:")
293
- lines.append(" F → Cycle freedrive: OFF → ALL → XYZ → OFF")
305
+ lines.append(" F → Cycle freedrive: OFF → ALL → XYZ+Rz → OFF")
294
306
  lines.append(" M → Toggle move frame: BASE / TOOL")
295
307
  lines.append(" T → Orient TCP downward (roll=180°)")
296
308
  lines.append(" 0 → Set speed slider (0-100%)")
309
+ lines.append(" Y → Save config (IP, gripper, points path)")
297
310
  lines.append("")
298
- lines.append(" ESC Exit")
311
+ lines.append(" Exit ESC")
299
312
  lines.append("=" * width)
300
313
  print("\n".join(lines))
301
314
 
@@ -710,6 +723,10 @@ class _ScreenLogHandler(logging.Handler):
710
723
 
711
724
  def _teach_pendant(
712
725
  robot: URRobot,
726
+ *,
727
+ config_path: str | Path | None = None,
728
+ current_gripper_name: str | None = None,
729
+ current_points_path: str | None = None,
713
730
  ) -> None:
714
731
  """Run the interactive teach pendant loop.
715
732
 
@@ -880,17 +897,10 @@ def _teach_pendant(
880
897
  # --- Freedrive ---
881
898
  elif key == "f":
882
899
  if not state["freedrive"]:
883
- mode = state.get("_last_freedrive_mode", FreedriveMode.ALL)
884
- if mode == FreedriveMode.ALL:
885
- mode = FreedriveMode.XYZ
886
- else:
887
- mode = FreedriveMode.ALL
888
900
  try:
889
- robot.enable_freedrive(mode)
901
+ robot.enable_freedrive(FreedriveMode.ALL)
890
902
  state["freedrive"] = True
891
- state["freedrive_mode"] = mode
892
- state["_last_freedrive_mode"] = mode
893
- messages.append(f"Freedrive {mode.name}")
903
+ state["freedrive_mode"] = FreedriveMode.ALL
894
904
  except MotionError as e:
895
905
  messages.append(f"Freedrive error: {e}")
896
906
  else:
@@ -899,15 +909,12 @@ def _teach_pendant(
899
909
  robot.disable_freedrive()
900
910
  robot.enable_freedrive(FreedriveMode.XYZ)
901
911
  state["freedrive_mode"] = FreedriveMode.XYZ
902
- state["_last_freedrive_mode"] = FreedriveMode.XYZ
903
- messages.append("Freedrive XYZ")
904
912
  except MotionError as e:
905
913
  messages.append(f"Freedrive error: {e}")
906
914
  else:
907
915
  try:
908
916
  robot.disable_freedrive()
909
917
  state["freedrive"] = False
910
- messages.append("Freedrive OFF")
911
918
  except MotionError as e:
912
919
  messages.append(f"Freedrive error: {e}")
913
920
  command_handled = True
@@ -1016,6 +1023,19 @@ def _teach_pendant(
1016
1023
  messages.append(f"Error: {e}")
1017
1024
  command_handled = True
1018
1025
 
1026
+ # --- Save config ---
1027
+ elif key == "y":
1028
+ save_cfg: dict = {}
1029
+ save_cfg["robot_ip"] = robot.ip
1030
+ if current_gripper_name:
1031
+ save_cfg["gripper"] = current_gripper_name
1032
+ if current_points_path:
1033
+ save_cfg["points_path"] = current_points_path
1034
+ _save_config(save_cfg, config_path)
1035
+ target = Path(config_path) if config_path else _resolve_default_config_path()
1036
+ messages.append(f"Config saved to {target}")
1037
+ command_handled = True
1038
+
1019
1039
  # --- Redraw after movement or command ---
1020
1040
  if moved or command_handled:
1021
1041
  _draw_screen(robot, state, messages)
@@ -1058,12 +1078,15 @@ def teach_command(args) -> None:
1058
1078
  else:
1059
1079
  logging.basicConfig(level=logging.WARNING)
1060
1080
 
1061
- # Load config (last-used defaults)
1062
- config = load_config()
1081
+ # Load config
1082
+ config = load_config(args.config)
1063
1083
 
1064
1084
  # Resolve effective values: CLI arg > config > None
1065
1085
  ip = args.ip or config.get("robot_ip")
1066
1086
  gripper_name = args.gripper or config.get("gripper")
1087
+ # "none" is an explicit CLI override — don't fall through to config
1088
+ if gripper_name and gripper_name.lower() == "none":
1089
+ gripper_name = None
1067
1090
  points_path = args.points or config.get("points_path") or "points.db"
1068
1091
 
1069
1092
  # Resolve gripper constructor params from config.yaml gripper_config section
@@ -1149,18 +1172,13 @@ def teach_command(args) -> None:
1149
1172
  )
1150
1173
  sys.exit(1)
1151
1174
 
1152
- # Auto-save last-used params to config
1153
- print(f" Saving config...", flush=True)
1154
- save_cfg = dict(config)
1155
- save_cfg["robot_ip"] = ip
1156
- if gripper_name:
1157
- save_cfg["gripper"] = gripper_name
1158
- save_cfg["points_path"] = points_path
1159
- _save_config(save_cfg)
1160
- print(f" Config saved.", flush=True)
1161
-
1162
1175
  try:
1163
- _teach_pendant(robot)
1176
+ _teach_pendant(
1177
+ robot,
1178
+ config_path=args.config,
1179
+ current_gripper_name=gripper_name,
1180
+ current_points_path=points_path,
1181
+ )
1164
1182
  except KeyboardInterrupt:
1165
1183
  pass
1166
1184
  finally:
@@ -170,7 +170,7 @@ def _validate_connection(ip: str, timeout: float = 5.0) -> dict[str, object]:
170
170
  def _connect_rtde(
171
171
  ip: str,
172
172
  *,
173
- frequency: float = 125.0,
173
+ frequency: float = 500.0,
174
174
  max_wait: float = 30.0,
175
175
  ) -> Tuple[
176
176
  "RTDEControlInterface",
@@ -193,7 +193,7 @@ def _connect_rtde(
193
193
 
194
194
  Args:
195
195
  ip: Robot IP address.
196
- frequency: RTDE communication frequency (default 125 Hz).
196
+ frequency: RTDE communication frequency (default 500 Hz).
197
197
  max_wait: Maximum time (seconds) to spend retrying the RTDE
198
198
  connection (default 30s).
199
199
 
@@ -296,7 +296,14 @@ def _connect_rtde(
296
296
  f"EtherNet/IP, PROFINET, and MODBUS. Save and restart."
297
297
  ) from e
298
298
 
299
- # Retryable RuntimeError (e.g., "Failed to start control script")
299
+ if "start control script" in err_msg.lower():
300
+ raise ConnectionError(
301
+ f"Failed to start the RTDE control script on the robot at {ip}. "
302
+ f"This usually means a program is already occupying the secondary interface. "
303
+ f"Stop any running program on the robot and try again."
304
+ ) from e
305
+
306
+ # Retryable RuntimeError (e.g., transient connection issues)
300
307
  remaining = max_wait - (time.time() - start)
301
308
  if remaining <= 0:
302
309
  raise ConnectionError(
@@ -13,11 +13,10 @@ from abc import ABC, abstractmethod
13
13
  class Gripper(ABC):
14
14
  """Abstract base class for gripper backends.
15
15
 
16
- Subclasses must implement ``open()``, ``close()``, ``set_position()``,
17
- and ``is_connected()``. The ``activate()`` method is a no-op by
18
- default and should be overridden by backends that require explicit
19
- activation (e.g., Robotiq). The factory method ``Gripper.create()``
20
- returns the appropriate backend.
16
+ Subclasses must implement ``open()``, ``close()``, and ``set_position()``.
17
+ The ``activate()`` method is a no-op by default and should be overridden
18
+ by backends that require explicit activation (e.g., Robotiq). The factory
19
+ method ``Gripper.create()`` returns the appropriate backend.
21
20
  """
22
21
 
23
22
  @abstractmethod
@@ -71,13 +70,24 @@ class Gripper(ABC):
71
70
  to re-enable the gripper.
72
71
  """
73
72
 
74
- @abstractmethod
75
- def is_connected(self) -> bool:
76
- """Check if the gripper is connected and ready.
73
+ def get_position_mm(self) -> float | None:
74
+ """Return the last commanded position in mm, or None if unknown.
75
+
76
+ Returns the most recently set position. Subclasses that support
77
+ reading the actual hardware position should override this method.
78
+
79
+ Returns:
80
+ Position in mm, or None if not available.
81
+ """
82
+ return None
83
+
84
+ def max_travel_mm(self) -> float | None:
85
+ """Return the maximum finger travel in mm, or None if unknown.
77
86
 
78
87
  Returns:
79
- True if the gripper is connected.
88
+ Max travel in mm, or None.
80
89
  """
90
+ return None
81
91
 
82
92
  @classmethod
83
93
  def create(cls, name: str, **kwargs) -> "Gripper":
@@ -128,13 +128,3 @@ class DigitalGripper(Gripper):
128
128
  raise GripperError(
129
129
  "Digital grippers do not support set_speed."
130
130
  )
131
-
132
- def is_connected(self) -> bool:
133
- """Check if the gripper is connected.
134
-
135
- Raises:
136
- GripperError: Always — digital grippers can't verify connection.
137
- """
138
- raise GripperError(
139
- "Digital grippers do not support connection checks."
140
- )
@@ -77,6 +77,7 @@ class RobotiqGripper(Gripper):
77
77
  self._force = force
78
78
  self._speed = speed
79
79
  self._activated = False
80
+ self._last_position_mm: float | None = None
80
81
 
81
82
  logger.info(
82
83
  "RobotiqGripper initialized (preamble, max_mm=%d)", max_mm
@@ -160,7 +161,7 @@ class RobotiqGripper(Gripper):
160
161
  """
161
162
  if self._activated:
162
163
  return
163
- # Check activation state and activate+open if needed, all in one script.
164
+ # Check activation state and activate if needed.
164
165
  # rq_activate_and_wait() sends ACT then polls until the gripper
165
166
  # reports activated (~3-5s for internal initialization).
166
167
  activation_script = (
@@ -170,7 +171,6 @@ class RobotiqGripper(Gripper):
170
171
  + "if (not rq_is_gripper_activated()):\n"
171
172
  + " rq_activate_and_wait()\n"
172
173
  + "end\n"
173
- + "rq_open_and_wait()\n"
174
174
  )
175
175
  # Run in a thread with a timeout — the preamble blocks indefinitely
176
176
  # if no gripper is physically connected (2000-iteration loop).
@@ -204,10 +204,9 @@ class RobotiqGripper(Gripper):
204
204
  def deactivate(self) -> None:
205
205
  """Deactivate the gripper (send DEACT command).
206
206
 
207
+ Sends the Robotiq DEACT command to power down the gripper.
207
208
  Safe to call when not activated — becomes a no-op.
208
- Use this before enabling freedrive to prevent the
209
- gripper's script from interfering with manual movement.
210
- Call activate() again after freedrive is disabled.
209
+ Call activate() again to re-enable the gripper.
211
210
  """
212
211
  if not self._activated:
213
212
  return
@@ -234,6 +233,7 @@ class RobotiqGripper(Gripper):
234
233
  self._require_activated()
235
234
  func = "rq_open_and_wait()" if wait else "rq_open()"
236
235
  self._send_script(self._build_script(func))
236
+ self._last_position_mm = float(self._max_mm)
237
237
  logger.debug("Robotiq gripper opened (wait=%s)", wait)
238
238
 
239
239
  def close(self, *, wait: bool = True) -> None:
@@ -250,6 +250,7 @@ class RobotiqGripper(Gripper):
250
250
  self._require_activated()
251
251
  func = "rq_close_and_wait()" if wait else "rq_close()"
252
252
  self._send_script(self._build_script(func))
253
+ self._last_position_mm = 0.0
253
254
  logger.debug("Robotiq gripper closed (wait=%s)", wait)
254
255
 
255
256
  def set_position(self, mm: float, *, wait: bool = True) -> None:
@@ -275,6 +276,7 @@ class RobotiqGripper(Gripper):
275
276
  else:
276
277
  func = f"rq_move_mm({mm})"
277
278
  self._send_script(self._build_script(func))
279
+ self._last_position_mm = mm
278
280
  logger.debug("Robotiq gripper set to %.1f mm (wait=%s)", mm, wait)
279
281
 
280
282
  def set_force(self, force: int) -> None:
@@ -303,15 +305,6 @@ class RobotiqGripper(Gripper):
303
305
  self._speed = speed
304
306
  logger.debug("Robotiq gripper speed set to %d", speed)
305
307
 
306
- def is_connected(self) -> bool:
307
- """Check if the gripper can send commands via RTDE."""
308
- try:
309
- return self._rtde.sendCustomScriptFunction(
310
- "_grip_ping", 'textmsg("gripper", "ok")'
311
- )
312
- except Exception:
313
- return False
314
-
315
308
  def disconnect(self) -> None:
316
309
  """Disconnect the gripper.
317
310
 
@@ -319,4 +312,18 @@ class RobotiqGripper(Gripper):
319
312
  using the gripper.
320
313
  """
321
314
  self._activated = False
315
+ self._last_position_mm = None
322
316
  logger.debug("Robotiq gripper disconnected")
317
+
318
+ def get_position_mm(self) -> float | None:
319
+ """Return the last commanded position in mm.
320
+
321
+ Returns:
322
+ Position in mm (0 = closed, max_mm = open), or None if
323
+ no position has been set yet.
324
+ """
325
+ return self._last_position_mm
326
+
327
+ def max_travel_mm(self) -> float:
328
+ """Return the maximum finger travel in mm."""
329
+ return float(self._max_mm)
@@ -57,7 +57,7 @@ class FreedriveMode(IntEnum):
57
57
  """
58
58
 
59
59
  ALL = 0 # All 6 axes free
60
- XYZ = 1 # Only linear axes (X, Y, Z)
60
+ XYZ = 1 # Linear axes (X, Y, Z) + rotation around Z (Rz)
61
61
  ROTATION = 2 # Only rotational axes (Roll, Pitch, Yaw)
62
62
 
63
63
 
@@ -475,7 +475,7 @@ class Motion:
475
475
  if mode == FreedriveMode.ALL:
476
476
  free_axes = [1, 1, 1, 1, 1, 1]
477
477
  elif mode == FreedriveMode.XYZ:
478
- free_axes = [1, 1, 1, 0, 0, 0]
478
+ free_axes = [1, 1, 1, 0, 0, 1]
479
479
  elif mode == FreedriveMode.ROTATION:
480
480
  free_axes = [0, 0, 0, 1, 1, 1]
481
481
  else:
@@ -76,7 +76,6 @@ class URRobot:
76
76
  Presets provide mass, CoG, TCP offset, and backend type.
77
77
  default_vel: Default linear velocity (m/s).
78
78
  default_acc: Default linear acceleration (m/s²).
79
- rtde_frequency: RTDE communication frequency in Hz (default 125).
80
79
  gripper_kwargs: Additional kwargs passed to the gripper backend
81
80
  to override preset values (e.g. ``max_mm=80`` for custom
82
81
  fingers, ``force=50``, ``speed=80`` for Robotiq).
@@ -97,13 +96,12 @@ class URRobot:
97
96
  gripper: GripperPreset | DigitalGripperConfig | None = None,
98
97
  default_vel: float = 0.5,
99
98
  default_acc: float = 0.3,
100
- rtde_frequency: float = 125.0,
101
99
  **gripper_kwargs,
102
100
  ) -> None:
103
101
  self._ip = ip
104
102
  self._default_vel = default_vel
105
103
  self._default_acc = default_acc
106
- self._rtde_frequency = rtde_frequency
104
+ self._rtde_frequency = 500.0
107
105
  self._connection_lost = False
108
106
  self._move_frame: MoveFrame = MoveFrame.BASE
109
107
 
@@ -298,7 +296,6 @@ class URRobot:
298
296
  gripper: GripperPreset | DigitalGripperConfig | str | None = None,
299
297
  default_vel: float | None = None,
300
298
  default_acc: float | None = None,
301
- rtde_frequency: float | None = None,
302
299
  **gripper_kwargs,
303
300
  ) -> "URRobot":
304
301
  """Create a URRobot from a YAML config file or dict.
@@ -315,7 +312,6 @@ class URRobot:
315
312
  or ``None``. Overrides the ``gripper`` key from config.
316
313
  default_vel: Default linear velocity (m/s).
317
314
  default_acc: Default linear acceleration (m/s²).
318
- rtde_frequency: RTDE communication frequency in Hz.
319
315
  gripper_kwargs: Overrides for gripper preset values
320
316
  (e.g. ``max_mm``, ``force``, ``speed``, ``pin``).
321
317
 
@@ -326,7 +322,7 @@ class URRobot:
326
322
  gripper: hand-e
327
323
  default_vel: 0.5
328
324
  default_acc: 0.3
329
- rtde_frequency: 125
325
+ rtde_frequency: 500
330
326
 
331
327
  Example:
332
328
  >>> robot = URRobot.from_config("config.yaml")
@@ -394,7 +390,6 @@ class URRobot:
394
390
  gripper=resolved_gripper,
395
391
  default_vel=default_vel if default_vel is not None else cfg.get("default_vel", 0.5),
396
392
  default_acc=default_acc if default_acc is not None else cfg.get("default_acc", 0.3),
397
- rtde_frequency=rtde_frequency if rtde_frequency is not None else cfg.get("rtde_frequency", 125.0),
398
393
  **gripper_kwargs,
399
394
  )
400
395
 
@@ -1,9 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Universal Robots e-Series control toolkit built on ur_rtde
5
5
  Author: URKit Contributors
6
6
  License: MIT
7
+ Project-URL: Repository, https://github.com/rodolfo-verde/urkit
7
8
  Keywords: universal-robots,ur3e,ur5e,ur7e,ur10e,ur12e,ur16e,ur8,ur15,ur18,ur20,ur30,rtde,robotics,industrial-robot
8
9
  Classifier: Development Status :: 3 - Alpha
9
10
  Classifier: Intended Audience :: Developers
@@ -25,6 +26,8 @@ Requires-Dist: rich>=13.0.0
25
26
 
26
27
  # URKit
27
28
 
29
+ [![PyPI](https://img.shields.io/pypi/v/urkit.svg)](https://pypi.org/project/urkit/)
30
+
28
31
  **URKit** is a Python toolkit for [Universal Robots](https://www.universal-robots.com/) e-Series robots that makes the common stuff simple and gets out of the way for everything else.
29
32
 
30
33
  Built on [`ur_rtde`](https://sdurobotics.gitlab.io/ur_rtde/), it packages the operations you reach for most: connecting, moving to named points, gripper control, telemetry, and I/O, while exposing the raw RTDE interfaces for anything deeper. It doesn't try to replace `ur_rtde`; it sits on top of it so you can use both in tandem.
@@ -103,17 +106,20 @@ urkit teach # reads IP from config.yaml
103
106
  | Flag | Description |
104
107
  |------|-------------|
105
108
  | `ip` | Robot IP address (positional, overrides config) |
106
- | `--gripper` | Gripper preset: `2f-85`, `2f-140`, `hand-e`, `digital` |
109
+ | `--gripper` | Gripper preset: `2f-85`, `2f-140`, `hand-e`, `digital`, `none` |
107
110
  | `--gripper-pin` | Digital gripper output pin (default: 0) |
108
111
  | `--gripper-force` | Robotiq force 0-100 |
109
112
  | `--gripper-speed` | Robotiq speed 0-100 |
110
113
  | `--gripper-close-on-high` | Digital polarity: `true` or `false` |
111
114
  | `--points` | Path to `points.db` file (overrides config) |
115
+ | `--config` | Path to config file (default: `config.yaml` in project root or CWD) |
112
116
  | `-v`, `--verbose` | Show verbose output (debug connection issues) |
113
117
 
114
118
  ```bash
115
119
  urkit teach 172.31.1.200 --gripper hand-e --points /path/to/points.db
116
120
  urkit teach --gripper digital --gripper-pin 3
121
+ urkit teach --gripper none # no gripper (overrides config)
122
+ urkit teach --config /path/to/my.yaml # load custom config
117
123
  urkit teach -v # verbose mode
118
124
  ```
119
125
 
@@ -210,10 +216,11 @@ All movement and orientation keys support **hold-to-repeat** — hold a key down
210
216
  <td align="center" style="width:50%">
211
217
  <table>
212
218
  <tr><th>Key</th><th>Action</th></tr>
213
- <tr><td><code>F</code></td><td>Freedrive (OFF → ALL → XYZ)</td></tr>
219
+ <tr><td><code>F</code></td><td>Freedrive (OFF → ALL → XYZ+Rz)</td></tr>
214
220
  <tr><td><code>M</code></td><td>Toggle frame (BASE / TOOL)</td></tr>
215
221
  <tr><td><code>N</code></td><td>Go To mode (Cartesian / Joint)</td></tr>
216
222
  <tr><td><code>T</code></td><td>Orient TCP down (180°)</td></tr>
223
+ <tr><td><code>Y</code></td><td>Save config to file</td></tr>
217
224
  <tr><td><code>ESC</code></td><td>Exit</td></tr>
218
225
  </table>
219
226
  </td>
@@ -249,22 +256,110 @@ robot = URRobot(
249
256
 
250
257
  ### From Config
251
258
 
252
- Store your config in a `config.yaml`:
259
+ ```python
260
+ robot = URRobot.from_config("config.yaml")
261
+ robot = URRobot.from_config("config.yaml", ip="10.0.0.50") # override IP
262
+ ```
263
+
264
+ See the [Configuration](#configuration) section for full details on config location, keys, and saving.
265
+
266
+ ---
267
+
268
+ ## Configuration
269
+
270
+ URKit uses a YAML config file (`config.yaml`) to persist settings between sessions. The CLI reads it automatically, and `URRobot.from_config()` loads it programmatically.
271
+
272
+ ### Config File Location
273
+
274
+ URKit searches for `config.yaml` in this order:
275
+ 1. Explicit path via `--config` flag or `load_config("path")`
276
+ 2. Project root (where `src/urkit` lives)
277
+ 3. Current working directory
278
+
279
+ If no config file exists, URKit uses built-in defaults and operates fine — the config is optional.
280
+
281
+ ### Config Keys
282
+
283
+ | Key | Description | Example |
284
+ |-----|-------------|---------|
285
+ | `robot_ip` | Robot IP address | `192.168.1.100` |
286
+ | `points_path` | Path to SQLite points database | `points.db` |
287
+ | `gripper` | Gripper preset name | `hand-e`, `2f-85`, `2f-140`, `digital` |
288
+ | `default_vel` | Default linear velocity (m/s) | `0.5` |
289
+ | `default_acc` | Default linear acceleration (m/s²) | `0.3` |
290
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
291
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
292
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
293
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
294
+ | `rtde_frequency` | RTDE communication frequency (Hz) | `125` |
295
+
296
+ ### Gripper Config Section
297
+
298
+ For digital grippers, specify pin and polarity:
299
+
300
+ ```yaml
301
+ gripper: digital
302
+ gripper_config:
303
+ pin: 3
304
+ close_on_high: true
305
+ ```
306
+
307
+ For Robotiq grippers, override preset values:
253
308
 
254
309
  ```yaml
255
- robot_ip: 172.31.1.200
256
- points_path: points.db
257
310
  gripper: hand-e
258
- default_vel: 0.5
259
- default_acc: 0.3
260
- rtde_frequency: 125
311
+ gripper_config:
312
+ force: 50
313
+ speed: 80
261
314
  ```
262
315
 
263
- Then load it:
316
+ ### CLI Override Precedence
317
+
318
+ Settings are resolved in this order (highest priority first):
319
+
320
+ 1. **CLI flags** — `urkit teach 172.31.1.200 --gripper none`
321
+ 2. **Config file** — values from `config.yaml`
322
+ 3. **Built-in defaults** — `points.db` for points, no gripper, `0.5` m/s velocity
323
+
324
+ Use `--gripper none` to explicitly disable a gripper that's set in your config file.
325
+
326
+ ### Saving Config
327
+
328
+ The CLI **never** modifies your config file automatically. Inside the teach pendant, press **Y** to save your current session's settings (IP, gripper, points path) to the config file. This way you only save settings you've actually tested and verified work.
329
+
330
+ ```bash
331
+ # First connection — test everything, then press Y inside the pendant
332
+ urkit teach 172.31.1.200 --gripper hand-e
333
+
334
+ # After pressing Y, config.yaml is saved. Next time:
335
+ urkit teach # reads IP + gripper from config
336
+
337
+ # Custom config file
338
+ urkit teach --config station_a.yaml # load from custom path
339
+ # press Y inside → saves back to station_a.yaml
340
+ ```
341
+
342
+ This lets you maintain separate configs per workcell:
343
+
344
+ ```bash
345
+ urkit teach --config station_a.yaml # press Y to save
346
+ urkit teach --config station_b.yaml # press Y to save
347
+ ```
348
+
349
+ ### Programmatic Config
264
350
 
265
351
  ```python
266
- robot = URRobot.from_config("config.yaml")
267
- robot = URRobot.from_config("config.yaml", ip="10.0.0.50") # override IP
352
+ from urkit import load_config, resolve_config
353
+
354
+ # Load with auto-resolution
355
+ config = load_config() # searches for config.yaml
356
+ config = load_config("/path/to/my.yaml") # explicit path
357
+
358
+ # Check if config exists
359
+ path = resolve_config() # returns Path or None
360
+
361
+ # Create robot from config dict
362
+ robot = URRobot.from_config({"robot_ip": "172.31.1.200", "gripper": "2f-85"})
268
363
  ```
269
364
 
270
365
  ---
@@ -478,7 +573,7 @@ from urkit import FreedriveMode
478
573
 
479
574
  # Freedrive lets you manually push the robot: motion commands won't work while active
480
575
  robot.enable_freedrive() # all 6 axes free
481
- robot.enable_freedrive(FreedriveMode.XYZ) # linear axes only
576
+ robot.enable_freedrive(FreedriveMode.XYZ) # linear axes + Rz rotation
482
577
  robot.enable_freedrive(FreedriveMode.ROTATION) # rotation only
483
578
  robot.disable_freedrive() # always disable before sending motion commands
484
579
 
@@ -116,6 +116,49 @@ class TestRobotiqActivation:
116
116
  with pytest.raises(GripperError, match="not activated"):
117
117
  self.gripper.open()
118
118
 
119
+ def test_get_position_mm_initially_none(self):
120
+ """get_position_mm() returns None before any operation."""
121
+ assert self.gripper.get_position_mm() is None
122
+
123
+ def test_get_position_mm_after_activate(self):
124
+ """get_position_mm() returns None after activation (gripper doesn't open)."""
125
+ self.gripper.activate()
126
+ assert self.gripper.get_position_mm() is None
127
+
128
+ def test_get_position_mm_after_open(self):
129
+ """get_position_mm() returns max_mm after open()."""
130
+ self.gripper.activate()
131
+ self.gripper.open()
132
+ assert self.gripper.get_position_mm() == 50.0
133
+
134
+ def test_get_position_mm_after_close(self):
135
+ """get_position_mm() returns 0 after close()."""
136
+ self.gripper.activate()
137
+ self.gripper.close()
138
+ assert self.gripper.get_position_mm() == 0.0
139
+
140
+ def test_get_position_mm_after_set_position(self):
141
+ """get_position_mm() returns the commanded position."""
142
+ self.gripper.activate()
143
+ self.gripper.set_position(25)
144
+ assert self.gripper.get_position_mm() == 25.0
145
+
146
+ def test_get_position_mm_after_disconnect(self):
147
+ """get_position_mm() returns None after disconnect."""
148
+ self.gripper.activate()
149
+ self.gripper.set_position(30)
150
+ self.gripper.disconnect()
151
+ assert self.gripper.get_position_mm() is None
152
+
153
+ def test_max_travel_mm(self):
154
+ """max_travel_mm() returns the configured max_mm."""
155
+ assert self.gripper.max_travel_mm() == 50.0
156
+
157
+ def test_max_travel_mm_custom(self):
158
+ """max_travel_mm() returns the custom max_mm value."""
159
+ gripper = RobotiqGripper(rtde_control=self.rtde, max_mm=140)
160
+ assert gripper.max_travel_mm() == 140.0
161
+
119
162
 
120
163
  # ------------------------------------------------------------------
121
164
  # DigitalGripper activation tests
@@ -146,11 +189,6 @@ class TestDigitalActivation:
146
189
  gripper.close()
147
190
  mock_rtde.setStandardDigitalOut.assert_called_with(1, False)
148
191
 
149
- def test_is_connected_raises(self, mock_rtde, mock_rtde_r):
150
- gripper = DigitalGripper(mock_rtde, mock_rtde_r, pin=0)
151
- with pytest.raises(GripperError, match="do not support connection"):
152
- gripper.is_connected()
153
-
154
192
  def test_set_position_raises(self, mock_rtde, mock_rtde_r):
155
193
  gripper = DigitalGripper(mock_rtde, mock_rtde_r, pin=0)
156
194
  with pytest.raises(GripperError, match="do not support set_position"):
@@ -78,13 +78,12 @@ class TestRobotiqGripper:
78
78
  self.gripper.activate()
79
79
  self.gripper.open()
80
80
  calls = self.rtde.sendCustomScriptFunction.call_args_list
81
- # activate (combined check+activate+open) + open = 2 calls
81
+ # activate (check+activate only) + open = 2 calls
82
82
  assert len(calls) == 2
83
83
  # Check the activate call contains activation logic
84
84
  activate_code = calls[0][0][1]
85
85
  assert "rq_is_gripper_activated()" in activate_code
86
86
  assert "rq_activate_and_wait()" in activate_code
87
- assert "rq_open_and_wait()" in activate_code
88
87
  # Check the open-after-activate call contains rq_open_and_wait
89
88
  open_code = calls[1][0][1]
90
89
  assert "rq_open_and_wait()" in open_code
@@ -131,9 +130,6 @@ class TestRobotiqGripper:
131
130
  second_count = self.rtde.sendCustomScriptFunction.call_count
132
131
  assert second_count - first_count == 1
133
132
 
134
- def test_is_connected(self):
135
- assert self.gripper.is_connected() is True
136
-
137
133
  def test_disconnect_resets_activation(self):
138
134
  """disconnect() resets activation flag; must call activate() again."""
139
135
  self.gripper.activate()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes