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/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()
|