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/cli/teach.py ADDED
@@ -0,0 +1,1181 @@
1
+ """Interactive teach pendant CLI for Universal Robots e-Series.
2
+
3
+ A single terminal-based UI for manual robot control, point management,
4
+ and freedrive — matching the pattern from robot_mover's point_saver.cpp.
5
+
6
+ Usage:
7
+ urkit 192.168.1.100 # connect with IP
8
+ urkit # reads robot_ip from config
9
+ urkit -v 192.168.1.100 # verbose (debug boot commands)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ipaddress
15
+ import logging
16
+ import math
17
+ import os
18
+ import select
19
+ import signal
20
+ import sys
21
+ import termios
22
+ import time
23
+ import tty
24
+ from pathlib import Path
25
+
26
+ import yaml
27
+
28
+ from urkit import load_config, resolve_config
29
+ from urkit.cli.colors import blue, cyan, dim, green, red, yellow
30
+ from urkit.cli.connection_monitor import ConnectionMonitor
31
+ from urkit.cli.points import _interactive_points_filter
32
+ from urkit.exceptions import URKitConnectionError
33
+ from urkit.connection import (
34
+ _connect_dashboard,
35
+ _dashboard_command,
36
+ _ping,
37
+ )
38
+ from urkit.exceptions import GripperError, MotionError, PointError, URKitConnectionError as ConnectionError
39
+ from urkit.geometry import MoveFrame, orient_tcp_down
40
+ from urkit.gripper.presets import DigitalGripperConfig, GripperPreset, PRESETS
41
+ from urkit.motion import FreedriveMode
42
+ from urkit.robot import URRobot
43
+
44
+ logger = logging.getLogger(__name__)
45
+
46
+ # Module-level monitor reference — set by _teach_pendant() so input helpers
47
+ # (_filter_select_points, _read_input) can check for faults without threading
48
+ # the parameter through every function.
49
+ _cli_monitor: "ConnectionMonitor | None" = None
50
+
51
+ # Default configuration path — resolved relative to the calling script's directory
52
+ def _resolve_default_config_path() -> Path:
53
+ """Resolve the default config.yaml path for save operations."""
54
+ resolved = resolve_config()
55
+ if resolved is not None:
56
+ return resolved
57
+ return Path.cwd() / "config.yaml"
58
+
59
+ # Default step sizes
60
+ _DEFAULT_LINEAR_STEP = 0.005 # 5mm
61
+ _DEFAULT_LINEAR_STEP_MIN = 0.0001 # 0.1mm
62
+ _DEFAULT_LINEAR_STEP_MAX = 0.05 # 50mm (5cm)
63
+ _DEFAULT_ANGULAR_STEP = math.radians(1) # 1 degree in radians
64
+ _DEFAULT_ANGULAR_STEP_MIN = math.radians(0.1) # 0.1 degrees
65
+ _DEFAULT_ANGULAR_STEP_MAX = math.radians(25) # 25 degrees
66
+
67
+ # Safe motion limits (UR protective stop thresholds)
68
+ _MAX_VEL = 3.0 # m/s
69
+ _MAX_ACC = 6.0 # m/s²
70
+ _MAX_ANG_VEL = 1.5 # rad/s
71
+ _MAX_ANG_ACC = 3.0 # rad/s²
72
+
73
+ # ------------------------------------------------------------------
74
+ # Dynamic velocity/acceleration from step size
75
+ # ------------------------------------------------------------------
76
+
77
+
78
+ def _compute_vel_acc(
79
+ linear_step: float,
80
+ angular_step: float,
81
+ is_angular: bool = False,
82
+ ) -> tuple[float, float]:
83
+ """Compute velocity & acceleration from step size for ~constant travel time.
84
+
85
+ Scales so the robot reaches the target in roughly the same time
86
+ regardless of step size, capped at safe limits to avoid protective stops.
87
+
88
+ Args:
89
+ linear_step: Current linear step in meters.
90
+ angular_step: Current angular step in radians.
91
+ is_angular: If True, compute for angular (orientation) move.
92
+
93
+ Returns:
94
+ (velocity, acceleration) tuple.
95
+ """
96
+ if is_angular:
97
+ vel = min(angular_step * 50.0, _MAX_ANG_VEL)
98
+ acc = min(vel * 3.0, _MAX_ANG_ACC)
99
+ else:
100
+ vel = min(linear_step * 30.0, _MAX_VEL)
101
+ acc = min(vel * 3.0, _MAX_ACC)
102
+ return vel, acc
103
+
104
+ def _save_config(config: dict) -> None:
105
+ """Write configuration to <script_dir>/config.yaml."""
106
+ try:
107
+ config_path = _resolve_default_config_path()
108
+ config_path.parent.mkdir(parents=True, exist_ok=True)
109
+ with open(config_path, "w") as f:
110
+ yaml.dump(config, f, default_flow_style=False)
111
+ logger.info("Config saved to %s", config_path)
112
+ except Exception as e:
113
+ logger.warning("Failed to save config: %s", e)
114
+
115
+
116
+ def _validate_ip(ip: str) -> bool:
117
+ """Check if a string is a valid IPv4 address."""
118
+ try:
119
+ ipaddress.IPv4Address(ip)
120
+ return True
121
+ except (ipaddress.AddressValueError, ValueError):
122
+ return False
123
+
124
+
125
+
126
+
127
+
128
+ # ------------------------------------------------------------------
129
+ # Raw terminal I/O
130
+ # ------------------------------------------------------------------
131
+
132
+ class _RawTerminal:
133
+ """Manage raw terminal mode with tcgetattr/tcsetattr.
134
+
135
+ Context-manager based: enters raw mode on __enter__, restores on __exit__.
136
+ Single-character non-blocking reads via getkey().
137
+ """
138
+
139
+ def __enter__(self) -> "_RawTerminal":
140
+ self._old_settings = termios.tcgetattr(sys.stdin)
141
+ tty.setcbreak(sys.stdin.fileno())
142
+ return self
143
+
144
+ def __exit__(self, *args) -> None:
145
+ if hasattr(self, "_old_settings"):
146
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._old_settings)
147
+
148
+ def getkey(self) -> str:
149
+ """Read a single character without waiting for Enter.
150
+
151
+ Returns:
152
+ Single character string, or empty string if no input.
153
+ """
154
+ ready, _, _ = select.select([sys.stdin], [], [], 0.05)
155
+ if ready:
156
+ return sys.stdin.read(1)
157
+ return ""
158
+
159
+
160
+ # ------------------------------------------------------------------
161
+ # UI rendering
162
+ # ------------------------------------------------------------------
163
+
164
+ def _draw_screen(
165
+ robot: URRobot,
166
+ state: dict,
167
+ messages: list[str] | None = None,
168
+ ) -> None:
169
+ """Draw the full teach pendant screen.
170
+
171
+ Single-screen layout matching point_saver style:
172
+ - Header with title
173
+ - Status section (position, orientation, speed, gripper)
174
+ - Control reference (key bindings)
175
+ """
176
+ width = 72
177
+
178
+ # Get live telemetry
179
+ try:
180
+ pose = robot.get_tcp_pose()
181
+ joints = robot.get_joint_positions()
182
+ tcp_force = robot.get_tcp_force()
183
+ except Exception:
184
+ pose = [0, 0, 0, 0, 0, 0]
185
+ joints = [0, 0, 0, 0, 0, 0]
186
+ tcp_force = [0, 0, 0, 0, 0, 0]
187
+
188
+ 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)")
196
+
197
+ lines: list[str] = []
198
+
199
+ # Header
200
+ lines.append(dim("=" * width))
201
+ lines.append(cyan(f" === URKit Teach Pendant ===").center(width))
202
+ lines.append(cyan(f" IP: {robot.ip}").center(width))
203
+
204
+ # Status section
205
+ lines.append("")
206
+ lw = 13 # label width for left alignment
207
+ lines.append(f" {blue('Position'.ljust(lw))} X={pose[0]:+5.3f} Y={pose[1]:+5.3f} Z={pose[2]:+5.3f}")
208
+ lines.append(f" {blue('Orientation'.ljust(lw))} R={math.degrees(pose[3]):+5.1f} P={math.degrees(pose[4]):+5.1f} Y={math.degrees(pose[5]):+5.1f}")
209
+ lines.append(f" {blue('Step:'.ljust(lw))} L={state['linear_step']*1000:.1f}mm A={math.degrees(state['angular_step']):.1f}°")
210
+ lines.append(f" {blue('Frame:'.ljust(lw))} {green(state['move_frame'].name)} {dim('[M: toggle BASE/TOOL]')}")
211
+ goto_label = "Cartesian" if state["goto_mode"] == "cartesian" else "Joint"
212
+ lines.append(f" {blue('Go To:'.ljust(lw))} {green(goto_label)} {dim('[N: toggle Cartesian/Joint]')}")
213
+ lines.append(f" {blue('Gripper:'.ljust(lw))} {gripper_state}")
214
+ if state["freedrive"]:
215
+ lines.append(f" {blue('Freedrive:'.ljust(lw))} {green('ON')} ({state['freedrive_mode'].name})")
216
+ else:
217
+ lines.append(f" {blue('Freedrive:'.ljust(lw))} {red('OFF')} {dim('[F: cycle ALL/XYZ]')}")
218
+ slider_pct = int(state["speed_slider"] * 100)
219
+ slider_color = green if state["speed_slider"] >= 0.5 else yellow if state["speed_slider"] >= 0.2 else red
220
+ lines.append(f" {blue('Speed:'.ljust(lw))} {slider_color(f'{slider_pct}%')} {dim('[0: set]')}")
221
+
222
+ # Robot fault status
223
+ try:
224
+ if robot.is_protective_stopped():
225
+ lines.append(f" {red('!! Protective Stop Active !!')}")
226
+ if robot.is_emergency_stopped():
227
+ lines.append(f" {red('!! Emergency Stop Active !!')}")
228
+ except Exception:
229
+ pass
230
+
231
+ # Status message line (only when there's something to show)
232
+ if messages:
233
+ status_msg = messages[-1][:width - 4]
234
+ if status_msg.startswith("Error"):
235
+ lines.append(f" {red(status_msg)}")
236
+ elif status_msg.startswith("Saved") or status_msg.startswith("Moved") or status_msg.startswith("Deleted") or status_msg.startswith("Renamed"):
237
+ lines.append(f" {green(status_msg)}")
238
+ elif status_msg == "Cancelled":
239
+ lines.append(f" {yellow(status_msg)}")
240
+ else:
241
+ lines.append(f" {status_msg}")
242
+
243
+ lines.append(dim("-" * width))
244
+
245
+ # Control reference
246
+ lines.append(f" {yellow('MOVE:')} {yellow('W/S')}: ±X {yellow('A/D')}: ±Y {yellow('Q/E')}: ±Z")
247
+ 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")
253
+ lines.append(dim("=" * width))
254
+
255
+ # Clear and redraw
256
+ print("\033[2J\033[1;1H", end="")
257
+ print("\n".join(lines))
258
+
259
+
260
+ def _draw_help() -> None:
261
+ """Print the full help overlay."""
262
+ width = 72
263
+ lines: list[str] = []
264
+
265
+ lines.append("=" * width)
266
+ lines.append(" === URKit Teach Pendant — Help ===".center(width))
267
+ lines.append("=" * width)
268
+ lines.append("")
269
+ lines.append(" MOVE:")
270
+ lines.append(" W/S → X ± move")
271
+ lines.append(" A/D → Y ± move")
272
+ lines.append(" Q/E → Z ± move")
273
+ lines.append(" U/O → Roll ± move")
274
+ lines.append(" I/K → Pitch ± move")
275
+ lines.append(" J/L → Yaw ± move")
276
+ lines.append("")
277
+ lines.append(" STEP SIZE:")
278
+ lines.append(" 1/2 → Linear step ÷2/×2")
279
+ lines.append(" 3/4 → Angular step ÷2/×2")
280
+ lines.append("")
281
+ lines.append(" GRIPPER:")
282
+ lines.append(" X → Open")
283
+ lines.append(" C → Close")
284
+ lines.append(" V → Set position (mm)")
285
+ lines.append("")
286
+ lines.append(" POINTS:")
287
+ lines.append(" B → Save current pose")
288
+ lines.append(" G → Go to saved point (ask cartesian/joint)")
289
+ lines.append(" H → Delete saved point")
290
+ lines.append(" R → Rename saved point")
291
+ lines.append("")
292
+ lines.append(" OTHER:")
293
+ lines.append(" F → Cycle freedrive: OFF → ALL → XYZ → OFF")
294
+ lines.append(" M → Toggle move frame: BASE / TOOL")
295
+ lines.append(" T → Orient TCP downward (roll=180°)")
296
+ lines.append(" 0 → Set speed slider (0-100%)")
297
+ lines.append("")
298
+ lines.append(" ESC → Exit")
299
+ lines.append("=" * width)
300
+ print("\n".join(lines))
301
+
302
+
303
+ # ------------------------------------------------------------------
304
+ # Point submenus (restore canonical terminal for multi-char input)
305
+ # ------------------------------------------------------------------
306
+
307
+ def _configure_terminal() -> list:
308
+ """Disable canonical mode and echo."""
309
+ old_settings = termios.tcgetattr(sys.stdin)
310
+ tty.setcbreak(sys.stdin.fileno())
311
+ return old_settings
312
+
313
+
314
+ def _restore_terminal(old_settings: list) -> None:
315
+ """Restore canonical mode and echo."""
316
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
317
+
318
+
319
+ def _read_input(prompt: str, max_len: int = 30) -> str | None:
320
+ """Read a line of input with backspace support.
321
+
322
+ Uses cbreak mode with echo disabled — we handle all character
323
+ display ourselves to avoid conflicts with terminal echo.
324
+
325
+ Args:
326
+ prompt: Prompt string.
327
+ max_len: Maximum input length.
328
+
329
+ Returns:
330
+ Input string, or None if cancelled (ESC/empty).
331
+ """
332
+ old_settings = termios.tcgetattr(sys.stdin)
333
+ tty.setcbreak(sys.stdin.fileno())
334
+ # Disable echo — we handle character display ourselves to avoid conflicts
335
+ settings = termios.tcgetattr(sys.stdin)
336
+ settings[3] = settings[3] & ~termios.ECHO
337
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, settings)
338
+
339
+ sys.stdout.write(prompt)
340
+ sys.stdout.flush()
341
+
342
+ name = ""
343
+ while True:
344
+ ready, _, _ = select.select([sys.stdin], [], [], 0.1)
345
+ if not ready:
346
+ if _cli_monitor and _cli_monitor.fault_detected:
347
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
348
+ raise URKitConnectionError(
349
+ f"Robot fault detected: {_cli_monitor._reason or 'RTDE connection lost'}. "
350
+ "RTDE connection lost."
351
+ )
352
+ continue
353
+ ch = sys.stdin.read(1)
354
+ if ch == "\x1b":
355
+ sys.stdout.write("\033[K")
356
+ sys.stdout.flush()
357
+ return None
358
+ elif ch == "\x03": # Ctrl+C
359
+ sys.stdout.write("\033[K")
360
+ sys.stdout.flush()
361
+ return None
362
+ elif ch == "\x08" or ch == "\x7f": # Backspace
363
+ if name:
364
+ name = name[:-1]
365
+ sys.stdout.write("\b \b")
366
+ sys.stdout.flush()
367
+ elif ch == "\n" or ch == "\r":
368
+ sys.stdout.write("\033[K")
369
+ sys.stdout.flush()
370
+ break
371
+ elif len(name) < max_len and ch.isprintable():
372
+ name += ch
373
+ sys.stdout.write(ch)
374
+ sys.stdout.flush()
375
+
376
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
377
+ sys.stdout.write("\r\033[K\n")
378
+ sys.stdout.flush()
379
+ return name.strip()
380
+
381
+
382
+ def _submenu_save_point(
383
+ robot: URRobot, messages: list[str]
384
+ ) -> None:
385
+ """Save current pose as a named point."""
386
+ name = _read_input(" Point name: ")
387
+ if not name:
388
+ messages.append("Cancelled")
389
+ return
390
+ try:
391
+ # Check if point already exists — ask for confirmation to overwrite
392
+ if name in robot.point_names():
393
+ confirm = _read_input(
394
+ f" Point '{name}' already exists. Overwrite? (y/n) "
395
+ )
396
+ if confirm != "y":
397
+ messages.append("Cancelled")
398
+ return
399
+
400
+ robot.save_point(name)
401
+ messages.append(f"Saved '{name}'")
402
+ except URKitConnectionError:
403
+ raise
404
+ except Exception as e:
405
+ messages.append(f"Error: {e}")
406
+
407
+
408
+ def _highlight_match(text: str, substring: str) -> str:
409
+ """Highlight matching substring occurrences in text using ANSI colors."""
410
+ if not substring:
411
+ return text
412
+ lower_text = text.lower()
413
+ lower_sub = substring.lower()
414
+ result = []
415
+ start = 0
416
+ while True:
417
+ idx = lower_text.find(lower_sub, start)
418
+ if idx == -1:
419
+ result.append(text[start:])
420
+ break
421
+ result.append(text[start:idx])
422
+ result.append("\033[1;32m" + text[idx:idx + len(substring)] + "\033[0m")
423
+ start = idx + len(substring)
424
+ return "".join(result)
425
+
426
+
427
+ def _filter_select_points(
428
+ all_points: list[str],
429
+ title: str,
430
+ ) -> str | None:
431
+ """Interactive point selector with real-time text filtering.
432
+
433
+ User types characters to filter the list. Arrow keys navigate the
434
+ filtered list. Matching text is highlighted in green. Press Enter
435
+ to select, Backspace to remove chars, ESC to cancel.
436
+
437
+ Returns:
438
+ Selected point name, or None if cancelled.
439
+ """
440
+ filter_str = ""
441
+ cursor = 0
442
+
443
+ # Set terminal to raw mode once, outside the loop
444
+ old_settings = termios.tcgetattr(sys.stdin)
445
+ new_settings = termios.tcgetattr(sys.stdin)
446
+ new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
447
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
448
+
449
+ fd = sys.stdin.fileno()
450
+
451
+ try:
452
+ while True:
453
+ # Clear screen and draw
454
+ sys.stdout.write("\033[2J\033[1;1H")
455
+ sys.stdout.write(cyan(f" === {title} ===") + "\n")
456
+ sys.stdout.write(dim(" Arrows navigate · Type to filter · Enter select · ESC cancel") + "\n\n")
457
+ sys.stdout.write(f" {blue('Search:')} {filter_str}\n")
458
+ sys.stdout.write(" " + dim("─" * 60) + "\n")
459
+
460
+ filtered = [p for p in all_points if filter_str == "" or filter_str.lower() in p.lower()]
461
+
462
+ # Clamp cursor to valid range
463
+ if not filtered:
464
+ cursor = 0
465
+ elif cursor >= len(filtered):
466
+ cursor = len(filtered) - 1
467
+
468
+ if not filtered:
469
+ sys.stdout.write(yellow(f" No points match '{filter_str}'") + "\n")
470
+ sys.stdout.write("\n " + dim("Type to search for points...") + "\n")
471
+ else:
472
+ sys.stdout.write(blue(" Matching points:") + "\n")
473
+ for i, p in enumerate(filtered):
474
+ marker = green("►") if i == cursor else " "
475
+ highlighted = _highlight_match(p, filter_str)
476
+ sys.stdout.write(f" {marker} {highlighted}\n")
477
+
478
+ sel = green("'" + filtered[cursor] + "'")
479
+ sys.stdout.write(f"\n {dim('Enter')} to select {sel}\n")
480
+
481
+ sys.stdout.flush()
482
+
483
+ ready, _, _ = select.select([fd], [], [], 0.1)
484
+ if not ready:
485
+ if _cli_monitor and _cli_monitor.fault_detected:
486
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
487
+ raise URKitConnectionError(
488
+ f"Robot fault detected: {_cli_monitor._reason or 'RTDE connection lost'}. "
489
+ "RTDE connection lost."
490
+ )
491
+ continue
492
+
493
+ # Read all bytes that arrived together — avoids Python text-mode
494
+ # buffering silently consuming escape sequence bytes.
495
+ # After select() says ready, os.read() gets everything the terminal
496
+ # sent in one burst (e.g., \x1b[A for up-arrow). A second read
497
+ # would block, so we only read once per select() notification.
498
+ raw = os.read(fd, 64)
499
+ if not raw:
500
+ continue
501
+
502
+ # Decode and parse sequentially, handling multi-byte escape sequences
503
+ text = raw.decode("ascii", errors="replace")
504
+ i = 0
505
+ while i < len(text):
506
+ ch = text[i]
507
+
508
+ if ch == "\x1b":
509
+ # Check for CSI escape sequence (arrow keys send \x1b[A or \x1b[B)
510
+ if i + 2 < len(text) and text[i + 1] == "[":
511
+ key = text[i + 2]
512
+ if key == "A":
513
+ cursor = max(0, cursor - 1)
514
+ elif key == "B":
515
+ cursor = min(len(filtered) - 1, cursor + 1)
516
+ # Unrecognized CSI sequences are ignored
517
+ i += 3
518
+ continue
519
+ else:
520
+ # Bare ESC → cancel
521
+ return None
522
+
523
+ if ch == "\n" or ch == "\r":
524
+ if filtered:
525
+ return filtered[cursor]
526
+ return None
527
+ elif ch == "\x08" or ch == "\x7f":
528
+ filter_str = filter_str[:-1]
529
+ elif ch == "\x03":
530
+ return None
531
+ elif ch.isprintable() and len(filter_str) < 30:
532
+ filter_str += ch
533
+
534
+ i += 1
535
+ finally:
536
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
537
+
538
+
539
+ def _submenu_goto_point(
540
+ robot: URRobot, state: dict, messages: list[str]
541
+ ) -> None:
542
+ """Go to a saved point using the current Go To mode."""
543
+ try:
544
+ point_names = robot.point_names()
545
+ if not point_names:
546
+ messages.append("No saved points")
547
+ return
548
+
549
+ name = _filter_select_points(point_names, "GO TO SAVED POINT")
550
+ if not name:
551
+ messages.append("Cancelled")
552
+ return
553
+
554
+ if state["freedrive"]:
555
+ robot.disable_freedrive()
556
+ state["freedrive"] = False
557
+
558
+ is_cartesian = state["goto_mode"] == "cartesian"
559
+ robot.move_to(name, linear=is_cartesian)
560
+ mode_label = "cartesian" if is_cartesian else "joint"
561
+ messages.append(f"Moved to '{name}' ({mode_label})")
562
+ except URKitConnectionError:
563
+ raise
564
+ except Exception as e:
565
+ messages.append(f"Error: {e}")
566
+
567
+
568
+ def _submenu_rename_point(
569
+ robot: URRobot, messages: list[str]
570
+ ) -> None:
571
+ """Rename a saved point."""
572
+ try:
573
+ point_names = robot.point_names()
574
+ if not point_names:
575
+ messages.append("No saved points")
576
+ return
577
+
578
+ name = _filter_select_points(point_names, "RENAME SAVED POINT")
579
+ if not name:
580
+ messages.append("Cancelled")
581
+ return
582
+
583
+ new_name = _read_input(" New name: ")
584
+ if not new_name:
585
+ messages.append("Cancelled")
586
+ return
587
+
588
+ # Check if new name already exists — ask for confirmation
589
+ if new_name in robot.point_names():
590
+ confirm = _read_input(
591
+ f" Point '{new_name}' already exists. Overwrite? (y/n) "
592
+ )
593
+ if confirm != "y":
594
+ messages.append("Cancelled")
595
+ return
596
+
597
+ # Rename via delete + save: load old point data, save as new name
598
+ # The internal Points class handles this, but we don't expose it.
599
+ # Use the raw _points object for rename (internal implementation).
600
+ robot.rename_point(name, new_name)
601
+ messages.append(f"Renamed '{name}' → '{new_name}'")
602
+ except URKitConnectionError:
603
+ raise
604
+ except Exception as e:
605
+ messages.append(f"Error: {e}")
606
+
607
+
608
+ def _submenu_delete_point(
609
+ robot: URRobot, messages: list[str]
610
+ ) -> None:
611
+ """Delete a saved point."""
612
+ try:
613
+ point_names = robot.point_names()
614
+ if not point_names:
615
+ messages.append("No saved points")
616
+ return
617
+
618
+ name = _filter_select_points(point_names, "DELETE SAVED POINT")
619
+ if not name:
620
+ messages.append("Cancelled")
621
+ return
622
+
623
+ # Confirmation
624
+ print("\033[2J\033[1;1H", end="")
625
+ print(" === DELETE CONFIRMATION ===")
626
+ print()
627
+ print(f" Delete point '{name}'?")
628
+ print(" Type 'yes' to confirm:")
629
+ confirm = _read_input(" > ")
630
+ if confirm == "yes":
631
+ robot.delete_point(name)
632
+ messages.append(f"Deleted '{name}'")
633
+ else:
634
+ messages.append("Cancelled")
635
+ except URKitConnectionError:
636
+ raise
637
+ except Exception as e:
638
+ messages.append(f"Error: {e}")
639
+
640
+
641
+ def _submenu_explore_points(robot: URRobot, messages: list[str]) -> None:
642
+ """Open the interactive points explorer."""
643
+ try:
644
+ if robot.points_db is None:
645
+ messages.append("No points database configured")
646
+ return
647
+
648
+ all_points = robot.points_db.list()
649
+ if not all_points:
650
+ messages.append("No saved points")
651
+ return
652
+
653
+ _interactive_points_filter(robot.points_db, all_points)
654
+ messages.append("Closed points explorer")
655
+ except Exception as e:
656
+ messages.append(f"Error: {e}")
657
+
658
+
659
+ # ------------------------------------------------------------------
660
+ # Key input helpers
661
+ # ------------------------------------------------------------------
662
+
663
+ def _key_to_delta(key: str, state: dict) -> list[float]:
664
+ """Map a single movement key to a 6-element delta vector."""
665
+ delta = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
666
+ if key == "w":
667
+ delta[0] = state["linear_step"]
668
+ elif key == "s":
669
+ delta[0] = -state["linear_step"]
670
+ elif key == "a":
671
+ delta[1] = -state["linear_step"]
672
+ elif key == "d":
673
+ delta[1] = state["linear_step"]
674
+ elif key == "q":
675
+ delta[2] = state["linear_step"]
676
+ elif key == "e":
677
+ delta[2] = -state["linear_step"]
678
+ elif key == "u":
679
+ delta[3] = state["angular_step"]
680
+ elif key == "o":
681
+ delta[3] = -state["angular_step"]
682
+ elif key == "i":
683
+ delta[4] = state["angular_step"]
684
+ elif key == "k":
685
+ delta[4] = -state["angular_step"]
686
+ elif key == "j":
687
+ delta[5] = state["angular_step"]
688
+ elif key == "l":
689
+ delta[5] = -state["angular_step"]
690
+ return delta
691
+
692
+
693
+ def _is_angular_key(key: str) -> bool:
694
+ """Return True if the key controls an orientation axis."""
695
+ return key in ("u", "o", "i", "j", "k", "l")
696
+
697
+
698
+ # ------------------------------------------------------------------
699
+ # Main interactive loop
700
+ # ------------------------------------------------------------------
701
+
702
+ class _ScreenLogHandler(logging.Handler):
703
+ """Logging handler that appends messages to a list for display on screen."""
704
+ def __init__(self, messages: list[str]) -> None:
705
+ super().__init__()
706
+ self._messages = messages
707
+ def emit(self, record: logging.LogRecord) -> None:
708
+ self._messages.append(self.format(record))
709
+
710
+
711
+ def _teach_pendant(
712
+ robot: URRobot,
713
+ ) -> None:
714
+ """Run the interactive teach pendant loop.
715
+
716
+ Single-screen redraw loop matching point_saver style.
717
+
718
+ Args:
719
+ robot: Initialized URRobot instance.
720
+ """
721
+ state: dict = {
722
+ "linear_step": _DEFAULT_LINEAR_STEP,
723
+ "angular_step": _DEFAULT_ANGULAR_STEP,
724
+ "freedrive": False,
725
+ "freedrive_mode": FreedriveMode.ALL,
726
+ "move_frame": robot.move_frame,
727
+ "goto_mode": "cartesian", # "cartesian" or "joint" for Go To
728
+ "speed_slider": 1.0,
729
+ }
730
+
731
+ messages: list[str] = []
732
+
733
+ # Redirect logging warnings/errors into screen messages (MUST be before
734
+ # Points creation, as it may emit warnings)
735
+ _urkit_logger = logging.getLogger("urkit")
736
+ _urkit_logger.setLevel(logging.WARNING)
737
+ _urkit_logger.propagate = False # Don't leak to stderr via root logger
738
+ _log_handler = _ScreenLogHandler(messages)
739
+ _log_handler.setLevel(logging.WARNING)
740
+ _log_handler.setFormatter(logging.Formatter("%(message)s"))
741
+ _urkit_logger.addHandler(_log_handler)
742
+
743
+ delta_keys = ("w", "s", "a", "d", "q", "e", "u", "o", "i", "j", "k", "l")
744
+
745
+ # Throttle between movement steps — prevents terminal key repeat
746
+ # from firing multiple moves on a single brief press
747
+ _MOVE_THROTTLE = 0.05 # seconds
748
+ last_move_t = 0.0
749
+
750
+ if not sys.stdin.isatty():
751
+ print("Error: stdin is not a terminal. Run from an interactive shell.")
752
+ sys.exit(1)
753
+
754
+ old_settings = termios.tcgetattr(sys.stdin)
755
+
756
+ # SIGINT handler: restore terminal and exit when Ctrl+C pressed.
757
+ # tty.setcbreak() keeps ISIG enabled, so Ctrl+C generates SIGINT.
758
+ # os._exit() works even when blocked in C library code.
759
+ def _sigint_handler(signum: int, frame: object) -> None:
760
+ try:
761
+ _restore_terminal(old_settings)
762
+ except Exception:
763
+ pass
764
+ sys.stderr.write("\nInterrupted.\n")
765
+ os._exit(0)
766
+
767
+ signal.signal(signal.SIGINT, _sigint_handler)
768
+
769
+ # Connection watchdog: detects faults and interrupts blocking calls.
770
+ monitor = ConnectionMonitor(robot)
771
+ monitor.start()
772
+ _old_sigalrm = signal.signal(signal.SIGALRM, monitor.alarm_handler)
773
+ _cli_monitor = monitor # Global so input helpers can check for faults
774
+
775
+ try:
776
+ try:
777
+ _configure_terminal()
778
+ _draw_screen(robot, state, messages)
779
+
780
+ # How often to refresh the screen while in freedrive (no key pressed)
781
+ _FREEDRIVE_REFRESH_S = 0.5
782
+ last_refresh = time.monotonic()
783
+
784
+ while True:
785
+ moved = False
786
+ command_handled = False
787
+
788
+ # --- Wait for a key ---
789
+ while True:
790
+ ready, _, _ = select.select([sys.stdin], [], [], 0.05)
791
+ if ready:
792
+ break
793
+
794
+ # Periodic refresh while in freedrive — robot may be moving by hand
795
+ if state["freedrive"]:
796
+ now = time.monotonic()
797
+ if now - last_refresh >= _FREEDRIVE_REFRESH_S:
798
+ last_refresh = now
799
+ _draw_screen(robot, state, messages)
800
+
801
+ # Check for fault detected by watchdog thread
802
+ if monitor.fault_detected:
803
+ raise URKitConnectionError(
804
+ f"Robot fault detected: {monitor._reason or 'RTDE connection lost'}. "
805
+ "RTDE connection lost."
806
+ )
807
+
808
+ # --- Read key, draining buffered repeats ---
809
+ # When a key is held, the terminal auto-repeats it into the
810
+ # input buffer. Reading only one character leaves stale repeats
811
+ # behind — when the user switches from one movement key to
812
+ # another, the last buffered copy of the old key fires one final
813
+ # move in the wrong direction before the new key is read.
814
+ # Fix: after select() signals ready, drain all immediately
815
+ # available bytes (non-blocking) and use the last one.
816
+ key = ""
817
+ while True:
818
+ ready, _, _ = select.select([sys.stdin], [], [], 0.0)
819
+ if not ready:
820
+ break
821
+ ch = sys.stdin.read(1).lower()
822
+ if not ch:
823
+ break
824
+ key = ch
825
+ if not key:
826
+ continue
827
+
828
+ # Exit
829
+ if key == "\x1b" or key == "\x03":
830
+ break
831
+
832
+ # --- Movement keys (one press = one step) ---
833
+ elif key in delta_keys:
834
+ # Throttle — skip rapid repeats from terminal key bounce
835
+ if time.monotonic() - last_move_t < _MOVE_THROTTLE:
836
+ continue
837
+ try:
838
+ if state["freedrive"]:
839
+ robot.disable_freedrive()
840
+ state["freedrive"] = False
841
+
842
+ delta = _key_to_delta(key, state)
843
+ has_angular = _is_angular_key(key)
844
+ vel, acc = _compute_vel_acc(
845
+ state["linear_step"], state["angular_step"], has_angular
846
+ )
847
+ robot.move_relative(delta, vel=vel, acc=acc, frame=state["move_frame"])
848
+ moved = True
849
+ last_move_t = time.monotonic()
850
+ except MotionError as e:
851
+ messages.append(f"Error: {e}")
852
+
853
+ # --- Step size ---
854
+ elif key == "1":
855
+ val = _read_input(" Linear step (mm): ")
856
+ try:
857
+ mm = float(val)
858
+ state["linear_step"] = max(mm / 1000, _DEFAULT_LINEAR_STEP_MIN)
859
+ state["linear_step"] = min(state["linear_step"], _DEFAULT_LINEAR_STEP_MAX)
860
+ except (ValueError, TypeError):
861
+ messages.append("Invalid step value")
862
+ command_handled = True
863
+
864
+ elif key == "2":
865
+ val = _read_input(" Angular step (degrees): ")
866
+ try:
867
+ deg = float(val)
868
+ state["angular_step"] = max(math.radians(deg), _DEFAULT_ANGULAR_STEP_MIN)
869
+ state["angular_step"] = min(state["angular_step"], _DEFAULT_ANGULAR_STEP_MAX)
870
+ except (ValueError, TypeError):
871
+ messages.append("Invalid step value")
872
+ command_handled = True
873
+
874
+ elif key == ".":
875
+ state["linear_step"] = _DEFAULT_LINEAR_STEP
876
+ state["angular_step"] = _DEFAULT_ANGULAR_STEP
877
+ messages.append("Step sizes reset to defaults")
878
+ command_handled = True
879
+
880
+ # --- Freedrive ---
881
+ elif key == "f":
882
+ 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
+ try:
889
+ robot.enable_freedrive(mode)
890
+ state["freedrive"] = True
891
+ state["freedrive_mode"] = mode
892
+ state["_last_freedrive_mode"] = mode
893
+ messages.append(f"Freedrive {mode.name}")
894
+ except MotionError as e:
895
+ messages.append(f"Freedrive error: {e}")
896
+ else:
897
+ if state["freedrive_mode"] == FreedriveMode.ALL:
898
+ try:
899
+ robot.disable_freedrive()
900
+ robot.enable_freedrive(FreedriveMode.XYZ)
901
+ state["freedrive_mode"] = FreedriveMode.XYZ
902
+ state["_last_freedrive_mode"] = FreedriveMode.XYZ
903
+ messages.append("Freedrive XYZ")
904
+ except MotionError as e:
905
+ messages.append(f"Freedrive error: {e}")
906
+ else:
907
+ try:
908
+ robot.disable_freedrive()
909
+ state["freedrive"] = False
910
+ messages.append("Freedrive OFF")
911
+ except MotionError as e:
912
+ messages.append(f"Freedrive error: {e}")
913
+ command_handled = True
914
+
915
+ # --- Frame toggle ---
916
+ elif key == "m":
917
+ if state["move_frame"] == MoveFrame.BASE:
918
+ state["move_frame"] = MoveFrame.TOOL
919
+ else:
920
+ state["move_frame"] = MoveFrame.BASE
921
+ robot.move_frame = state["move_frame"]
922
+ messages.append(f"Move frame: {state['move_frame'].name}")
923
+ command_handled = True
924
+
925
+ # --- Go To mode toggle ---
926
+ elif key == "n":
927
+ state["goto_mode"] = "joint" if state["goto_mode"] == "cartesian" else "cartesian"
928
+ messages.append(f"Go To mode: {state['goto_mode'].capitalize()}")
929
+ command_handled = True
930
+
931
+ # --- TCP orient down ---
932
+ elif key == "t":
933
+ if state["move_frame"] == MoveFrame.TOOL:
934
+ messages.append("TCP Down unavailable in TOOL frame")
935
+ command_handled = True
936
+ else:
937
+ try:
938
+ if state["freedrive"]:
939
+ robot.disable_freedrive()
940
+ state["freedrive"] = False
941
+ pose = robot.get_tcp_pose()
942
+ target = orient_tcp_down(pose)
943
+ robot.move_to(target, vel=0.5, acc=0.3)
944
+ messages.append("TCP oriented downward")
945
+ except MotionError as e:
946
+ messages.append(f"Error: {e}")
947
+ command_handled = True
948
+
949
+ # --- Gripper ---
950
+ elif key == "x":
951
+ if robot.gripper:
952
+ try:
953
+ robot.gripper.open()
954
+ except Exception as e:
955
+ messages.append(f"Gripper error: {e}")
956
+ else:
957
+ messages.append("No gripper configured")
958
+ command_handled = True
959
+
960
+ elif key == "c":
961
+ if robot.gripper:
962
+ try:
963
+ robot.gripper.close()
964
+ except Exception as e:
965
+ messages.append(f"Gripper error: {e}")
966
+ else:
967
+ messages.append("No gripper configured")
968
+ command_handled = True
969
+
970
+ elif key == "v":
971
+ if robot.gripper:
972
+ max_mm = getattr(robot.gripper, '_max_mm', None)
973
+ prompt = f" Gripper position (0-{max_mm} mm): " if max_mm is not None else " Gripper position (mm): "
974
+ val = _read_input(prompt)
975
+ try:
976
+ mm = float(val)
977
+ robot.gripper.set_position(mm)
978
+ messages.append(f"Gripper set to {mm:.1f} mm")
979
+ except (ValueError, TypeError):
980
+ messages.append("Invalid position value")
981
+ except Exception as e:
982
+ messages.append(f"Gripper error: {e}")
983
+ else:
984
+ messages.append("No gripper configured")
985
+ command_handled = True
986
+
987
+ # --- Point management ---
988
+ elif key == "b":
989
+ _submenu_save_point(robot, messages)
990
+ command_handled = True
991
+ elif key == "g":
992
+ _submenu_goto_point(robot, state, messages)
993
+ command_handled = True
994
+ elif key == "h":
995
+ _submenu_delete_point(robot, messages)
996
+ command_handled = True
997
+ elif key == "r":
998
+ _submenu_rename_point(robot, messages)
999
+ command_handled = True
1000
+ elif key == "p":
1001
+ _submenu_explore_points(robot, messages)
1002
+ command_handled = True
1003
+
1004
+ # --- Speed slider ---
1005
+ elif key == "0":
1006
+ val = _read_input(" Speed slider (0-100%): ")
1007
+ try:
1008
+ pct = float(val)
1009
+ factor = max(0.0, min(pct / 100.0, 1.0))
1010
+ robot.set_speed_slider(factor)
1011
+ state["speed_slider"] = factor
1012
+ messages.append(f"Speed slider: {int(factor * 100)}%")
1013
+ except (ValueError, TypeError):
1014
+ messages.append("Invalid speed value")
1015
+ except MotionError as e:
1016
+ messages.append(f"Error: {e}")
1017
+ command_handled = True
1018
+
1019
+ # --- Redraw after movement or command ---
1020
+ if moved or command_handled:
1021
+ _draw_screen(robot, state, messages)
1022
+ last_refresh = time.monotonic()
1023
+ messages = []
1024
+
1025
+ except URKitConnectionError as e:
1026
+ # Robot fault detected by monitoring thread
1027
+ print(f"\n{red('Fault:')} {e}")
1028
+ finally:
1029
+ # Stop monitor and restore signal handler
1030
+ _cli_monitor = None
1031
+ monitor.stop()
1032
+ signal.signal(signal.SIGALRM, _old_sigalrm)
1033
+
1034
+ finally:
1035
+ _urkit_logger.removeHandler(_log_handler)
1036
+ _urkit_logger.propagate = True
1037
+ # Restore terminal
1038
+ try:
1039
+ _restore_terminal(old_settings)
1040
+ except Exception:
1041
+ pass
1042
+ print("\n Exiting teach pendant.")
1043
+
1044
+
1045
+ def teach_command(args) -> None:
1046
+ """Execute the teach pendant command.
1047
+
1048
+ Args:
1049
+ args: Parsed arguments from argparse (with teach subcommand attributes).
1050
+ """
1051
+
1052
+ # Configure logging
1053
+ if args.verbose:
1054
+ logging.basicConfig(
1055
+ level=logging.DEBUG,
1056
+ format="%(name)s - %(levelname)s - %(message)s",
1057
+ )
1058
+ else:
1059
+ logging.basicConfig(level=logging.WARNING)
1060
+
1061
+ # Load config (last-used defaults)
1062
+ config = load_config()
1063
+
1064
+ # Resolve effective values: CLI arg > config > None
1065
+ ip = args.ip or config.get("robot_ip")
1066
+ gripper_name = args.gripper or config.get("gripper")
1067
+ points_path = args.points or config.get("points_path") or "points.db"
1068
+
1069
+ # Resolve gripper constructor params from config.yaml gripper_config section
1070
+ # and CLI --gripper-* flags (CLI overrides config)
1071
+ cfg = config.get("gripper_config") or {}
1072
+ gripper_kwargs: dict = {}
1073
+ if args.gripper_pin is not None:
1074
+ gripper_kwargs["pin"] = args.gripper_pin
1075
+ elif "pin" in cfg:
1076
+ gripper_kwargs["pin"] = cfg["pin"]
1077
+ if args.gripper_force is not None:
1078
+ gripper_kwargs["force"] = args.gripper_force
1079
+ elif "force" in cfg:
1080
+ gripper_kwargs["force"] = cfg["force"]
1081
+ if args.gripper_speed is not None:
1082
+ gripper_kwargs["speed"] = args.gripper_speed
1083
+ elif "speed" in cfg:
1084
+ gripper_kwargs["speed"] = cfg["speed"]
1085
+ if args.gripper_close_on_high is not None:
1086
+ gripper_kwargs["close_on_high"] = args.gripper_close_on_high == "true"
1087
+ elif "close_on_high" in cfg:
1088
+ gripper_kwargs["close_on_high"] = cfg["close_on_high"]
1089
+
1090
+ # Resolve gripper string to config object
1091
+ gripper_config = None
1092
+ if gripper_name:
1093
+ preset = PRESETS.get(gripper_name.upper())
1094
+ if preset is not None:
1095
+ gripper_config = preset
1096
+ elif gripper_name == "digital":
1097
+ gripper_config = DigitalGripperConfig(
1098
+ pin=gripper_kwargs.pop("pin", 0),
1099
+ close_on_high=gripper_kwargs.pop("close_on_high", True),
1100
+ )
1101
+
1102
+ if not ip:
1103
+ print("Error: No robot IP specified.")
1104
+ print(" Usage: urkit 192.168.1.100")
1105
+ print(" Or: urkit (uses last-used IP from config)")
1106
+ print(f" Config: {_resolve_default_config_path()}")
1107
+ sys.exit(1)
1108
+
1109
+ # Validate IP format before any connection attempt
1110
+ if not _validate_ip(ip):
1111
+ print(f"Error: \"{ip}\" is not a valid IPv4 address.")
1112
+ print(" Usage: urkit 192.168.1.100")
1113
+ sys.exit(1)
1114
+
1115
+ # Quick ping check to verify reachability
1116
+ if not _ping(ip, timeout=2.0):
1117
+ print(f"Error: Robot at {ip} is not reachable.")
1118
+ print(" Check the IP address and network connection.")
1119
+ sys.exit(1)
1120
+
1121
+ # Print resolved params
1122
+ print(f"Connecting to robot at {ip}...")
1123
+ if gripper_name:
1124
+ print(f" Gripper: {gripper_name}")
1125
+ print(f" Points: {points_path}")
1126
+
1127
+ # URRobot handles everything: safety recovery, remote mode check,
1128
+ # power on, brake release, program stop, and RTDE connection.
1129
+ try:
1130
+ robot = URRobot(
1131
+ ip=ip,
1132
+ points=points_path,
1133
+ gripper=gripper_config,
1134
+ **gripper_kwargs,
1135
+ )
1136
+ print(f" Connected.", flush=True)
1137
+ if robot.activate_gripper():
1138
+ print(f" Gripper activated.", flush=True)
1139
+ except ConnectionError as e:
1140
+ print(f"Connection error: {e}")
1141
+ if "RTDE" in str(e):
1142
+ print(
1143
+ "\n The robot is reachable but RTDE could not connect.\n"
1144
+ " This usually means Remote Control is not enabled.\n"
1145
+ " On the teach pendant:\n"
1146
+ " 1. Go to Settings → System → Remote Control → Enable\n"
1147
+ " 2. Press the remote/local button for remote mode\n"
1148
+ f" 3. Then try: urkit {ip}"
1149
+ )
1150
+ sys.exit(1)
1151
+
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
+ try:
1163
+ _teach_pendant(robot)
1164
+ except KeyboardInterrupt:
1165
+ pass
1166
+ finally:
1167
+ # Stop any running program on the robot
1168
+ try:
1169
+ s = _connect_dashboard(robot.ip, timeout=5.0)
1170
+ try:
1171
+ _dashboard_command(s, "stop", timeout=2.0)
1172
+ except ConnectionError:
1173
+ pass
1174
+ s.close()
1175
+ except ConnectionError:
1176
+ pass
1177
+ robot.disconnect()
1178
+
1179
+
1180
+ if __name__ == "__main__":
1181
+ main()