Skip to content

Commit 7e33c73

Browse files
authored
feat(ur): complete RTDE driver with blocking motion and full telemetry (#61)
- Block move_joints/move_cartesian via output_int_registers_0; URScript bookends each command with write_output_integer_register(0,0/1) and RTDE polls for completion — eliminates post-exit motion - Call stop_motion() in disconnect() to halt on any clean exit - Add codes.py with RobotMode, SafetyMode, RuntimeState IntEnums - Expand config.xml: velocity, target pose/speed, I/O, state fields, input recipe for speed slider and digital/analog output writes - Add get/set methods for all new telemetry in UniversalRobots - Fix sleep() to block Python side, not just controller side - Fix ImportError message spacing in RTDE.__init__
1 parent d626f8a commit 7e33c73

5 files changed

Lines changed: 542 additions & 156 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from __future__ import annotations
2+
3+
from enum import IntEnum
4+
5+
6+
class RobotMode(IntEnum):
7+
NO_CONTROLLER = -1
8+
DISCONNECTED = 0
9+
CONFIRM_SAFETY = 1
10+
BOOTING = 2
11+
POWER_OFF = 3
12+
POWER_ON = 4
13+
IDLE = 5
14+
BACKDRIVE = 6
15+
RUNNING = 7
16+
UPDATING_FIRMWARE = 8
17+
18+
19+
class SafetyMode(IntEnum):
20+
NORMAL = 1
21+
REDUCED = 2
22+
PROTECTIVE_STOP = 3
23+
RECOVERY = 4
24+
SAFEGUARD_STOP = 5
25+
SYSTEM_EMERGENCY_STOP = 6
26+
ROBOT_EMERGENCY_STOP = 7
27+
VIOLATION = 8
28+
FAULT = 9
29+
30+
31+
class RuntimeState(IntEnum):
32+
STOPPED = 0
33+
PLAYING = 1
34+
PAUSED = 2
35+
PAUSING = 3
36+
RESUMING = 4
Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,51 @@
11
<?xml version="1.0" encoding="UTF-8"?>
22
<rtde_config>
33
<recipe key="out">
4-
<field name="actual_TCP_pose" type="VECTOR6D" />
4+
<!-- Pose / kinematics -->
55
<field name="actual_q" type="VECTOR6D" />
6+
<field name="actual_qd" type="VECTOR6D" />
7+
<field name="actual_current" type="VECTOR6D" />
68
<field name="actual_current_as_torque" type="VECTOR6D" />
9+
<field name="actual_TCP_pose" type="VECTOR6D" />
10+
<field name="actual_TCP_speed" type="VECTOR6D" />
711
<field name="actual_TCP_force" type="VECTOR6D" />
12+
<field name="target_q" type="VECTOR6D" />
13+
<field name="target_qd" type="VECTOR6D" />
14+
<field name="target_TCP_pose" type="VECTOR6D" />
15+
<field name="target_TCP_speed" type="VECTOR6D" />
16+
<!-- Controller state -->
17+
<field name="robot_mode" type="INT32" />
18+
<field name="safety_mode" type="INT32" />
19+
<field name="runtime_state" type="UINT32" />
820
<field name="robot_status_bits" type="UINT32" />
921
<field name="safety_status_bits" type="UINT32" />
22+
<field name="speed_scaling" type="DOUBLE" />
23+
<field name="payload" type="DOUBLE" />
24+
<field name="payload_cog" type="VECTOR3D" />
25+
<!-- Standard I/O -->
26+
<field name="standard_analog_input0" type="DOUBLE" />
27+
<field name="standard_analog_input1" type="DOUBLE" />
28+
<field name="standard_analog_output0" type="DOUBLE" />
29+
<field name="standard_analog_output1" type="DOUBLE" />
30+
<field name="standard_digital_input_bits" type="UINT32" />
31+
<field name="standard_digital_output_bits" type="UINT32" />
32+
<!-- Completion register -->
33+
<field name="output_int_registers_0" type="INT32" />
34+
<!-- Tool I/O -->
35+
<field name="tool_analog_input0" type="DOUBLE" />
36+
<field name="tool_analog_input1" type="DOUBLE" />
37+
<field name="tool_output_voltage" type="INT32" />
38+
<field name="tool_output_current" type="DOUBLE" />
39+
<field name="tool_temperature" type="DOUBLE" />
40+
</recipe>
41+
<recipe key="in">
42+
<field name="speed_slider_mask" type="UINT32" />
43+
<field name="speed_slider_fraction" type="DOUBLE" />
44+
<field name="standard_digital_output_mask" type="UINT32" />
45+
<field name="standard_digital_output" type="UINT32" />
46+
<field name="standard_analog_output_mask" type="UINT32" />
47+
<field name="standard_analog_output_type" type="UINT32" />
48+
<field name="standard_analog_output_0" type="DOUBLE" />
49+
<field name="standard_analog_output_1" type="DOUBLE" />
1050
</recipe>
11-
</rtde_config>
51+
</rtde_config>

armctl/universal_robots/protocols/rtde.py

Lines changed: 210 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
from __future__ import annotations
22

3+
import time
4+
from ctypes import c_uint32
35
from pathlib import Path
46
from typing import NewType
5-
from ctypes import c_uint32
7+
8+
from .codes import RobotMode, RuntimeState, SafetyMode
69

710
try:
811
from rtde import RTDE as _RTDE
@@ -13,22 +16,26 @@
1316

1417
u32 = NewType("u32", c_uint32)
1518

19+
_MOVING_VELOCITY_THRESHOLD = 1e-3 # rad/s — below this on all joints = stopped
20+
1621

1722
class RTDE:
1823
def __init__(self, ip: str):
1924
if _RTDE is None or ConfigFile is None:
2025
raise ImportError(
21-
"Universal Robots RTDE support requires additional dependencies."
26+
"Universal Robots RTDE support requires additional dependencies. "
2227
"Install it with: pip install armctl[ur]"
2328
)
2429

2530
config_file = Path(__file__).parent / "config.xml"
2631
config = ConfigFile(str(config_file))
27-
state_names, state_types = config.get_recipe("out")
32+
out_names, out_types = config.get_recipe("out")
33+
in_names, in_types = config.get_recipe("in")
2834

2935
self.c = _RTDE(ip)
3036
self.c.connect()
31-
self.c.send_output_setup(state_names, state_types)
37+
self.c.send_output_setup(out_names, out_types)
38+
self._input = self.c.send_input_setup(in_names, in_types)
3239
self.controller_version = (
3340
self.c.get_controller_version()
3441
) # (MAJOR, MINOR, BUGFIX, BUILD)
@@ -40,29 +47,71 @@ def _get_data(self):
4047
return self.c.receive()
4148

4249
def joint_angles(self) -> list[float]:
43-
"""Return joint angles in radians."""
50+
"""Return actual joint angles in radians."""
4451
return list(self._get_data().actual_q)
4552

46-
def tcp_pose(self) -> list[float]:
47-
"""Return TCP pose [x, y, z, rx, ry, rz]."""
48-
return list(self._get_data().actual_TCP_pose)
53+
def joint_velocities(self) -> list[float]:
54+
"""Return actual joint velocities in rad/s."""
55+
return list(self._get_data().actual_qd)
56+
57+
def joint_currents(self) -> list[float]:
58+
"""Return actual joint currents in Amperes."""
59+
return list(self._get_data().actual_current)
4960

5061
def joint_torques(self) -> list[float]:
51-
"""Return joint torques in Nm converted from current."""
52-
# See: https://www.universal-robots.com/articles/ur/release-notes/release-note-software-version-523x/
62+
"""Return joint torques in Nm converted from current.
63+
64+
Requires controller >= 5.23.0.0.
65+
See: https://www.universal-robots.com/articles/ur/release-notes/release-note-software-version-523x/
66+
"""
5367
if self.controller_version >= (5, 23, 0, 0):
5468
return list(self._get_data().actual_current_as_torque)
55-
else:
56-
raise NotImplementedError(
57-
"Joint torques not available for controller versions below 5.23.0.0"
58-
)
69+
raise NotImplementedError(
70+
"Joint torques not available for controller versions below 5.23.0.0"
71+
)
72+
73+
def tcp_pose(self) -> list[float]:
74+
"""Return actual TCP pose [x, y, z, rx, ry, rz] in metres and radians."""
75+
return list(self._get_data().actual_TCP_pose)
76+
77+
def tcp_speed(self) -> list[float]:
78+
"""Return actual TCP speed [vx, vy, vz, wx, wy, wz] in m/s and rad/s."""
79+
return list(self._get_data().actual_TCP_speed)
5980

6081
def tcp_force(self) -> list[float]:
61-
"""Return TCP force [Fx, Fy, Fz, Tx, Ty, Tz] in Newton and Newton-meters."""
82+
"""Return TCP force [Fx, Fy, Fz, Tx, Ty, Tz] in Newton and Newton-metres."""
6283
return list(self._get_data().actual_TCP_force)
6384

85+
def target_joint_positions(self) -> list[float]:
86+
"""Return target joint positions in radians."""
87+
return list(self._get_data().target_q)
88+
89+
def target_joint_velocities(self) -> list[float]:
90+
"""Return target joint velocities in rad/s."""
91+
return list(self._get_data().target_qd)
92+
93+
def target_tcp_pose(self) -> list[float]:
94+
"""Return target TCP pose [x, y, z, rx, ry, rz] in metres and radians."""
95+
return list(self._get_data().target_TCP_pose)
96+
97+
def target_tcp_speed(self) -> list[float]:
98+
"""Return target TCP speed [vx, vy, vz, wx, wy, wz] in m/s and rad/s."""
99+
return list(self._get_data().target_TCP_speed)
100+
101+
def robot_mode(self) -> RobotMode:
102+
"""Return robot mode as a RobotMode enum."""
103+
return RobotMode(self._get_data().robot_mode)
104+
105+
def safety_mode(self) -> SafetyMode:
106+
"""Return safety mode as a SafetyMode enum."""
107+
return SafetyMode(self._get_data().safety_mode)
108+
109+
def runtime_state(self) -> RuntimeState:
110+
"""Return runtime state as a RuntimeState enum."""
111+
return RuntimeState(self._get_data().runtime_state)
112+
64113
def robot_status(self) -> dict[str, bool]:
65-
"""Return robot status.
114+
"""Return robot and safety status bit fields.
66115
67116
Robot status bits (UINT32):
68117
- **`Bit 0`**: Is power on
@@ -108,3 +157,148 @@ def robot_status(self) -> dict[str, bool]:
108157
"Fault": bit(ssb, 9),
109158
"Stopped Due to Safety": bit(ssb, 10),
110159
}
160+
161+
def speed_scaling(self) -> float:
162+
"""Return current speed scaling factor in [0, 1]."""
163+
return float(self._get_data().speed_scaling)
164+
165+
def payload(self) -> dict:
166+
"""Return payload mass (kg) and centre of gravity (m)."""
167+
data = self._get_data()
168+
return {
169+
"mass_kg": float(data.payload),
170+
"cog": list(data.payload_cog),
171+
}
172+
173+
def analog_inputs(self) -> dict[str, float]:
174+
"""Return standard and tool analog input values (V or mA)."""
175+
data = self._get_data()
176+
return {
177+
"standard_0": float(data.standard_analog_input0),
178+
"standard_1": float(data.standard_analog_input1),
179+
"tool_0": float(data.tool_analog_input0),
180+
"tool_1": float(data.tool_analog_input1),
181+
}
182+
183+
def analog_outputs(self) -> dict[str, float]:
184+
"""Return standard analog output values (V or mA)."""
185+
data = self._get_data()
186+
return {
187+
"standard_0": float(data.standard_analog_output0),
188+
"standard_1": float(data.standard_analog_output1),
189+
}
190+
191+
def digital_inputs(self) -> dict[str, bool]:
192+
"""Return digital input states.
193+
194+
Bits 0-7: Standard DI 0-7
195+
Bits 8-15: Configurable DI 0-7
196+
Bits 16-17: Tool DI 0-1
197+
"""
198+
bits = self._get_data().standard_digital_input_bits
199+
bit = lambda n: bool(bits & (1 << n))
200+
return {
201+
**{f"DI{i}": bit(i) for i in range(8)},
202+
**{f"CDI{i}": bit(i + 8) for i in range(8)},
203+
"Tool_DI0": bit(16),
204+
"Tool_DI1": bit(17),
205+
}
206+
207+
def digital_outputs(self) -> dict[str, bool]:
208+
"""Return digital output states.
209+
210+
Bits 0-7: Standard DO 0-7
211+
Bits 8-15: Configurable DO 0-7
212+
Bits 16-17: Tool DO 0-1
213+
"""
214+
bits = self._get_data().standard_digital_output_bits
215+
bit = lambda n: bool(bits & (1 << n))
216+
return {
217+
**{f"DO{i}": bit(i) for i in range(8)},
218+
**{f"CDO{i}": bit(i + 8) for i in range(8)},
219+
"Tool_DO0": bit(16),
220+
"Tool_DO1": bit(17),
221+
}
222+
223+
def tool_io(self) -> dict:
224+
"""Return tool I/O state."""
225+
data = self._get_data()
226+
return {
227+
"analog_input_0": float(data.tool_analog_input0),
228+
"analog_input_1": float(data.tool_analog_input1),
229+
"output_voltage": int(data.tool_output_voltage),
230+
"output_current": float(data.tool_output_current),
231+
"temperature": float(data.tool_temperature),
232+
}
233+
234+
def is_moving(self, threshold: float = _MOVING_VELOCITY_THRESHOLD) -> bool:
235+
"""Return True if any joint velocity exceeds threshold (rad/s)."""
236+
return any(abs(v) > threshold for v in self._get_data().actual_qd)
237+
238+
def wait_until_stopped(
239+
self, timeout: float = 120.0, poll_interval: float = 0.05
240+
) -> None:
241+
"""Block until all joints are stopped or timeout expires.
242+
243+
Waits for motion to begin, then polls joint velocities via RTDE
244+
until all fall below _MOVING_VELOCITY_THRESHOLD.
245+
246+
Parameters
247+
----------
248+
timeout : float
249+
Maximum seconds to wait after motion starts.
250+
poll_interval : float
251+
RTDE poll interval in seconds.
252+
253+
Raises
254+
------
255+
TimeoutError
256+
If the robot does not stop within timeout.
257+
"""
258+
deadline = time.time() + timeout
259+
while time.time() < deadline:
260+
if self._get_data().output_int_registers_0 == 1:
261+
return
262+
time.sleep(poll_interval)
263+
raise TimeoutError(f"Robot did not stop within {timeout}s")
264+
265+
def set_speed_slider(self, fraction: float) -> None:
266+
"""Set speed override slider fraction in [0.0, 1.0]."""
267+
if not 0.0 <= fraction <= 1.0:
268+
raise ValueError(
269+
f"Speed fraction must be in [0, 1], got {fraction}"
270+
)
271+
self._input.speed_slider_mask = 1
272+
self._input.speed_slider_fraction = fraction
273+
self.c.send(self._input)
274+
275+
def set_digital_output(self, pin: int, value: bool) -> None:
276+
"""Set a standard digital output pin (0-7) high or low."""
277+
if not 0 <= pin <= 7:
278+
raise ValueError(f"Digital output pin must be 0-7, got {pin}")
279+
mask = 1 << pin
280+
self._input.standard_digital_output_mask = mask
281+
self._input.standard_digital_output = mask if value else 0
282+
self.c.send(self._input)
283+
284+
def set_analog_output(self, channel: int, value: float) -> None:
285+
"""Set standard analog output voltage (channel 0 or 1).
286+
287+
Parameters
288+
----------
289+
channel : int
290+
Output channel — 0 or 1.
291+
value : float
292+
Output value in Volts [0, 10] (voltage mode).
293+
"""
294+
if channel not in (0, 1):
295+
raise ValueError(
296+
f"Analog output channel must be 0 or 1, got {channel}"
297+
)
298+
self._input.standard_analog_output_mask = 1 << channel
299+
self._input.standard_analog_output_type = 0 # 0 = voltage mode
300+
if channel == 0:
301+
self._input.standard_analog_output_0 = value
302+
else:
303+
self._input.standard_analog_output_1 = value
304+
self.c.send(self._input)

0 commit comments

Comments
 (0)