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/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."""