Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:

- name: Install dependencies
run: |
uv sync --locked --all-groups --extra zaber
uv sync --locked --all-groups --all-extras

- name: Clear all caches and compiled files
run: |
Expand Down
8 changes: 1 addition & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,8 @@ The `armctl` library currently supports the following manufacturers and robot mo
pip install armctl
```

> **Note:** Universal Robots (UR) and Zaber support require optional extras:
> **Note:** Zaber support requires the optional extra:
> ```bash
> pip install armctl[ur]
> pip install armctl[zaber]
> ```

Expand Down Expand Up @@ -342,8 +341,3 @@ Please feel free to submit a pull request or open an issue for any enhancements
## License

This project is licensed under the MIT License. See the [LICENSE](https://github.com/MGross21/armctl/blob/main/LICENSE) file for more details.

### Notice

> This package automatically installs the [Universal Robots RTDE Python Client Library](https://github.com/UniversalRobots/RTDE_Python_Client_Library) when needed.
> The RTDE library is provided by Universal Robots and is subject to their licensing terms.
74 changes: 74 additions & 0 deletions armctl/universal_robots/protocols/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import ctypes


class Recipe(ctypes.BigEndianStructure):
"""RTDE data package: recipe id followed by the recipe's fields in order."""

_pack_ = 1
_layout_ = "ms"
_fields_ = [("recipe_id", ctypes.c_uint8)]

@classmethod
def names(cls) -> list[str]:
return [field[0] for field in cls._fields_]

@classmethod
def rtde_types(cls) -> list[str]:
def rtde_type(ctype) -> str:
if issubclass(ctype, ctypes.Array):
return f"VECTOR{ctype._length_}D"
if ctype._type_ == "d":
return "DOUBLE"
prefix = "INT" if ctype._type_.islower() else "UINT"
return f"{prefix}{ctypes.sizeof(ctype) * 8}"

return [rtde_type(field[1]) for field in cls._fields_]


class OutputData(Recipe):
_fields_ = [
("actual_q", ctypes.c_double * 6),
("actual_qd", ctypes.c_double * 6),
("actual_current", ctypes.c_double * 6),
("actual_current_as_torque", ctypes.c_double * 6),
("actual_TCP_pose", ctypes.c_double * 6),
("actual_TCP_speed", ctypes.c_double * 6),
("actual_TCP_force", ctypes.c_double * 6),
("target_q", ctypes.c_double * 6),
("target_qd", ctypes.c_double * 6),
("target_TCP_pose", ctypes.c_double * 6),
("target_TCP_speed", ctypes.c_double * 6),
("robot_mode", ctypes.c_int32),
("safety_mode", ctypes.c_int32),
("runtime_state", ctypes.c_uint32),
("robot_status_bits", ctypes.c_uint32),
("safety_status_bits", ctypes.c_uint32),
("speed_scaling", ctypes.c_double),
("payload", ctypes.c_double),
("payload_cog", ctypes.c_double * 3),
("standard_analog_input0", ctypes.c_double),
("standard_analog_input1", ctypes.c_double),
("standard_analog_output0", ctypes.c_double),
("standard_analog_output1", ctypes.c_double),
("actual_digital_input_bits", ctypes.c_uint64),
("actual_digital_output_bits", ctypes.c_uint64),
("output_int_register_0", ctypes.c_int32),
("tool_analog_input0", ctypes.c_double),
("tool_analog_input1", ctypes.c_double),
("tool_output_voltage", ctypes.c_int32),
("tool_output_current", ctypes.c_double),
("tool_temperature", ctypes.c_double),
]


class InputData(Recipe):
_fields_ = [
("speed_slider_mask", ctypes.c_uint32),
("speed_slider_fraction", ctypes.c_double),
("standard_digital_output_mask", ctypes.c_uint8),
("standard_digital_output", ctypes.c_uint8),
("standard_analog_output_mask", ctypes.c_uint8),
("standard_analog_output_type", ctypes.c_uint8),
("standard_analog_output_0", ctypes.c_double),
("standard_analog_output_1", ctypes.c_double),
]
51 changes: 0 additions & 51 deletions armctl/universal_robots/protocols/config.xml

This file was deleted.

95 changes: 40 additions & 55 deletions armctl/universal_robots/protocols/rtde.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,26 @@
from __future__ import annotations

import time
from ctypes import c_uint32
from pathlib import Path
from typing import NewType

from .codes import RobotMode, RuntimeState, SafetyMode

try:
from rtde import RTDE as _RTDE
from rtde.rtde_config import ConfigFile
except ImportError:
_RTDE = None
ConfigFile = None

u32 = NewType("u32", c_uint32)

_MOVING_VELOCITY_THRESHOLD = 1e-3 # rad/s — below this on all joints = stopped
from .rtde_core import RTDEClient


class RTDE:
def __init__(self, ip: str):
if _RTDE is None or ConfigFile is None:
raise ImportError(
"Universal Robots RTDE support requires additional dependencies. "
"Install it with: pip install armctl[ur]"
)

config_file = Path(__file__).parent / "config.xml"
config = ConfigFile(str(config_file))
out_names, out_types = config.get_recipe("out")
in_names, in_types = config.get_recipe("in")

self.c = _RTDE(ip)
self.c.connect()
self.c.send_output_setup(out_names, out_types)
self._input = self.c.send_input_setup(in_names, in_types)
self.controller_version = self.c.get_controller_version() # (MAJOR, MINOR, BUGFIX, BUILD)
self.c.send_start()
self._client = RTDEClient(ip)
self._client.connect()
self._client.send_output_setup()
self._input = self._client.send_input_setup()
# (MAJOR, MINOR, BUGFIX, BUILD)
self.controller_version = self._client.get_controller_version()
self._client.send_start()

def disconnect(self) -> None:
self._client.disconnect()

def _get_data(self):
if not self.c.is_connected():
raise ConnectionError("RTDE connection has been lost.")
return self.c.receive()
return self._client.receive()

def joint_angles(self) -> list[float]:
"""Return actual joint angles in radians."""
Expand Down Expand Up @@ -131,8 +109,8 @@ def robot_status(self) -> dict[str, bool]:
- **`Bit 10`**: Is stopped due to safety
"""
data = self._get_data()
rsb: u32 = data.robot_status_bits
ssb: u32 = data.safety_status_bits
rsb = data.robot_status_bits
ssb = data.safety_status_bits

def bit(data, n):
return bool(data & (1 << n))
Expand Down Expand Up @@ -194,7 +172,7 @@ def digital_inputs(self) -> dict[str, bool]:
Bits 8-15: Configurable DI 0-7
Bits 16-17: Tool DI 0-1
"""
bits = self._get_data().standard_digital_input_bits
bits = self._get_data().actual_digital_input_bits

def bit(n):
return bool(bits & (1 << n))
Expand All @@ -213,7 +191,7 @@ def digital_outputs(self) -> dict[str, bool]:
Bits 8-15: Configurable DO 0-7
Bits 16-17: Tool DO 0-1
"""
bits = self._get_data().standard_digital_output_bits
bits = self._get_data().actual_digital_output_bits

def bit(n):
return bool(bits & (1 << n))
Expand All @@ -236,42 +214,47 @@ def tool_io(self) -> dict:
"temperature": float(data.tool_temperature),
}

def is_moving(self, threshold: float = _MOVING_VELOCITY_THRESHOLD) -> bool:
def is_moving(self, threshold: float = 1e-3) -> bool:
"""Return True if any joint velocity exceeds threshold (rad/s)."""
return any(abs(v) > threshold for v in self._get_data().actual_qd)

def wait_until_stopped(self, timeout: float = 120.0, poll_interval: float = 0.05) -> None:
"""Block until all joints are stopped or timeout expires.
def get_last_move_seqno(self) -> int:
"""Return the sequence number of the last completed move program."""
return self._get_data().output_int_register_0

Waits for motion to begin, then polls joint velocities via RTDE
until all fall below _MOVING_VELOCITY_THRESHOLD.
def wait_for_move(
self, seqno: int, timeout: float = 120.0, poll_interval: float = 0.05
) -> None:
"""Block until the move program writes seqno to output int register 0.

Parameters
----------
seqno : int
Sequence number written by the move program on completion.
timeout : float
Maximum seconds to wait after motion starts.
Maximum seconds to wait.
poll_interval : float
RTDE poll interval in seconds.

Raises
------
TimeoutError
If the robot does not stop within timeout.
If the move does not finish within timeout.
"""
deadline = time.time() + timeout
while time.time() < deadline:
if self._get_data().output_int_registers_0 == 1:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.get_last_move_seqno() == seqno:
return
time.sleep(poll_interval)
raise TimeoutError(f"Robot did not stop within {timeout}s")
raise TimeoutError(f"Move {seqno} did not finish within {timeout}s")

def set_speed_slider(self, fraction: float) -> None:
"""Set speed override slider fraction in [0.0, 1.0]."""
if not 0.0 <= fraction <= 1.0:
raise ValueError(f"Speed fraction must be in [0, 1], got {fraction}")
self._input.speed_slider_mask = 1
self._input.speed_slider_fraction = fraction
self.c.send(self._input)
self._client.send(self._input)

def set_digital_output(self, pin: int, value: bool) -> None:
"""Set a standard digital output pin (0-7) high or low."""
Expand All @@ -280,7 +263,7 @@ def set_digital_output(self, pin: int, value: bool) -> None:
mask = 1 << pin
self._input.standard_digital_output_mask = mask
self._input.standard_digital_output = mask if value else 0
self.c.send(self._input)
self._client.send(self._input)

def set_analog_output(self, channel: int, value: float) -> None:
"""Set standard analog output voltage (channel 0 or 1).
Expand All @@ -294,10 +277,12 @@ def set_analog_output(self, channel: int, value: float) -> None:
"""
if channel not in (0, 1):
raise ValueError(f"Analog output channel must be 0 or 1, got {channel}")
if not 0.0 <= value <= 10.0:
raise ValueError(f"Analog output voltage must be in [0, 10], got {value}")
self._input.standard_analog_output_mask = 1 << channel
self._input.standard_analog_output_type = 0 # 0 = voltage mode
self._input.standard_analog_output_type = 1 << channel # set bit = voltage mode
if channel == 0:
self._input.standard_analog_output_0 = value
self._input.standard_analog_output_0 = value / 10.0 # 0..1 ratio
else:
self._input.standard_analog_output_1 = value
self.c.send(self._input)
self._input.standard_analog_output_1 = value / 10.0
self._client.send(self._input)
Loading
Loading