urkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- urkit/__init__.py +99 -0
- urkit/__main__.py +116 -0
- urkit/cli/__init__.py +6 -0
- urkit/cli/colors.py +99 -0
- urkit/cli/connection_monitor.py +154 -0
- urkit/cli/points.py +293 -0
- urkit/cli/teach.py +1181 -0
- urkit/config.py +78 -0
- urkit/connection.py +580 -0
- urkit/exceptions.py +51 -0
- urkit/geometry.py +413 -0
- urkit/gripper/__init__.py +36 -0
- urkit/gripper/base.py +116 -0
- urkit/gripper/digital.py +140 -0
- urkit/gripper/presets.py +113 -0
- urkit/gripper/robotiq.py +322 -0
- urkit/gripper/robotiq_preamble.py +1356 -0
- urkit/io.py +311 -0
- urkit/motion.py +504 -0
- urkit/points.py +277 -0
- urkit/robot.py +1510 -0
- urkit/telemetry.py +177 -0
- urkit-0.1.0.dist-info/METADATA +612 -0
- urkit-0.1.0.dist-info/RECORD +27 -0
- urkit-0.1.0.dist-info/WHEEL +5 -0
- urkit-0.1.0.dist-info/entry_points.txt +2 -0
- urkit-0.1.0.dist-info/top_level.txt +1 -0
urkit/config.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Configuration loading utilities.
|
|
2
|
+
|
|
3
|
+
Centralized YAML config loading shared by examples and the CLI.
|
|
4
|
+
Resolves ``config.yaml`` relative to the project root or an explicit path.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from urkit import load_config
|
|
9
|
+
|
|
10
|
+
config = load_config() # tries config.yaml in the project root
|
|
11
|
+
config = load_config("/path/to/my.yaml") # explicit path
|
|
12
|
+
|
|
13
|
+
# Or create a robot directly from config:
|
|
14
|
+
from urkit import URRobot
|
|
15
|
+
robot = URRobot.from_config("config.yaml")
|
|
16
|
+
robot = URRobot.from_config("config.yaml", ip="10.0.0.50") # override IP
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import yaml
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["resolve_config", "load_config"]
|
|
27
|
+
|
|
28
|
+
_DEFAULT_NAME = "config.yaml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_config(path: Path | str | None = None) -> Path | None:
|
|
32
|
+
"""Find and return a config file path, or ``None`` if not found.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
path: Explicit path to a YAML file. If ``None``, searches for
|
|
36
|
+
``config.yaml`` in the project root first, then the CWD.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
A :class:`Path` if the file exists, otherwise ``None``.
|
|
40
|
+
"""
|
|
41
|
+
if path is not None:
|
|
42
|
+
p = Path(path)
|
|
43
|
+
return p if p.exists() else None
|
|
44
|
+
|
|
45
|
+
# Try project root (parent of src/urkit)
|
|
46
|
+
project_root = Path(__file__).resolve().parent.parent.parent
|
|
47
|
+
candidate = project_root / _DEFAULT_NAME
|
|
48
|
+
if candidate.exists():
|
|
49
|
+
return candidate
|
|
50
|
+
|
|
51
|
+
# Fallback: CWD
|
|
52
|
+
candidate = Path.cwd() / _DEFAULT_NAME
|
|
53
|
+
if candidate.exists():
|
|
54
|
+
return candidate
|
|
55
|
+
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def load_config(path: Path | str | None = None) -> dict:
|
|
60
|
+
"""Load a YAML config file and return it as a dict.
|
|
61
|
+
|
|
62
|
+
Returns ``{}`` if the file is not found, empty, or invalid.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
path: Explicit path to a YAML file. If ``None``, uses
|
|
66
|
+
:func:`resolve_config` to search for ``config.yaml``.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
A dict with config keys, or ``{}`` on any error.
|
|
70
|
+
"""
|
|
71
|
+
resolved = resolve_config(path)
|
|
72
|
+
if resolved is None:
|
|
73
|
+
return {}
|
|
74
|
+
try:
|
|
75
|
+
with open(resolved, "r") as f:
|
|
76
|
+
return yaml.safe_load(f) or {}
|
|
77
|
+
except Exception:
|
|
78
|
+
return {}
|
urkit/connection.py
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
"""Pre-connection validation for Universal Robots e-Series.
|
|
2
|
+
|
|
3
|
+
Performs ping, port checks, and remote mode verification.
|
|
4
|
+
Connects to RTDE interfaces using ur_rtde's default constructor,
|
|
5
|
+
which uploads its own control script (no Dashboard, .urp, or URCap
|
|
6
|
+
program needed). Raises typed exceptions with actionable messages.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import socket
|
|
13
|
+
import time
|
|
14
|
+
from typing import TYPE_CHECKING, Tuple
|
|
15
|
+
|
|
16
|
+
from urkit.exceptions import URKitConnectionError as ConnectionError
|
|
17
|
+
from urkit.exceptions import RobotNotInRemoteModeError
|
|
18
|
+
from urkit.exceptions import RtdeRegisterConflictError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from rtde_control import RTDEControlInterface
|
|
22
|
+
from rtde_io import RTDEIOInterface
|
|
23
|
+
from rtde_receive import RTDEReceiveInterface
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# Required ports and their purposes
|
|
28
|
+
_REQUIRED_PORTS: list[tuple[int, str]] = [
|
|
29
|
+
(30004, "RTDE (data receive)"),
|
|
30
|
+
(29999, "Dashboard"),
|
|
31
|
+
(30001, "Primary interface"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# RTDE port for control (also required)
|
|
35
|
+
_RTDE_CONTROL_PORT = 30003
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ping(ip: str, timeout: float = 3.0) -> bool:
|
|
39
|
+
"""Ping the robot IP to verify basic network reachability.
|
|
40
|
+
|
|
41
|
+
Uses a TCP connect to port 30001 (primary interface) as a
|
|
42
|
+
lightweight ping since ICMP may be blocked on industrial networks.
|
|
43
|
+
"""
|
|
44
|
+
try:
|
|
45
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
46
|
+
s.settimeout(timeout)
|
|
47
|
+
s.connect((ip, 30001))
|
|
48
|
+
return True
|
|
49
|
+
except (socket.timeout, socket.error, OSError):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _check_port(ip: str, port: int, timeout: float = 2.0) -> bool:
|
|
54
|
+
"""Check if a TCP port is open on the robot.
|
|
55
|
+
|
|
56
|
+
Returns True if the port accepts a connection within timeout.
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
60
|
+
s.settimeout(timeout)
|
|
61
|
+
s.connect((ip, port))
|
|
62
|
+
return True
|
|
63
|
+
except (socket.timeout, socket.error, OSError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _check_all_ports(ip: str) -> dict[int, str]:
|
|
68
|
+
"""Check all required ports. Returns {port: status} dict."""
|
|
69
|
+
results: dict[int, str] = {}
|
|
70
|
+
for port, description in _REQUIRED_PORTS:
|
|
71
|
+
results[port] = "open" if _check_port(ip, port) else "closed"
|
|
72
|
+
|
|
73
|
+
# Also check RTDE control port
|
|
74
|
+
control_open = _check_port(ip, _RTDE_CONTROL_PORT)
|
|
75
|
+
results[_RTDE_CONTROL_PORT] = "open" if control_open else "closed"
|
|
76
|
+
return results
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _check_remote_mode(ip: str, timeout: float = 5.0) -> bool:
|
|
80
|
+
"""Check if the robot is in remote control mode via Dashboard protocol.
|
|
81
|
+
|
|
82
|
+
Sends 'is in remote control\\n' to port 29999 and parses the response.
|
|
83
|
+
Returns True if remote control mode is active.
|
|
84
|
+
|
|
85
|
+
Uses 'is in remote control' rather than 'robotmode' because robotmode
|
|
86
|
+
only returns a string containing "remote control" when a remote program
|
|
87
|
+
is actively executing. When the robot is idle (brakes released, no
|
|
88
|
+
program running), robotmode returns "IDLE" — even though remote control
|
|
89
|
+
is enabled. 'is in remote control' returns "True"/"False" regardless
|
|
90
|
+
of whether a program is running.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
94
|
+
s.settimeout(timeout)
|
|
95
|
+
s.connect((ip, 29999))
|
|
96
|
+
|
|
97
|
+
# Read welcome banner
|
|
98
|
+
s.recv(1024)
|
|
99
|
+
|
|
100
|
+
# Query remote control status
|
|
101
|
+
s.send(b"is in remote control\n")
|
|
102
|
+
response = s.recv(1024).decode("utf-8").strip()
|
|
103
|
+
|
|
104
|
+
# Response is "True" or "False"
|
|
105
|
+
return response.lower() == "true"
|
|
106
|
+
|
|
107
|
+
except (socket.timeout, socket.error, OSError):
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _validate_connection(ip: str, timeout: float = 5.0) -> dict[str, object]:
|
|
112
|
+
"""Validate that the robot is reachable and required ports are open.
|
|
113
|
+
|
|
114
|
+
Performs:
|
|
115
|
+
1. Ping check (TCP connect to port 30001)
|
|
116
|
+
2. Required port checks (30004, 29999, 30001, 30003)
|
|
117
|
+
|
|
118
|
+
Does not check remote mode — that is handled separately by callers.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
ip: Robot IP address.
|
|
122
|
+
timeout: Timeout in seconds for each check.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dict with validation results:
|
|
126
|
+
{
|
|
127
|
+
"ping_ok": bool,
|
|
128
|
+
"ports": {port: "open"/"closed", ...},
|
|
129
|
+
"ok": bool,
|
|
130
|
+
"errors": list[str],
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
ConnectionError: If any validation step fails.
|
|
135
|
+
"""
|
|
136
|
+
errors: list[str] = []
|
|
137
|
+
|
|
138
|
+
# Step 1: Ping
|
|
139
|
+
ping_ok = _ping(ip, timeout=timeout)
|
|
140
|
+
if not ping_ok:
|
|
141
|
+
errors.append(
|
|
142
|
+
f"Robot at {ip} is not reachable. "
|
|
143
|
+
f"Check network cable, IP address, and that the robot is powered on."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# Step 2: Port checks
|
|
147
|
+
ports = _check_all_ports(ip)
|
|
148
|
+
closed_ports = [p for p, status in ports.items() if status == "closed"]
|
|
149
|
+
for port in closed_ports:
|
|
150
|
+
desc = next((d for p, d in _REQUIRED_PORTS if p == port), "unknown service")
|
|
151
|
+
errors.append(f"Port {port} ({desc}) is closed on {ip}")
|
|
152
|
+
|
|
153
|
+
ok = ping_ok and len(closed_ports) == 0
|
|
154
|
+
|
|
155
|
+
result = {
|
|
156
|
+
"ping_ok": ping_ok,
|
|
157
|
+
"ports": ports,
|
|
158
|
+
"ok": ok,
|
|
159
|
+
"errors": errors,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if errors:
|
|
163
|
+
raise ConnectionError(
|
|
164
|
+
f"Connection validation failed for {ip}:\n" + "\n".join(f" - {e}" for e in errors)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _connect_rtde(
|
|
171
|
+
ip: str,
|
|
172
|
+
*,
|
|
173
|
+
frequency: float = 125.0,
|
|
174
|
+
max_wait: float = 30.0,
|
|
175
|
+
) -> Tuple[
|
|
176
|
+
"RTDEControlInterface",
|
|
177
|
+
"RTDEReceiveInterface",
|
|
178
|
+
"RTDEIOInterface",
|
|
179
|
+
]:
|
|
180
|
+
"""Connect to RTDE interfaces.
|
|
181
|
+
|
|
182
|
+
Uses ur_rtde's default constructor which uploads its own control
|
|
183
|
+
script to the robot's Secondary Interface (port 30002). No Dashboard
|
|
184
|
+
commands, .urp file, or URCap program are needed.
|
|
185
|
+
|
|
186
|
+
The user's only prerequisites are:
|
|
187
|
+
- Remote Control enabled on the teach pendant
|
|
188
|
+
- No conflicting fieldbus adapters (EtherNet/IP, PROFINET, MODBUS)
|
|
189
|
+
|
|
190
|
+
Retries with a socket timeout so the constructor does not block
|
|
191
|
+
indefinitely when the robot has just booted and the RTDE service
|
|
192
|
+
is not yet ready.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
ip: Robot IP address.
|
|
196
|
+
frequency: RTDE communication frequency (default 125 Hz).
|
|
197
|
+
max_wait: Maximum time (seconds) to spend retrying the RTDE
|
|
198
|
+
connection (default 30s).
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Tuple of (RTDEControlInterface, RTDEReceiveInterface, RTDEIOInterface).
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
ConnectionError: If ur_rtde is not installed or connection fails
|
|
205
|
+
for an unhandled reason.
|
|
206
|
+
RobotNotInRemoteModeError: If the robot is not in remote mode.
|
|
207
|
+
RtdeRegisterConflictError: If RTDE registers are claimed by
|
|
208
|
+
another protocol.
|
|
209
|
+
"""
|
|
210
|
+
try:
|
|
211
|
+
from rtde_control import RTDEControlInterface
|
|
212
|
+
from rtde_io import RTDEIOInterface
|
|
213
|
+
from rtde_receive import RTDEReceiveInterface
|
|
214
|
+
except ImportError:
|
|
215
|
+
raise ConnectionError(
|
|
216
|
+
"ur_rtde package is not installed. "
|
|
217
|
+
"Install it with: pip install ur_rtde"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
logger.info(
|
|
221
|
+
"Connecting to RTDE at %s (frequency=%sHz, max_wait=%ss)...",
|
|
222
|
+
ip, frequency, max_wait,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
start = time.time()
|
|
226
|
+
attempt = 0
|
|
227
|
+
|
|
228
|
+
while time.time() - start < max_wait:
|
|
229
|
+
attempt += 1
|
|
230
|
+
rtde_c = None
|
|
231
|
+
rtde_r = None
|
|
232
|
+
rtde_io = None
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
rtde_c = RTDEControlInterface(ip, frequency=frequency)
|
|
236
|
+
rtde_r = RTDEReceiveInterface(ip, frequency=frequency)
|
|
237
|
+
rtde_io = RTDEIOInterface(ip)
|
|
238
|
+
|
|
239
|
+
logger.info("RTDE connection established (attempt %d)", attempt)
|
|
240
|
+
return rtde_c, rtde_r, rtde_io
|
|
241
|
+
|
|
242
|
+
except (socket.timeout, socket.error, OSError) as e:
|
|
243
|
+
# Clean up partially created interfaces on failure
|
|
244
|
+
if rtde_io is not None:
|
|
245
|
+
try:
|
|
246
|
+
rtde_io.disconnect()
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
if rtde_r is not None:
|
|
250
|
+
try:
|
|
251
|
+
rtde_r.disconnect()
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
254
|
+
if rtde_c is not None:
|
|
255
|
+
try:
|
|
256
|
+
rtde_c.disconnect()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
remaining = max_wait - (time.time() - start)
|
|
261
|
+
if remaining <= 0:
|
|
262
|
+
break
|
|
263
|
+
|
|
264
|
+
logger.info(
|
|
265
|
+
"RTDE connection attempt %d failed (%s), retrying in 1s (%.0fs remaining)...",
|
|
266
|
+
attempt, e, remaining,
|
|
267
|
+
)
|
|
268
|
+
# Print progress every few attempts so the user doesn't think it's stuck
|
|
269
|
+
if attempt % 3 == 0:
|
|
270
|
+
print(
|
|
271
|
+
f" Waiting for RTDE service... ({remaining:.0fs remaining})",
|
|
272
|
+
flush=True,
|
|
273
|
+
)
|
|
274
|
+
time.sleep(1)
|
|
275
|
+
|
|
276
|
+
except RuntimeError as e:
|
|
277
|
+
# ur_rtde raises RuntimeError for its own failures (e.g.,
|
|
278
|
+
# "Failed to start control script"). Retry these — the robot
|
|
279
|
+
# may still be booting or a program may occupy the interface.
|
|
280
|
+
err_msg = str(e)
|
|
281
|
+
|
|
282
|
+
if "remote control" in err_msg.lower() or "not in remote mode" in err_msg.lower():
|
|
283
|
+
raise RobotNotInRemoteModeError(
|
|
284
|
+
f"Failed to connect to RTDE at {ip}. "
|
|
285
|
+
f"Please enable Remote Control: "
|
|
286
|
+
f"On the teach pendant, go to Settings → System → Remote Control → Enable. "
|
|
287
|
+
f"Then press the remote/local button to put the robot in remote mode."
|
|
288
|
+
) from e
|
|
289
|
+
|
|
290
|
+
if "register" in err_msg.lower() and (
|
|
291
|
+
"in use" in err_msg.lower() or "already" in err_msg.lower()
|
|
292
|
+
):
|
|
293
|
+
raise RtdeRegisterConflictError(
|
|
294
|
+
f"RTDE registers are in use by another protocol at {ip}. "
|
|
295
|
+
f"On the teach pendant, go to Installation → Fieldbus and disable "
|
|
296
|
+
f"EtherNet/IP, PROFINET, and MODBUS. Save and restart."
|
|
297
|
+
) from e
|
|
298
|
+
|
|
299
|
+
# Retryable RuntimeError (e.g., "Failed to start control script")
|
|
300
|
+
remaining = max_wait - (time.time() - start)
|
|
301
|
+
if remaining <= 0:
|
|
302
|
+
raise ConnectionError(
|
|
303
|
+
f"Failed to connect to RTDE interfaces at {ip}: {e}"
|
|
304
|
+
) from e
|
|
305
|
+
|
|
306
|
+
logger.info(
|
|
307
|
+
"RTDE connection attempt %d failed (%s), retrying in 1s (%.0fs remaining)...",
|
|
308
|
+
attempt, err_msg, remaining,
|
|
309
|
+
)
|
|
310
|
+
if attempt % 3 == 0:
|
|
311
|
+
print(
|
|
312
|
+
f" Waiting for RTDE service... ({remaining:.0fs remaining})",
|
|
313
|
+
flush=True,
|
|
314
|
+
)
|
|
315
|
+
time.sleep(1)
|
|
316
|
+
|
|
317
|
+
except Exception as e:
|
|
318
|
+
# Non-retryable errors
|
|
319
|
+
err_msg = str(e)
|
|
320
|
+
|
|
321
|
+
if "remote control" in err_msg.lower() or "not in remote mode" in err_msg.lower():
|
|
322
|
+
raise RobotNotInRemoteModeError(
|
|
323
|
+
f"Failed to connect to RTDE at {ip}. "
|
|
324
|
+
f"Please enable Remote Control: "
|
|
325
|
+
f"On the teach pendant, go to Settings → System → Remote Control → Enable. "
|
|
326
|
+
f"Then press the remote/local button to put the robot in remote mode."
|
|
327
|
+
) from e
|
|
328
|
+
|
|
329
|
+
if "register" in err_msg.lower() and (
|
|
330
|
+
"in use" in err_msg.lower() or "already" in err_msg.lower()
|
|
331
|
+
):
|
|
332
|
+
raise RtdeRegisterConflictError(
|
|
333
|
+
f"RTDE registers are in use by another protocol at {ip}. "
|
|
334
|
+
f"On the teach pendant, go to Installation → Fieldbus and disable "
|
|
335
|
+
f"EtherNet/IP, PROFINET, and MODBUS. Save and restart."
|
|
336
|
+
) from e
|
|
337
|
+
|
|
338
|
+
raise ConnectionError(
|
|
339
|
+
f"Failed to connect to RTDE interfaces at {ip}: {e}"
|
|
340
|
+
) from e
|
|
341
|
+
|
|
342
|
+
raise ConnectionError(
|
|
343
|
+
f"Failed to connect to RTDE at {ip} after {max_wait:.0fs} "
|
|
344
|
+
f"({attempt} attempts). The robot may still be booting. "
|
|
345
|
+
f"Wait a few seconds and try again."
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# Safety statuses that don't block normal operation
|
|
350
|
+
_SAFE_STATUSES = {"NORMAL", "REDUCED", "RECOVERY"}
|
|
351
|
+
|
|
352
|
+
# Safety statuses we can recover from via Dashboard
|
|
353
|
+
_RECOVERABLE_STATUSES = {"PROTECTIVE_STOP", "VIOLATION"}
|
|
354
|
+
|
|
355
|
+
# Safety statuses that require manual intervention on the teach pendant
|
|
356
|
+
_UNRECOVERABLE_STATUSES = {
|
|
357
|
+
"FAULT",
|
|
358
|
+
"SAFEGUARD_STOP",
|
|
359
|
+
"SYSTEM_EMERGENCY_STOP",
|
|
360
|
+
"ROBOT_EMERGENCY_STOP",
|
|
361
|
+
"AUTOMATIC_MODE_SAFEGUARD_STOP",
|
|
362
|
+
"SYSTEM_THREE_POSITION_ENABLING_STOP",
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _check_safety_status(ip: str, timeout: float = 5.0) -> str:
|
|
367
|
+
"""Query the robot's current safety status via Dashboard.
|
|
368
|
+
|
|
369
|
+
Returns the raw response string (e.g., "Safetystatus: NORMAL").
|
|
370
|
+
"""
|
|
371
|
+
s = _connect_dashboard(ip, timeout=timeout)
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
response = _dashboard_command(s, "safetystatus", timeout=3.0)
|
|
375
|
+
return response.strip()
|
|
376
|
+
finally:
|
|
377
|
+
s.close()
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _extract_safety_status(raw: str) -> str:
|
|
381
|
+
"""Extract the status token from a 'Safetystatus: <status>' response.
|
|
382
|
+
|
|
383
|
+
Handles multi-word statuses like 'SYSTEM_EMERGENCY_STOP'.
|
|
384
|
+
Returns the raw upper-cased string if parsing fails.
|
|
385
|
+
"""
|
|
386
|
+
if ":" in raw:
|
|
387
|
+
return raw.split(":", 1)[1].strip()
|
|
388
|
+
return raw.strip()
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _try_recover_safety(ip: str, timeout: float = 30.0) -> tuple[bool, str]:
|
|
392
|
+
"""Attempt to recover from a safety violation via Dashboard.
|
|
393
|
+
|
|
394
|
+
Recovery actions by status:
|
|
395
|
+
- PROTECTIVE_STOP → send `unlock protective stop`
|
|
396
|
+
- VIOLATION → send `close safety popup` then `restart safety`
|
|
397
|
+
(robot returns to POWER_OFF after restart safety)
|
|
398
|
+
- NORMAL / REDUCED / RECOVERY → no action needed
|
|
399
|
+
- FAULT / SAFEGUARD_STOP / EMERGENCY_STOP → cannot recover remotely
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
ip: Robot IP address.
|
|
403
|
+
timeout: Timeout for Dashboard connection.
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Tuple of (success, status).
|
|
407
|
+
- success=True means the robot is ready to proceed.
|
|
408
|
+
- success=False means manual intervention is required.
|
|
409
|
+
- status is the current safety status string.
|
|
410
|
+
|
|
411
|
+
Raises:
|
|
412
|
+
ConnectionError: If Dashboard is unreachable.
|
|
413
|
+
"""
|
|
414
|
+
raw = _check_safety_status(ip, timeout=timeout)
|
|
415
|
+
status = _extract_safety_status(raw).upper()
|
|
416
|
+
|
|
417
|
+
logger.info("Robot safety status: %s", status)
|
|
418
|
+
|
|
419
|
+
if status in _SAFE_STATUSES:
|
|
420
|
+
logger.info("Safety status OK: %s", status)
|
|
421
|
+
return True, status
|
|
422
|
+
|
|
423
|
+
if status in _RECOVERABLE_STATUSES:
|
|
424
|
+
try:
|
|
425
|
+
s = _connect_dashboard(ip, timeout=timeout)
|
|
426
|
+
try:
|
|
427
|
+
if status == "PROTECTIVE_STOP":
|
|
428
|
+
response = _dashboard_command(
|
|
429
|
+
|
|
430
|
+
s, "unlock protective stop", timeout=10.0
|
|
431
|
+
)
|
|
432
|
+
logger.info("Unlock protective stop: %s", response)
|
|
433
|
+
print(" Unlocking protective stop...", flush=True)
|
|
434
|
+
# Wait for the protective stop to fully release
|
|
435
|
+
time.sleep(2)
|
|
436
|
+
|
|
437
|
+
elif status == "VIOLATION":
|
|
438
|
+
# Close any safety popup first
|
|
439
|
+
try:
|
|
440
|
+
response = _dashboard_command(
|
|
441
|
+
|
|
442
|
+
s, "close safety popup", timeout=5.0
|
|
443
|
+
)
|
|
444
|
+
logger.info("Close safety popup: %s", response)
|
|
445
|
+
print(" Closing safety popup...", flush=True)
|
|
446
|
+
except ConnectionError:
|
|
447
|
+
logger.warning("Failed to close safety popup, continuing")
|
|
448
|
+
|
|
449
|
+
# Restart the safety stack
|
|
450
|
+
response = _dashboard_command(
|
|
451
|
+
|
|
452
|
+
s, "restart safety", timeout=10.0
|
|
453
|
+
)
|
|
454
|
+
logger.info("Restart safety: %s", response)
|
|
455
|
+
print(" Restarting safety stack...", flush=True)
|
|
456
|
+
# After restart safety, the robot is in POWER_OFF.
|
|
457
|
+
# The caller needs to power on and release brakes.
|
|
458
|
+
time.sleep(2)
|
|
459
|
+
|
|
460
|
+
finally:
|
|
461
|
+
s.close()
|
|
462
|
+
|
|
463
|
+
# Re-check status after recovery attempt
|
|
464
|
+
try:
|
|
465
|
+
raw = _check_safety_status(ip, timeout=timeout)
|
|
466
|
+
status = _extract_safety_status(raw).upper()
|
|
467
|
+
logger.info("Safety status after recovery: %s", status)
|
|
468
|
+
except ConnectionError:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
return True, status
|
|
472
|
+
|
|
473
|
+
except ConnectionError as e:
|
|
474
|
+
logger.warning("Safety recovery failed: %s", e)
|
|
475
|
+
return False, status
|
|
476
|
+
|
|
477
|
+
# Unrecoverable — requires manual intervention
|
|
478
|
+
return False, status
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _get_safety_help_message(status: str) -> str:
|
|
482
|
+
"""Return a user-friendly help message for an unrecoverable safety status."""
|
|
483
|
+
messages = {
|
|
484
|
+
"FAULT": (
|
|
485
|
+
"The robot is in a FAULT state (hardware fault or critical error).\n"
|
|
486
|
+
" This cannot be cleared remotely.\n"
|
|
487
|
+
" On the teach pendant:\n"
|
|
488
|
+
" 1. Acknowledge the fault message\n"
|
|
489
|
+
" 2. Fix the underlying issue\n"
|
|
490
|
+
" 3. Press the reset button if needed\n"
|
|
491
|
+
" 4. Then try again"
|
|
492
|
+
),
|
|
493
|
+
"SAFEGUARD_STOP": (
|
|
494
|
+
"The robot is in a SAFEGUARD_STOP (safety circuit open).\n"
|
|
495
|
+
" Check the safety circuit (light curtains, e-stop chain, etc.).\n"
|
|
496
|
+
" Once the circuit is closed, the robot should recover automatically."
|
|
497
|
+
),
|
|
498
|
+
"SYSTEM_EMERGENCY_STOP": (
|
|
499
|
+
"The robot is in a SYSTEM_EMERGENCY_STOP.\n"
|
|
500
|
+
" On the teach pendant:\n"
|
|
501
|
+
" 1. Release the emergency stop button\n"
|
|
502
|
+
" 2. Press the reset button\n"
|
|
503
|
+
" 3. Then try again"
|
|
504
|
+
),
|
|
505
|
+
"ROBOT_EMERGENCY_STOP": (
|
|
506
|
+
"The robot is in a ROBOT_EMERGENCY_STOP.\n"
|
|
507
|
+
" On the teach pendant:\n"
|
|
508
|
+
" 1. Release the emergency stop button\n"
|
|
509
|
+
" 2. Press the reset button\n"
|
|
510
|
+
" 3. Then try again"
|
|
511
|
+
),
|
|
512
|
+
"AUTOMATIC_MODE_SAFEGUARD_STOP": (
|
|
513
|
+
"The robot is in an AUTOMATIC_MODE_SAFEGUARD_STOP.\n"
|
|
514
|
+
" Check the automatic mode safety circuit.\n"
|
|
515
|
+
" Once resolved, the robot should recover."
|
|
516
|
+
),
|
|
517
|
+
"SYSTEM_THREE_POSITION_ENABLING_STOP": (
|
|
518
|
+
"The robot requires the three-position enabling device.\n"
|
|
519
|
+
" Press and hold the enabling device on the teach pendant,\n"
|
|
520
|
+
" or clear the stop from the pendant."
|
|
521
|
+
),
|
|
522
|
+
}
|
|
523
|
+
return messages.get(
|
|
524
|
+
status,
|
|
525
|
+
f"Safety status {status} requires manual intervention on the teach pendant."
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _connect_dashboard(ip: str, timeout: float = 5.0) -> socket.socket:
|
|
530
|
+
"""Open a raw TCP socket to the Dashboard server.
|
|
531
|
+
|
|
532
|
+
Args:
|
|
533
|
+
ip: Robot IP address.
|
|
534
|
+
timeout: Socket timeout in seconds.
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Connected socket object (caller is responsible for closing).
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
ConnectionError: If Dashboard connection fails.
|
|
541
|
+
"""
|
|
542
|
+
try:
|
|
543
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
544
|
+
s.settimeout(timeout)
|
|
545
|
+
s.connect((ip, 29999))
|
|
546
|
+
# Read welcome banner
|
|
547
|
+
s.recv(1024)
|
|
548
|
+
return s
|
|
549
|
+
except (socket.timeout, socket.error, OSError) as e:
|
|
550
|
+
raise ConnectionError(
|
|
551
|
+
f"Dashboard server at {ip}:29999 is not reachable: {e}"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _dashboard_command(s: socket.socket, command: str, timeout: float = 5.0) -> str:
|
|
556
|
+
"""Send a command to the Dashboard server and return the response.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
s: Connected Dashboard socket.
|
|
560
|
+
command: Command string (e.g., "power on", "robotmode").
|
|
561
|
+
timeout: Response timeout in seconds.
|
|
562
|
+
|
|
563
|
+
Returns:
|
|
564
|
+
Response string from the robot.
|
|
565
|
+
|
|
566
|
+
Raises:
|
|
567
|
+
ConnectionError: If the command times out or fails.
|
|
568
|
+
"""
|
|
569
|
+
try:
|
|
570
|
+
s.send(f"{command}\n".encode("utf-8"))
|
|
571
|
+
response = s.recv(1024).decode("utf-8").strip()
|
|
572
|
+
return response
|
|
573
|
+
except socket.timeout:
|
|
574
|
+
raise ConnectionError(
|
|
575
|
+
f"Dashboard command '{command}' timed out on {s}"
|
|
576
|
+
)
|
|
577
|
+
except Exception as e:
|
|
578
|
+
raise ConnectionError(
|
|
579
|
+
f"Dashboard command '{command}' failed: {e}"
|
|
580
|
+
)
|
urkit/exceptions.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""URKit exception hierarchy.
|
|
2
|
+
|
|
3
|
+
All urkit errors derive from URKitError. Subclasses provide
|
|
4
|
+
descriptive, actionable messages for failure modes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class URKitError(Exception):
|
|
11
|
+
"""Base exception for all URKit errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class URKitConnectionError(URKitError):
|
|
15
|
+
"""Raised when robot connection or pre-connection validation fails.
|
|
16
|
+
|
|
17
|
+
Covers ping failures, port checks, remote mode checks, and RTDE
|
|
18
|
+
connection drops.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MotionError(URKitError):
|
|
23
|
+
"""Raised when a motion command fails."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GripperError(URKitError):
|
|
27
|
+
"""Raised when a gripper operation fails."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PointError(URKitError):
|
|
31
|
+
"""Raised when point file operations fail (corrupt file, not found, etc.)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class URKitIOError(URKitError):
|
|
35
|
+
"""Raised when a digital I/O operation fails."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TelemetryError(URKitError):
|
|
39
|
+
"""Raised when telemetry data cannot be read."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class URKitRuntimeError(URKitError):
|
|
43
|
+
"""Raised when the robot is in an invalid state during runtime."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RobotNotInRemoteModeError(URKitError):
|
|
47
|
+
"""Raised when RTDE connection fails because the robot is not in remote mode."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RtdeRegisterConflictError(URKitError):
|
|
51
|
+
"""Raised when RTDE registers are already claimed by another protocol."""
|