urkit 0.4.1__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {urkit-0.4.1 → urkit-0.4.2}/PKG-INFO +7 -2
  2. {urkit-0.4.1 → urkit-0.4.2}/README.md +6 -1
  3. {urkit-0.4.1 → urkit-0.4.2}/pyproject.toml +1 -1
  4. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/__init__.py +1 -1
  5. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/cli/connection_monitor.py +8 -1
  6. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/cli/points.py +28 -35
  7. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/cli/teach.py +100 -154
  8. urkit-0.4.2/src/urkit/cli/terminal.py +233 -0
  9. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/points.py +8 -5
  10. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/robot.py +7 -6
  11. {urkit-0.4.1 → urkit-0.4.2}/src/urkit.egg-info/PKG-INFO +7 -2
  12. {urkit-0.4.1 → urkit-0.4.2}/src/urkit.egg-info/SOURCES.txt +1 -0
  13. {urkit-0.4.1 → urkit-0.4.2}/setup.cfg +0 -0
  14. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/__main__.py +0 -0
  15. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/cli/__init__.py +0 -0
  16. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/cli/colors.py +0 -0
  17. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/cli/init.py +0 -0
  18. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/config.py +0 -0
  19. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/connection.py +0 -0
  20. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/exceptions.py +0 -0
  21. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/geometry.py +0 -0
  22. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/gripper/__init__.py +0 -0
  23. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/gripper/base.py +0 -0
  24. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/gripper/digital.py +0 -0
  25. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/gripper/presets.py +0 -0
  26. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/gripper/robotiq.py +0 -0
  27. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/gripper/robotiq_preamble.py +0 -0
  28. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/io.py +0 -0
  29. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/motion.py +0 -0
  30. {urkit-0.4.1 → urkit-0.4.2}/src/urkit/telemetry.py +0 -0
  31. {urkit-0.4.1 → urkit-0.4.2}/src/urkit.egg-info/dependency_links.txt +0 -0
  32. {urkit-0.4.1 → urkit-0.4.2}/src/urkit.egg-info/entry_points.txt +0 -0
  33. {urkit-0.4.1 → urkit-0.4.2}/src/urkit.egg-info/requires.txt +0 -0
  34. {urkit-0.4.1 → urkit-0.4.2}/src/urkit.egg-info/top_level.txt +0 -0
  35. {urkit-0.4.1 → urkit-0.4.2}/tests/test_exceptions.py +0 -0
  36. {urkit-0.4.1 → urkit-0.4.2}/tests/test_geometry.py +0 -0
  37. {urkit-0.4.1 → urkit-0.4.2}/tests/test_gripper.py +0 -0
  38. {urkit-0.4.1 → urkit-0.4.2}/tests/test_gripper_factory.py +0 -0
  39. {urkit-0.4.1 → urkit-0.4.2}/tests/test_gripper_presets.py +0 -0
  40. {urkit-0.4.1 → urkit-0.4.2}/tests/test_move_sequence.py +0 -0
  41. {urkit-0.4.1 → urkit-0.4.2}/tests/test_points.py +0 -0
  42. {urkit-0.4.1 → urkit-0.4.2}/tests/test_robot_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Universal Robots e-Series control toolkit built on ur_rtde
5
5
  Author: URKit Contributors
6
6
  License: MIT
@@ -94,6 +94,11 @@ The `-U` (upgrade) flag ensures you always get the latest version. This project
94
94
 
95
95
  Requires Python 3.8+ and a Universal Robots e-Series (UR3e to UR30).
96
96
 
97
+ ### Platform Support
98
+
99
+ - **Linux, macOS**: fully supported
100
+ - **Windows**: the Python library works on Windows. The interactive CLIs (`urkit teach`, `urkit points`) run on Windows but are **not fully tested and not officially supported**. A warning is printed at startup. If you hit input or console problems, use Linux, macOS, or WSL2
101
+
97
102
  ### Robot Setup (one-time)
98
103
 
99
104
  1. **Network**: Connect your PC to the robot via Ethernet cable (direct) or a switch.
@@ -663,7 +668,7 @@ Default is `None` (controller handles IK natively). Set it globally when most of
663
668
 
664
669
  #### Point Management
665
670
 
666
- Points are stored in the active TCP frame, so they work with any tool. Swap grippers and your saved points stay valid.
671
+ Points are stored as absolute TCP poses in the robot base frame. When you move to a point, the controller moves whatever TCP is currently active to that base-frame pose, so the tool tip lands at the same physical location. Change the TCP offset (or swap grippers) and your saved points stay valid.
667
672
 
668
673
  ```python
669
674
  robot.save_point("here")
@@ -68,6 +68,11 @@ The `-U` (upgrade) flag ensures you always get the latest version. This project
68
68
 
69
69
  Requires Python 3.8+ and a Universal Robots e-Series (UR3e to UR30).
70
70
 
71
+ ### Platform Support
72
+
73
+ - **Linux, macOS**: fully supported
74
+ - **Windows**: the Python library works on Windows. The interactive CLIs (`urkit teach`, `urkit points`) run on Windows but are **not fully tested and not officially supported**. A warning is printed at startup. If you hit input or console problems, use Linux, macOS, or WSL2
75
+
71
76
  ### Robot Setup (one-time)
72
77
 
73
78
  1. **Network**: Connect your PC to the robot via Ethernet cable (direct) or a switch.
@@ -637,7 +642,7 @@ Default is `None` (controller handles IK natively). Set it globally when most of
637
642
 
638
643
  #### Point Management
639
644
 
640
- Points are stored in the active TCP frame, so they work with any tool. Swap grippers and your saved points stay valid.
645
+ Points are stored as absolute TCP poses in the robot base frame. When you move to a point, the controller moves whatever TCP is currently active to that base-frame pose, so the tool tip lands at the same physical location. Change the TCP offset (or swap grippers) and your saved points stay valid.
641
646
 
642
647
  ```python
643
648
  robot.save_point("here")
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "urkit"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Universal Robots e-Series control toolkit built on ur_rtde"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -24,7 +24,7 @@ Quick start::
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
- __version__ = "0.4.1"
27
+ __version__ = "0.4.2"
28
28
 
29
29
 
30
30
  from urkit.exceptions import (
@@ -144,10 +144,17 @@ class ConnectionMonitor:
144
144
  logger.info("Connection monitor loop exited")
145
145
 
146
146
  def _trigger(self, reason: str) -> None:
147
- """Record fault reason and send SIGALRM to interrupt blocking calls."""
147
+ """Record fault reason and send SIGALRM to interrupt blocking calls.
148
+
149
+ On Windows there is no self-signal: the fault_detected flag is
150
+ the only interruption path, and callers poll it between and
151
+ after RTDE calls.
152
+ """
148
153
  self._reason = reason
149
154
  self._fault_event.set()
150
155
  logger.info("Fault detected: %s", reason)
156
+ if not hasattr(signal, "SIGALRM"):
157
+ return
151
158
  try:
152
159
  os.kill(os.getpid(), signal.SIGALRM)
153
160
  except OSError as e:
@@ -9,9 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import difflib
11
11
  import io
12
- import select
13
12
  import sys
14
- import termios
15
13
  import time
16
14
  from pathlib import Path
17
15
 
@@ -20,6 +18,7 @@ from rich.table import Table
20
18
 
21
19
  from urkit.config import _load_config as load_config
22
20
  from urkit.cli.colors import blue, cyan, dim, yellow
21
+ from urkit.cli.terminal import RawTerminal, read_burst, wait_input, warn_if_windows
23
22
  from urkit.points import Points
24
23
 
25
24
 
@@ -29,6 +28,7 @@ def points_command(args) -> None:
29
28
  Args:
30
29
  args: Parsed arguments from argparse (with points subcommand attributes).
31
30
  """
31
+ warn_if_windows()
32
32
  _explore_points(args)
33
33
 
34
34
 
@@ -120,12 +120,8 @@ def _interactive_points_filter(points_db: Points, all_points: list[str], points_
120
120
  refresh_error = None
121
121
 
122
122
  # Set terminal to raw mode
123
- old_settings = termios.tcgetattr(sys.stdin)
124
- new_settings = termios.tcgetattr(sys.stdin)
125
- new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
126
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
127
-
128
- fd = sys.stdin.fileno()
123
+ raw = RawTerminal()
124
+ raw.enable()
129
125
 
130
126
  try:
131
127
  while True:
@@ -238,44 +234,41 @@ def _interactive_points_filter(points_db: Points, all_points: list[str], points_
238
234
  last_refresh = time.time()
239
235
 
240
236
  # Check for input (non-blocking to allow periodic refresh)
241
- rlist, _, _ = select.select([fd], [], [], 0.1)
242
- if not rlist:
237
+ if not wait_input(0.1):
243
238
  continue
244
239
 
245
- # Read input
240
+ # Read the full input burst. On Unix an arrow key arrives as
241
+ # \x1b[A in the same burst; on Windows the terminal shim
242
+ # translates arrow keys to the same byte sequence.
246
243
  try:
247
- ch = sys.stdin.read(1)
244
+ text = read_burst(0.0).decode("ascii", errors="replace")
248
245
  except Exception:
249
246
  break
247
+ if not text:
248
+ continue
250
249
 
251
- if ch == "\x1b": # ESC or arrow key or function key
252
- # Use longer timeout to reliably detect sequences
253
- rlist, _, _ = select.select([fd], [], [], 0.2)
254
- if rlist:
255
- # There's more input, likely an arrow or function key sequence
256
- try:
257
- ch2 = sys.stdin.read(1)
258
- if ch2 == "[":
259
- ch3 = sys.stdin.read(1)
260
- if ch3 == "A": # Up arrow - scroll up
261
- scroll = max(0, scroll - 1)
262
- needs_redraw = True
263
- elif ch3 == "B": # Down arrow - scroll down
264
- filtered = [p for p in all_points_sorted if filter_str == "" or filter_str.lower() in p.lower()]
265
- scroll = min(len(filtered) - 1, scroll + 1) if filtered else 0
266
- needs_redraw = True
267
- except Exception:
268
- break
250
+ if text.startswith("\x1b"): # ESC or arrow key or function key
251
+ # A bare ESC may still be the first byte of a sequence
252
+ # that arrives late; wait a bit before treating it as quit.
253
+ if text == "\x1b":
254
+ text = text + read_burst(0.2).decode("ascii", errors="replace")
255
+ if text.endswith("[A"): # Up arrow - scroll up
256
+ scroll = max(0, scroll - 1)
257
+ needs_redraw = True
258
+ elif text.endswith("[B"): # Down arrow - scroll down
259
+ filtered = [p for p in all_points_sorted if filter_str == "" or filter_str.lower() in p.lower()]
260
+ scroll = min(len(filtered) - 1, scroll + 1) if filtered else 0
261
+ needs_redraw = True
269
262
  else:
270
- # No more input, so this was just ESC — quit
263
+ # Just ESC (or an unrecognized sequence) — quit
271
264
  break
272
- elif ch == "\x7f" or ch == "\x08": # Backspace
265
+ elif text == "\x7f" or text == "\x08": # Backspace
273
266
  if filter_str:
274
267
  filter_str = filter_str[:-1]
275
268
  scroll = 0
276
269
  needs_redraw = True
277
- elif ch.isprintable():
278
- filter_str += ch
270
+ elif text.isprintable():
271
+ filter_str += text
279
272
  scroll = 0
280
273
  needs_redraw = True
281
274
 
@@ -283,7 +276,7 @@ def _interactive_points_filter(points_db: Points, all_points: list[str], points_
283
276
  # Ctrl+C — exit gracefully without traceback
284
277
  pass
285
278
  finally:
286
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
279
+ raw.disable()
287
280
  sys.stdout.write("\n")
288
281
 
289
282
 
@@ -16,18 +16,21 @@ import ipaddress
16
16
  import logging
17
17
  import math
18
18
  import os
19
- import select
20
19
  import signal
21
20
  import sys
22
- import termios
23
21
  import time
24
- import tty
25
22
  from pathlib import Path
26
23
 
27
24
  import yaml
28
25
 
29
- from urkit.config import _load_config as load_config
30
26
  from urkit.cli.colors import blue, cyan, dim, green, red, yellow
27
+ from urkit.cli.terminal import (
28
+ RawTerminal,
29
+ read_burst,
30
+ wait_input,
31
+ warn_if_windows,
32
+ )
33
+ from urkit.config import _load_config as load_config
31
34
  from urkit.cli.connection_monitor import ConnectionMonitor
32
35
  from urkit.cli.points import _interactive_points_filter
33
36
  from urkit.connection import (
@@ -181,38 +184,6 @@ def _validate_ip(ip: str) -> bool:
181
184
 
182
185
 
183
186
 
184
- # ------------------------------------------------------------------
185
- # Raw terminal I/O
186
- # ------------------------------------------------------------------
187
-
188
- class _RawTerminal:
189
- """Manage raw terminal mode with tcgetattr/tcsetattr.
190
-
191
- Context-manager based: enters raw mode on __enter__, restores on __exit__.
192
- Single-character non-blocking reads via getkey().
193
- """
194
-
195
- def __enter__(self) -> "_RawTerminal":
196
- self._old_settings = termios.tcgetattr(sys.stdin)
197
- tty.setcbreak(sys.stdin.fileno())
198
- return self
199
-
200
- def __exit__(self, *args) -> None:
201
- if hasattr(self, "_old_settings"):
202
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self._old_settings)
203
-
204
- def getkey(self) -> str:
205
- """Read a single character without waiting for Enter.
206
-
207
- Returns:
208
- Single character string, or empty string if no input.
209
- """
210
- ready, _, _ = select.select([sys.stdin], [], [], 0.05)
211
- if ready:
212
- return sys.stdin.read(1)
213
- return ""
214
-
215
-
216
187
  # ------------------------------------------------------------------
217
188
  # UI rendering
218
189
  # ------------------------------------------------------------------
@@ -469,16 +440,19 @@ def _draw_help() -> None:
469
440
  # Point submenus (restore canonical terminal for multi-char input)
470
441
  # ------------------------------------------------------------------
471
442
 
472
- def _configure_terminal() -> list:
473
- """Disable canonical mode and echo."""
474
- old_settings = termios.tcgetattr(sys.stdin)
475
- tty.setcbreak(sys.stdin.fileno())
476
- return old_settings
443
+ # Raw terminal shared by the main loop, the SIGINT handler, and the
444
+ # exit path. All terminal setup goes through the cross-platform shim.
445
+ _raw_terminal = RawTerminal()
446
+
477
447
 
448
+ def _configure_terminal() -> None:
449
+ """Enter raw terminal mode (no canonical, no echo)."""
450
+ _raw_terminal.enable()
478
451
 
479
- def _restore_terminal(old_settings: list) -> None:
452
+
453
+ def _restore_terminal() -> None:
480
454
  """Restore canonical mode and echo."""
481
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
455
+ _raw_terminal.disable()
482
456
 
483
457
 
484
458
  def _read_input(prompt: str, max_len: int = 30) -> str | None:
@@ -494,51 +468,50 @@ def _read_input(prompt: str, max_len: int = 30) -> str | None:
494
468
  Returns:
495
469
  Input string, or None if cancelled (ESC/empty).
496
470
  """
497
- old_settings = termios.tcgetattr(sys.stdin)
498
- tty.setcbreak(sys.stdin.fileno())
499
- # Disable echo — we handle character display ourselves to avoid conflicts
500
- settings = termios.tcgetattr(sys.stdin)
501
- settings[3] = settings[3] & ~termios.ECHO
502
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, settings)
471
+ raw = RawTerminal()
472
+ raw.enable()
503
473
 
504
474
  sys.stdout.write(prompt)
505
475
  sys.stdout.flush()
506
476
 
507
477
  name = ""
508
- while True:
509
- ready, _, _ = select.select([sys.stdin], [], [], 0.1)
510
- if not ready:
511
- if _cli_monitor and _cli_monitor.fault_detected:
512
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
513
- raise URKitConnectionError(
514
- f"Robot fault detected: {_cli_monitor._reason or 'RTDE connection lost'}. "
515
- "RTDE connection lost."
516
- )
517
- continue
518
- ch = sys.stdin.read(1)
519
- if ch == "\x1b":
520
- sys.stdout.write("\033[K")
521
- sys.stdout.flush()
522
- return None
523
- elif ch == "\x03": # Ctrl+C
524
- sys.stdout.write("\033[K")
525
- sys.stdout.flush()
526
- return None
527
- elif ch == "\x08" or ch == "\x7f": # Backspace
528
- if name:
529
- name = name[:-1]
530
- sys.stdout.write("\b \b")
531
- sys.stdout.flush()
532
- elif ch == "\n" or ch == "\r":
533
- sys.stdout.write("\033[K")
534
- sys.stdout.flush()
535
- break
536
- elif len(name) < max_len and ch.isprintable():
537
- name += ch
538
- sys.stdout.write(ch)
539
- sys.stdout.flush()
478
+ try:
479
+ while True:
480
+ if not wait_input(0.1):
481
+ if _cli_monitor and _cli_monitor.fault_detected:
482
+ raise URKitConnectionError(
483
+ f"Robot fault detected: {_cli_monitor._reason or 'RTDE connection lost'}. "
484
+ "RTDE connection lost."
485
+ )
486
+ continue
487
+ text = read_burst(0.0).decode("ascii", errors="replace")
488
+ if not text:
489
+ continue
490
+ done = False
491
+ for ch in text:
492
+ if ch == "\x1b" or ch == "\x03": # ESC or Ctrl+C → cancel
493
+ sys.stdout.write("\033[K")
494
+ sys.stdout.flush()
495
+ return None
496
+ elif ch == "\x08" or ch == "\x7f": # Backspace
497
+ if name:
498
+ name = name[:-1]
499
+ sys.stdout.write("\b \b")
500
+ sys.stdout.flush()
501
+ elif ch == "\n" or ch == "\r":
502
+ sys.stdout.write("\033[K")
503
+ sys.stdout.flush()
504
+ done = True
505
+ break
506
+ elif len(name) < max_len and ch.isprintable():
507
+ name += ch
508
+ sys.stdout.write(ch)
509
+ sys.stdout.flush()
510
+ if done:
511
+ break
512
+ finally:
513
+ raw.disable()
540
514
 
541
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
542
515
  sys.stdout.write("\r\033[K\n")
543
516
  sys.stdout.flush()
544
517
  return name.strip()
@@ -606,12 +579,8 @@ def _filter_select_points(
606
579
  cursor = 0
607
580
 
608
581
  # Set terminal to raw mode once, outside the loop
609
- old_settings = termios.tcgetattr(sys.stdin)
610
- new_settings = termios.tcgetattr(sys.stdin)
611
- new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
612
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
613
-
614
- fd = sys.stdin.fileno()
582
+ raw = RawTerminal()
583
+ raw.enable()
615
584
 
616
585
  try:
617
586
  while True:
@@ -645,27 +614,19 @@ def _filter_select_points(
645
614
 
646
615
  sys.stdout.flush()
647
616
 
648
- ready, _, _ = select.select([fd], [], [], 0.1)
649
- if not ready:
617
+ # Wait for input, then read the full burst (e.g. the
618
+ # 3-byte \x1b[A arrow-key sequence) in one go.
619
+ raw_bytes = read_burst(0.1)
620
+ if not raw_bytes:
650
621
  if _cli_monitor and _cli_monitor.fault_detected:
651
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
652
622
  raise URKitConnectionError(
653
623
  f"Robot fault detected: {_cli_monitor._reason or 'RTDE connection lost'}. "
654
624
  "RTDE connection lost."
655
625
  )
656
626
  continue
657
627
 
658
- # Read all bytes that arrived together — avoids Python text-mode
659
- # buffering silently consuming escape sequence bytes.
660
- # After select() says ready, os.read() gets everything the terminal
661
- # sent in one burst (e.g., \x1b[A for up-arrow). A second read
662
- # would block, so we only read once per select() notification.
663
- raw = os.read(fd, 64)
664
- if not raw:
665
- continue
666
-
667
628
  # Decode and parse sequentially, handling multi-byte escape sequences
668
- text = raw.decode("ascii", errors="replace")
629
+ text = raw_bytes.decode("ascii", errors="replace")
669
630
  i = 0
670
631
  while i < len(text):
671
632
  ch = text[i]
@@ -698,7 +659,7 @@ def _filter_select_points(
698
659
 
699
660
  i += 1
700
661
  finally:
701
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
662
+ raw.disable()
702
663
 
703
664
 
704
665
  def _submenu_goto_point(
@@ -764,10 +725,9 @@ def _submenu_goto_point(
764
725
  robot, name, mode_label, start_pose, target_pose, is_cartesian, max_progress
765
726
  )
766
727
  max_progress = pct
767
- ready, _, _ = select.select([sys.stdin], [], [], 0.05)
768
- if ready:
769
- key = sys.stdin.read(1)
770
- if key == " ": # Space to cancel
728
+ if wait_input(0.05):
729
+ key = read_burst(0.0).decode("ascii", errors="replace")
730
+ if " " in key: # Space to cancel
771
731
  cancelled = True
772
732
  break
773
733
 
@@ -905,11 +865,8 @@ def _submenu_orient_tcp(
905
865
  ) -> None:
906
866
  """Interactive submenu to orient TCP along a base-axis direction."""
907
867
  cursor = 0
908
- old_settings = termios.tcgetattr(sys.stdin)
909
- new_settings = termios.tcgetattr(sys.stdin)
910
- new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
911
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
912
- fd = sys.stdin.fileno()
868
+ raw = RawTerminal()
869
+ raw.enable()
913
870
 
914
871
  try:
915
872
  while True:
@@ -926,20 +883,15 @@ def _submenu_orient_tcp(
926
883
  sys.stdout.write(f"\n {dim('Enter')} to orient {green(_TCP_ORIENT_OPTIONS[cursor][0])}\n")
927
884
  sys.stdout.flush()
928
885
 
929
- ready, _, _ = select.select([fd], [], [], 0.1)
930
- if not ready:
886
+ raw_bytes = read_burst(0.1)
887
+ if not raw_bytes:
931
888
  if _cli_monitor and _cli_monitor.fault_detected:
932
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
933
889
  raise URKitConnectionError(
934
890
  f"Robot fault detected: {_cli_monitor._reason or 'RTDE connection lost'}. "
935
891
  "RTDE connection lost."
936
892
  )
937
893
  continue
938
-
939
- raw = os.read(fd, 64)
940
- if not raw:
941
- continue
942
- text = raw.decode("ascii", errors="replace")
894
+ text = raw_bytes.decode("ascii", errors="replace")
943
895
  i = 0
944
896
  selected = None
945
897
  while i < len(text):
@@ -964,7 +916,7 @@ def _submenu_orient_tcp(
964
916
  if selected is not None:
965
917
  break
966
918
  finally:
967
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
919
+ raw.disable()
968
920
 
969
921
  if selected is None:
970
922
  messages.append("Cancelled")
@@ -1019,11 +971,8 @@ def _submenu_freedrive_axes(
1019
971
  axes = [1, 1, 1, 1, 1, 1]
1020
972
 
1021
973
  cursor = 0
1022
- old_settings = termios.tcgetattr(sys.stdin)
1023
- new_settings = termios.tcgetattr(sys.stdin)
1024
- new_settings[3] = new_settings[3] & ~(termios.ICANON | termios.ECHO)
1025
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, new_settings)
1026
- fd = sys.stdin.fileno()
974
+ raw = RawTerminal()
975
+ raw.enable()
1027
976
 
1028
977
  try:
1029
978
  while True:
@@ -1043,14 +992,10 @@ def _submenu_freedrive_axes(
1043
992
  sys.stdout.write(f" {dim('Enter')} to apply · {dim('ESC')} to cancel\n")
1044
993
  sys.stdout.flush()
1045
994
 
1046
- ready, _, _ = select.select([fd], [], [], 0.1)
1047
- if not ready:
1048
- continue
1049
-
1050
- raw = os.read(fd, 64)
1051
- if not raw:
995
+ raw_bytes = read_burst(0.1)
996
+ if not raw_bytes:
1052
997
  continue
1053
- text = raw.decode("ascii", errors="replace")
998
+ text = raw_bytes.decode("ascii", errors="replace")
1054
999
  i = 0
1055
1000
  applied = False
1056
1001
  while i < len(text):
@@ -1077,7 +1022,7 @@ def _submenu_freedrive_axes(
1077
1022
  if applied:
1078
1023
  break
1079
1024
  finally:
1080
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
1025
+ raw.disable()
1081
1026
 
1082
1027
  if sum(axes) == 0:
1083
1028
  messages.append("Cancelled — at least 1 axis must be active")
@@ -1203,11 +1148,11 @@ def _teach_pendant(
1203
1148
  print("Error: stdin is not a terminal. Run from an interactive shell.")
1204
1149
  sys.exit(1)
1205
1150
 
1206
- old_settings = termios.tcgetattr(sys.stdin)
1207
-
1208
1151
  # SIGINT handler: restore terminal and exit when Ctrl+C pressed.
1209
- # tty.setcbreak() keeps ISIG enabled, so Ctrl+C generates SIGINT.
1210
- # os._exit() works even when blocked in C library code.
1152
+ # On Unix, raw mode keeps ISIG enabled, so Ctrl+C generates SIGINT.
1153
+ # On Windows, Ctrl+C arrives as a raw 0x03 byte instead (handled by
1154
+ # the main loop's exit key path). os._exit() works even when blocked
1155
+ # in C library code.
1211
1156
  def _sigint_handler(signum: int, frame: object) -> None:
1212
1157
  try:
1213
1158
  if state["freedrive"]:
@@ -1224,7 +1169,7 @@ def _teach_pendant(
1224
1169
  except Exception:
1225
1170
  pass
1226
1171
  try:
1227
- _restore_terminal(old_settings)
1172
+ _restore_terminal()
1228
1173
  except Exception:
1229
1174
  pass
1230
1175
  sys.stderr.write("\nInterrupted.\n")
@@ -1235,7 +1180,13 @@ def _teach_pendant(
1235
1180
  # Connection watchdog: detects faults and interrupts blocking calls.
1236
1181
  monitor = ConnectionMonitor(robot)
1237
1182
  monitor.start()
1238
- _old_sigalrm = signal.signal(signal.SIGALRM, monitor.alarm_handler)
1183
+ # SIGALRM does not exist on Windows: the monitor sets its
1184
+ # fault_detected flag instead, and the input-wait loops below check
1185
+ # it every 50ms. Faults during long blocking RTDE calls surface
1186
+ # when the call returns rather than being interrupted immediately.
1187
+ _old_sigalrm = None
1188
+ if hasattr(signal, "SIGALRM"):
1189
+ _old_sigalrm = signal.signal(signal.SIGALRM, monitor.alarm_handler)
1239
1190
  global _cli_monitor
1240
1191
  _cli_monitor = monitor # used by _read_input and _filter_select_points
1241
1192
 
@@ -1254,8 +1205,7 @@ def _teach_pendant(
1254
1205
 
1255
1206
  # --- Wait for a key ---
1256
1207
  while True:
1257
- ready, _, _ = select.select([sys.stdin], [], [], 0.05)
1258
- if ready:
1208
+ if wait_input(0.05):
1259
1209
  break
1260
1210
 
1261
1211
  # Periodic refresh while in freedrive — robot may be moving by hand
@@ -1278,17 +1228,10 @@ def _teach_pendant(
1278
1228
  # behind — when the user switches from one movement key to
1279
1229
  # another, the last buffered copy of the old key fires one final
1280
1230
  # move in the wrong direction before the new key is read.
1281
- # Fix: after select() signals ready, drain all immediately
1282
- # available bytes (non-blocking) and use the last one.
1283
- key = ""
1284
- while True:
1285
- ready, _, _ = select.select([sys.stdin], [], [], 0.0)
1286
- if not ready:
1287
- break
1288
- ch = sys.stdin.read(1).lower()
1289
- if not ch:
1290
- break
1291
- key = ch
1231
+ # Fix: drain all immediately available bytes
1232
+ # (non-blocking) and use the last one.
1233
+ key_text = read_burst(0.0).decode("ascii", errors="replace")
1234
+ key = key_text[-1].lower() if key_text else ""
1292
1235
  if not key:
1293
1236
  continue
1294
1237
 
@@ -1550,14 +1493,15 @@ def _teach_pendant(
1550
1493
  finally:
1551
1494
  # Stop monitor and restore signal handler
1552
1495
  monitor.stop()
1553
- signal.signal(signal.SIGALRM, _old_sigalrm)
1496
+ if _old_sigalrm is not None:
1497
+ signal.signal(signal.SIGALRM, _old_sigalrm)
1554
1498
 
1555
1499
  finally:
1556
1500
  _urkit_logger.removeHandler(_log_handler)
1557
1501
  _urkit_logger.propagate = True
1558
1502
  # Restore terminal
1559
1503
  try:
1560
- _restore_terminal(old_settings)
1504
+ _restore_terminal()
1561
1505
  except Exception:
1562
1506
  pass
1563
1507
  print("\n Exiting teach pendant.")
@@ -1570,6 +1514,8 @@ def teach_command(args) -> None:
1570
1514
  args: Parsed arguments from argparse (with teach subcommand attributes).
1571
1515
  """
1572
1516
 
1517
+ warn_if_windows()
1518
+
1573
1519
  # Configure logging
1574
1520
  if args.verbose:
1575
1521
  logging.basicConfig(
@@ -0,0 +1,233 @@
1
+ """Cross-platform raw terminal input for the interactive CLIs.
2
+
3
+ The CLIs need three things from the platform:
4
+
5
+ 1. Raw mode on stdin (no line buffering, no echo) with a way to
6
+ restore the original settings, even on error paths.
7
+ 2. Non-blocking input reads: "is there input?" and "read everything
8
+ currently buffered" (a burst, e.g. the 3-byte arrow-key sequence).
9
+ 3. On Windows only: ANSI escape processing on stdout so colors and
10
+ cursor codes render.
11
+
12
+ Unix backend: termios + tty + select, exactly the syscalls the CLIs
13
+ used to issue directly.
14
+
15
+ Windows backend: msvcrt with console input mode changed via kernel32.
16
+ The console does not emit ANSI sequences for special keys, so the
17
+ backend translates extended keycodes (prefix byte 0xE0/0x00 + scan
18
+ code) into the same byte sequences a Unix terminal sends. This keeps
19
+ the CLI key-parsing code identical on both platforms.
20
+
21
+ Windows limitation: with ENABLE_PROCESSED_INPUT disabled, Ctrl+C is
22
+ delivered as a raw 0x03 byte instead of raising KeyboardInterrupt.
23
+ The CLIs already handle 0x03 as an exit key, and it matters that
24
+ Ctrl+C is a byte rather than an exception here: an exception would
25
+ bypass terminal restore.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import ctypes
31
+ import os
32
+ import sys
33
+ import time
34
+ from typing import Any
35
+
36
+ if sys.platform == "win32":
37
+ import msvcrt
38
+ else:
39
+ import select
40
+ import termios
41
+ import tty
42
+
43
+ if sys.platform == "win32":
44
+ # CDLL is used instead of WinDLL because ctypes.WinDLL is only
45
+ # exposed in the Windows type stubs; on 64-bit Windows both use
46
+ # the same calling convention.
47
+ _kernel32 = ctypes.CDLL("kernel32", use_last_error=True)
48
+ _STD_INPUT_HANDLE = -10
49
+ _STD_OUTPUT_HANDLE = -11
50
+ _ENABLE_PROCESSED_INPUT = 0x0001
51
+ _ENABLE_LINE_INPUT = 0x0002
52
+ _ENABLE_ECHO_INPUT = 0x0004
53
+ _ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
54
+
55
+ # msvcrt extended-key scan codes (after the 0xE0/0x00 prefix byte)
56
+ # mapped to the ANSI CSI sequences a Unix terminal emits for the
57
+ # same key.
58
+ _EXTENDED_KEYS: dict[int, str] = {
59
+ 0x48: "\x1b[A", # Up
60
+ 0x50: "\x1b[B", # Down
61
+ 0x4D: "\x1b[C", # Right
62
+ 0x4B: "\x1b[D", # Left
63
+ 0x47: "\x1b[H", # Home
64
+ 0x4F: "\x1b[F", # End
65
+ 0x49: "\x1b[5~", # Page Up
66
+ 0x51: "\x1b[6~", # Page Down
67
+ 0x52: "\x1b[2~", # Insert
68
+ 0x53: "\x1b[3~", # Delete
69
+ }
70
+
71
+ def _console_mode(handle: int) -> int:
72
+ mode = ctypes.c_uint32()
73
+ if not _kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
74
+ raise OSError(
75
+ "GetConsoleMode failed (stdin is not a console). "
76
+ "Run from an interactive shell."
77
+ )
78
+ return mode.value
79
+
80
+ def _set_console_mode(handle: int, mode: int) -> None:
81
+ if not _kernel32.SetConsoleMode(handle, mode):
82
+ raise OSError("SetConsoleMode failed")
83
+
84
+ def _enable_ansi_output() -> None:
85
+ """Enable ANSI escape processing on stdout.
86
+
87
+ No-op on Windows Terminal and recent conhost; needed on
88
+ older conhost versions. Best effort: some non-console
89
+ stdout handles (redirected output) reject it.
90
+ """
91
+ try:
92
+ handle = _kernel32.GetStdHandle(_STD_OUTPUT_HANDLE)
93
+ mode = _console_mode(handle)
94
+ _set_console_mode(
95
+ handle, mode | _ENABLE_VIRTUAL_TERMINAL_PROCESSING
96
+ )
97
+ except OSError:
98
+ pass
99
+
100
+
101
+ def warn_if_windows() -> None:
102
+ """Print a notice when the interactive CLI runs on Windows.
103
+
104
+ Call at the top of interactive CLI commands. No-op on other
105
+ platforms. Windows CLI support is not fully tested.
106
+ """
107
+ if sys.platform == "win32":
108
+ print(
109
+ "Warning: Windows CLI support is not fully tested and not "
110
+ "officially supported. If you hit input or console problems, "
111
+ "use Linux, macOS, or WSL2."
112
+ )
113
+
114
+
115
+ class RawTerminal:
116
+ """Raw (cbreak, no-echo) mode for stdin.
117
+
118
+ Usage:
119
+ with RawTerminal():
120
+ ...
121
+ or manually, where restore must also happen on explicit error
122
+ paths:
123
+ raw = RawTerminal()
124
+ raw.enable()
125
+ ...
126
+ raw.disable() # idempotent, safe to call multiple times
127
+ """
128
+
129
+ def __init__(self) -> None:
130
+ self._enabled = False
131
+ self._old_settings: list[Any] | None = None
132
+ self._old_mode: int | None = None
133
+ self._stdin_handle: int | None = None
134
+
135
+ def enable(self) -> None:
136
+ """Enter raw mode. Idempotent."""
137
+ if self._enabled:
138
+ return
139
+ if sys.platform == "win32":
140
+ self._stdin_handle = _kernel32.GetStdHandle(_STD_INPUT_HANDLE)
141
+ self._old_mode = _console_mode(self._stdin_handle)
142
+ # Disable processed input, line input, and echo. Ctrl+C
143
+ # arrives as raw byte 0x03 (handled by the CLIs) instead
144
+ # of KeyboardInterrupt, which would skip terminal restore.
145
+ _set_console_mode(
146
+ self._stdin_handle,
147
+ self._old_mode
148
+ & ~(
149
+ _ENABLE_PROCESSED_INPUT
150
+ | _ENABLE_LINE_INPUT
151
+ | _ENABLE_ECHO_INPUT
152
+ ),
153
+ )
154
+ _enable_ansi_output()
155
+ else:
156
+ self._old_settings = termios.tcgetattr(sys.stdin)
157
+ tty.setcbreak(sys.stdin.fileno())
158
+ self._enabled = True
159
+
160
+ def disable(self) -> None:
161
+ """Restore the original terminal settings. Idempotent."""
162
+ if not self._enabled:
163
+ return
164
+ if sys.platform == "win32":
165
+ if self._stdin_handle is None or self._old_mode is None:
166
+ raise RuntimeError("RawTerminal disabled before enable()")
167
+ _set_console_mode(self._stdin_handle, self._old_mode)
168
+ else:
169
+ if self._old_settings is None:
170
+ raise RuntimeError("RawTerminal disabled before enable()")
171
+ termios.tcsetattr(
172
+ sys.stdin, termios.TCSADRAIN, self._old_settings
173
+ )
174
+ self._enabled = False
175
+
176
+ def __enter__(self) -> "RawTerminal":
177
+ self.enable()
178
+ return self
179
+
180
+ def __exit__(self, *exc: object) -> None:
181
+ self.disable()
182
+
183
+
184
+ def _win_read_pending() -> bytes:
185
+ """Read all pending console input, translating extended keys to
186
+ the ANSI CSI sequences a Unix terminal would have emitted."""
187
+ if sys.platform == "win32":
188
+ out = bytearray()
189
+ while msvcrt.kbhit():
190
+ byte = msvcrt.getch()
191
+ if byte in (b"\xe0", b"\x00"):
192
+ code = msvcrt.getch()
193
+ seq = _EXTENDED_KEYS.get(code)
194
+ if seq is None:
195
+ # Unrecognized function key (F1-F12 etc.): drop it
196
+ continue
197
+ out += seq.encode("ascii")
198
+ else:
199
+ out += byte
200
+ return bytes(out)
201
+ raise RuntimeError("_win_read_pending is Windows-only")
202
+
203
+
204
+ def wait_input(timeout: float) -> bool:
205
+ """Return True if any stdin input is available within timeout."""
206
+ if sys.platform == "win32":
207
+ deadline = time.monotonic() + timeout
208
+ while not msvcrt.kbhit():
209
+ if time.monotonic() >= deadline:
210
+ return False
211
+ time.sleep(0.005)
212
+ return True
213
+ ready, _, _ = select.select([sys.stdin], [], [], timeout)
214
+ return bool(ready)
215
+
216
+
217
+ def read_burst(timeout: float) -> bytes:
218
+ """Wait up to timeout for input, then return everything currently
219
+ buffered as one burst (e.g. the full \\x1b[A arrow-key sequence).
220
+
221
+ Returns b"" if no input arrives within timeout.
222
+
223
+ On Unix this is select() + a single os.read(), the same burst
224
+ semantics as reading once after a select() notification. On
225
+ Windows every pending key event is consumed.
226
+ """
227
+ if sys.platform == "win32":
228
+ if not wait_input(timeout):
229
+ return b""
230
+ return _win_read_pending()
231
+ if not wait_input(timeout):
232
+ return b""
233
+ return os.read(sys.stdin.fileno(), 64)
@@ -14,11 +14,14 @@ from urkit.geometry import MoveFrame, transform_pose_delta
14
14
 
15
15
  @dataclass(frozen=True, slots=True)
16
16
  class Point:
17
- """A robot waypoint stored as a TCP pose.
17
+ """A robot waypoint stored as a TCP pose in the base frame.
18
18
 
19
- Stores the TCP pose (from ``getActualTCPPose()``). The UR controller
20
- interprets the stored pose in whatever TCP frame is active at playback
21
- time, making points tool-agnostic by design.
19
+ Stores the TCP pose from ``getActualTCPPose()`` as an absolute
20
+ position in the robot base frame. When passed to ``moveL()`` or
21
+ ``moveJ()``, the controller moves whatever TCP is currently active
22
+ to that base-frame position. This means saved points remain valid
23
+ after TCP changes: the tool tip arrives at the same physical
24
+ location regardless of which tool is mounted.
22
25
 
23
26
  Returned by Points attribute/subscript access. Pass directly
24
27
  to URRobot.move_to(). Points loaded from the database carry a
@@ -26,7 +29,7 @@ class Point:
26
29
 
27
30
  Fields:
28
31
  name: Point name (key in the database, empty string for ad-hoc points).
29
- pose: TCP pose [x, y, z, rx, ry, rz] in meters/radians.
32
+ pose: TCP pose [x, y, z, rx, ry, rz] in base frame, meters/radians.
30
33
  """
31
34
 
32
35
  name: str
@@ -1891,19 +1891,20 @@ class URRobot:
1891
1891
  """Save a named point.
1892
1892
 
1893
1893
  When *pose* is omitted, saves the current TCP pose from
1894
- getActualTCPPose(). When *pose* is provided, saves that
1894
+ ``getActualTCPPose()``. When *pose* is provided, saves that
1895
1895
  arbitrary pose directly without moving the robot.
1896
1896
 
1897
- The UR controller interprets the stored pose in whatever TCP
1898
- frame is active at playback time, making points tool-agnostic
1899
- by design.
1897
+ The pose is stored as an absolute position in the robot base
1898
+ frame. At playback, ``moveL()`` moves whatever TCP is currently
1899
+ active to that base-frame position, so saved points remain
1900
+ valid after TCP changes.
1900
1901
 
1901
1902
  Overwrites if a point with the same name already exists.
1902
1903
 
1903
1904
  Args:
1904
1905
  name: Name for the saved point.
1905
- pose: TCP pose [x, y, z, rx, ry, rz]. Defaults to current
1906
- robot position.
1906
+ pose: TCP pose [x, y, z, rx, ry, rz] in base frame.
1907
+ Defaults to current robot position.
1907
1908
 
1908
1909
  Returns:
1909
1910
  The saved Point object.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: urkit
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Universal Robots e-Series control toolkit built on ur_rtde
5
5
  Author: URKit Contributors
6
6
  License: MIT
@@ -94,6 +94,11 @@ The `-U` (upgrade) flag ensures you always get the latest version. This project
94
94
 
95
95
  Requires Python 3.8+ and a Universal Robots e-Series (UR3e to UR30).
96
96
 
97
+ ### Platform Support
98
+
99
+ - **Linux, macOS**: fully supported
100
+ - **Windows**: the Python library works on Windows. The interactive CLIs (`urkit teach`, `urkit points`) run on Windows but are **not fully tested and not officially supported**. A warning is printed at startup. If you hit input or console problems, use Linux, macOS, or WSL2
101
+
97
102
  ### Robot Setup (one-time)
98
103
 
99
104
  1. **Network**: Connect your PC to the robot via Ethernet cable (direct) or a switch.
@@ -663,7 +668,7 @@ Default is `None` (controller handles IK natively). Set it globally when most of
663
668
 
664
669
  #### Point Management
665
670
 
666
- Points are stored in the active TCP frame, so they work with any tool. Swap grippers and your saved points stay valid.
671
+ Points are stored as absolute TCP poses in the robot base frame. When you move to a point, the controller moves whatever TCP is currently active to that base-frame pose, so the tool tip lands at the same physical location. Change the TCP offset (or swap grippers) and your saved points stay valid.
667
672
 
668
673
  ```python
669
674
  robot.save_point("here")
@@ -23,6 +23,7 @@ src/urkit/cli/connection_monitor.py
23
23
  src/urkit/cli/init.py
24
24
  src/urkit/cli/points.py
25
25
  src/urkit/cli/teach.py
26
+ src/urkit/cli/terminal.py
26
27
  src/urkit/gripper/__init__.py
27
28
  src/urkit/gripper/base.py
28
29
  src/urkit/gripper/digital.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes